All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[1.4.0] — October 23rd, 2026

Release notes

  1. Built on CLDR 49 and Unicode 18. CLDR 49 no longer publishes 114 locales below Basic coverage, collation follows UCA 18, and its new data is supported, from dual and UTC offset formats to new units and the Unicode 18 currency signs.

  2. Significant work on date and time formatting towards TR35: semantic skeletons, append items, qualified metazone names, cyclic years, and a calendar's own weeks and quarters. Many outputs change; see the breaking changes below.

  3. Date, time, date-time and interval parsing move from Calendrical into Localize, so an application that uses only the proleptic Gregorian calendar no longer needs Calendrical.

  4. Time zones are parsed and resolved by Localize.DateTime.Timezone.parse_zone/2, resolve/3 and parse_offset/2, and Localize.TimeZoneDatabase handles fixed offsets.

  5. Calendars other than Calendar.ISO come from Calendrical 2.0, which ships the same day, and a locale's -u-ca- converts a value into its calendar.

  6. Localize.Inflection is added as a technical preview: inflected words, pronouns and quantities, MessageFormat 2's i: functions and inflected units.

  7. CLDR 49's technical previews are supported and marked as such: ordinal days, numeric date and time separators, the c12 and c24 hour cycles, semantic skeletons and rational number formats.

  8. Number parsing reads currency amounts and a locale's alternative separators (:currency, :separators).

  9. Locale data is verifiable and self-hostable: mix localize.verify_locales, Localize.cldr_repo_ref/0 and a self-hosting guide.

  10. Public functions return {:error, exception} for arguments and options of the wrong type, where they previously raised.

Breaking changes

Localize 1.4 includes a significant number of breaking changes but they are all a result of improved conformance with TR35 and would not, in typical use, be expected to be observed in the majority of current use cases.

Calendars

  • A calendar other than Calendar.ISO must implement the Calendrical 2.0 behaviour, and any other is refused with Localize.UnknownCalendarError. Use Calendrical 2.0 with Localize 1.4.

  • A locale's -u-ca- converts a value into that calendar before it is formatted: en-u-ca-hebrew writes a Gregorian date as a Hebrew one. The calendar comes from the provider registered with Localize.Calendar.register_provider/1, which Calendrical 2.0 registers when it starts.

  • MessageFormat 2's calendar option names the calendar on the message's locale, as -u-ca- does, rather than a calendar module.

  • The parse functions' :calendar option is a calendar module, and the date is returned in it. A CLDR calendar name such as :hebrew, or a module that is no calendar, returns Localize.UnknownCalendarError.

  • The parse functions no longer take :return_calendar. Use Date.convert/2 on the result.

  • Localize.DependencyRequiredError is removed. Nothing returns it, since Localize no longer reaches for Calendrical by name.

  • A numeric month is written as the calendar writes it (written_date/3): a Hebrew common year's Adar is "6", as ICU writes it, where it was "7".

  • y in the Chinese and Dangi calendars is the year's place in the 60-year cycle: the year that began in 2026 is "43", where it was "4663". u writes the year's number.

  • Quarters and week numbers are the calendar's own: a Coptic or Hebrew thirteenth month is in Q4, where it was Q5. Calendar.ISO keeps the locale's weeks.

  • W numbers a month's weeks as a year's are numbered, and its pattern writes the month that week belongs to: 1 October 2021 is "week 5 of September" in en-GB, where it was "week 0 of October".

  • A date in a calendar of weeks is written with CLDR's generic calendar, "M06 16, 2026 AD", where its week was written as its month.

  • A calendar's own formats are read before ISO 8601, so "2023-11-22" read in the Chinese calendar is a Chinese date, where it was a Gregorian date converted.

  • Text that none of a calendar's formats reads is read in the calendar its info/0 names as :parsing_calendar, then converted: "Feb 1, 2024" in Calendrical.ISOWeek is 1 February 2024.

  • Japanese eras before Meiji start on proleptic Gregorian dates, so each moves by days to weeks: CLDR recorded their lunisolar proclamation dates.

Formatting dates and times

  • Localize.DateTime.to_string/2 joins date and time with the locale's "at" pattern by default: en :long is "July 6, 2024 at 2:30:45 PM". style: :default restores the previous output; :medium and :short are unchanged.

  • An interval joins date and time with the locale's standard date-time pattern, not the "at" pattern: "June 15, 2026, 10:00 – 14:30". style: :at restores it.

  • An interval at a standard format writes the fields and widths of that format's single-value pattern: vi :short is "1/4/23 – 10/4/23", where it was "01/04/2023 – 10/04/2023".

  • Two times on one day at a format with seconds are each written whole: "Apr 8, 2026, 12:00:00 PM – Apr 8, 2026, 2:00:00 PM". time_format: :short, or a skeleton without seconds, writes the date once.

  • Skeletons match the locale's availableFormats only, as CLDR's pattern generator does, not its standard formats: ko :yMd is "2024/7/6".

  • A pattern or skeleton that asks for a field the value lacks returns {:error, %Localize.DateTimeInvalidInputError{}}, where it left the field blank: "Jun , 2026".

  • A partial date takes its skeleton from the fields it has: %{year: 2024, month: 6} is "Jun 2024", where it was "6/2024".

  • The numeric weekday fields e and c count from the locale's first day of the week: Saturday is 7 in en and 6 in de, where both gave 6. cc is one digit.

  • A pattern field at a width TR35 does not list, such as MMMMMM or HHH, formats as U+FFFD.

  • Localize.Calendar.localize/3 returns a Localize.DateTimeInvalidInputError for a value that lacks the field it names, where it gave the field's first value: localize(%{year: 2026}, :month) was "January".

Time zones

  • A metazone name is qualified by the zone's country or city unless the zone is the metazone's preferred zone for the locale's country: in en, Berlin is "Central European Time (Germany)" (v) and "Central European Summer Time (Germany)" (z).

  • A zone's standard name stands in for a missing generic name only where the zone keeps one offset for 184 days either side, so London's summer is no longer "Greenwich Mean Time".

  • A zero offset is spelled out in the localized GMT format, "GMT+00:00" long and "GMT+0" short. Localize.DateTime.Timezone.gmt_format/3 drops its :zero_format option.

  • EST5EDT, CST6CDT, MST7MDT and PST8PDT are zones of their own in CLDR 49, not aliases of New York, Chicago, Denver and Los Angeles, so they format by their offset.

Parsing

  • A date-time parsed with an offset keeps its wall time and offset: "2026-05-23T14:30:00+05:00" is 14:30 at +05:00, where it was 09:30 UTC. DateTime.shift_zone/3 gives the previous result.

  • The time zone of a date-time parsed with an offset is the offset itself, "+05:00", where it was "Etc/UTC". DateTime.add/4 and DateTime.shift/3 on it need the new Localize.TimeZoneDatabase.

  • A named zone in parsed text resolves to a DateTime through the configured time zone database, where it was dropped unless Calendrical was installed. A zone the locale does not write, such as "XQZV", is an error.

Relative time

  • Localize.DateTime.Relative counts the calendar periods between two dates with the calendar's diff/3 and shift/6, not a mean month or year: 1 February is "next month" from 31 January. Hours count the clock, so 00:01 is "in 1 hour" from 23:59.

  • Without :unit, the largest unit of which a whole one lies between the two values is chosen, as ECMA-262 Temporal chooses it. A number of seconds is counted in weeks at most.

MessageFormat 2

  • {$t :time} and {$d :datetime} give the time to the minute, as TR35 specifies; precision=second restores the seconds. A date or time option other than timeZone, hour12 and calendar set from a variable is an error.

Locale data and display names

  • CLDR 49 drops 114 locales below Basic coverage, among them aa, ab, an and ht. Such a locale resolves to a fallback, :und for aa.

  • An unsupported :prefer value is an error, where it resolved to :standard. :prefer now applies to Localize.Language, Localize.Territory and Localize.Script too.

  • Collation moves to Unicode 18 (UCA 18.0.0), so any stored Localize.Collation.sort_key/2 must be regenerated. compare/3 is unchanged for existing characters.

Added

  • An ISO 8601 week date parses in any of its forms, "2026-W25-2", "2026W252" or "2026-W25", as a week and day of the calendar it is read in.

  • Localize.Number.Parser.parse/2 takes :currency and reads an amount with that currency's separators: de-AT writes € 1.234,56 but 1 234,56 plain, so "1.234,56" parses only with the option.

  • Localize.Number.Parser.parse/2 takes :separators, naming the set of decimal and grouping separators to read with for a locale that has more than one: en-ZA writes 1 234,56 with its :standard set and 1,234.56 with its :us set. A locale without the named set reads with its standard one.

  • Localize.Locale.data_locale_id/1 returns the id of the locale whose data is loaded for a locale, which is its own unless a fallback supplied it. Language rules now follow that locale so they agree with its text, while territory preferences keep following the requested locale.

  • Localize.Number.Symbol.for_currency/2 returns the symbols an amount of a currency is written with, taking the currency's own decimal separator over the locale's currency separators over its plain ones.

  • The day of the month (d) is the day the calendar writes its date with, its written_date/3, so Calendrical's Julian year-start variants write 25 March 1750 from month 1 day 1.

  • Such a date reads back through the calendar's date_from_written_date/3: "March 25, 1750" is month 1 day 1 again, alone, in a map or at either end of an interval.

  • The months between two dates are counted by the calendar's diff/3, so Calendrical's March25 months 1 and 13, both named March, are two months.

  • Every parse function refuses text longer than :max_parse_length, 1,024 bytes by default, with a Localize.DateTimeParseLengthError. The longest text any locale writes is 666 bytes, and reading cost grows with the square of the length.

  • Localize.Territory.Hemisphere.hemisphere/1 returns the hemisphere of a territory or locale, :northern, :southern or :ambiguous where the equator crosses it: hemisphere(:AU) is {:ok, :southern}. A region is answered from the territories it contains.

  • Localize.Calendar.register_provider/1 registers the module that supplies calendar modules for a locale's -u-ca-, and calendar_provider/0 returns it. Calendrical 2.0 registers itself when it starts.

  • Localize.Date.parse/2, Localize.DateTime.parse/2 and Localize.Interval.parse/2 read a composite calendar's date in each calendar its parsing_calendars/0 names, so "Mo5 11, 1872" in Calendrical.Reform.Japan reads back.

  • Localize.Date.parse/2 takes :format, a standard format, skeleton or pattern, and reads the text with it alone: mt's :yMd "4/3/2024" is 3 April. Localize.Interval.parse/2 takes it as the interval's format.

  • Localize.DateTime.parse/2 reads the date and the time with the two halves of :format: "3/4/2024 22:05" with format: "d/M/y HH:mm". :date_format and :time_format set one half.

  • Localize.Time.parse/2 takes :format too, a standard format, a skeleton or a pattern, and reads the text with that format alone: "14h30" with format: "HH'h'mm". A :long or a :full format reads a time with its zone and, as it is written for a time that has none, without it.

  • A date's months, days and patterns are named from the CLDR calendar in effect on that date, the calendar's cldr_calendar_type/3: Japan's lunisolar dates before 1873 take the Chinese calendar's names.

  • Localize.Number.resolve_currency/2 and resolve_currencies/2 resolve a symbol several currencies share by the nearest locale that uses one, so "$" is the Canadian dollar in fr. It stays unknown where no locale is a clear fit, as in es.

  • Localize.Interval.to_string/3 and to_parts/3 take a skeleton or a pattern as a date interval's :format or :date_format, as CLDR keys interval formats by skeleton, so a format no standard format reaches can be named: format: :yMMMEd is "Mon, Jun 15 – Thu, Jun 18, 2026" in en.

  • Localize.DateTime.Timezone.parse_zone/2 reads a time zone in any form a locale writes one — a name, location, city, zone ID or localized GMT format — as TR35's time zone parsing does, and resolve/3 resolves it at a date and time through the configured time zone database.

  • Localize.Inflection, a technical preview, inflects words from CLDR's inflection data, with pronoun/2,3 and quantify/4: inflect("Haus", :de, %{case: "dative", number: "plural"}) is "Häusern". Data for 48 languages comes from mix localize.download_inflection.

  • MessageFormat 2 gains the Unicode inflection project's i: functions, :i:inflect, :i:pronoun, :i:quantify, :i:list and :i:numeral, with SSML output (output: :ssml). Localize.Message.Namespace lets an application register a namespace of its own.

  • Localize.Unit.to_string/2 takes :inflect, synthesizing a pattern when the requested :grammatical_case has no CLDR one — :safe uses attested paths only, :always also guesses from suffixes. Localize.Unit.grammatical_gender/2 returns a unit's gender.

  • Localize.Locale.LocaleDisplay.key_name/2 and type_name/3 return a BCP 47 key's localized name and the name of one of its type values — key_name(:ca) is "Calendar", type_name(:ca, :buddhist) is "Buddhist Calendar", and "Buddhist", CLDR's scope="core" name, with prefer: :menu. A value may be given in its BCP 47 form, :gregory or :level1.

  • Localize.Locale.LocaleDisplay.display_name/2 takes key_value_display: :separate, TR35's SeparateKeyValue, so en-u-ca-gregory is "English (Calendar: Gregorian)".

  • Localize.Calendar.display_name(:calendar, calendar) takes prefer: :menu for the short name a menu of calendars uses, "Gregorian", and accepts BCP 47 names such as :gregory.

  • Localize.affirmative_responses/1 and negative_responses/1 return CLDR's POSIX yesstr / nostr forms — {:ok, ["ja", "j"]} for :de — and affirmative?/2 and negative?/2 match a response against them, folding case as TR35 requires.

  • Localize.Locale.LocaleDisplay.type_value_name/2 returns the localized name for a boolean BCP 47 keyword value: "On" and "Off" in en, "Ein" and "Aus" in de.

  • CLDR 49's placeholderBoundarySpacing separates a text field from a neighbour its output would run digits into, as TR35 now specifies, so zh's "vHH:mm:ss" renders a nameless zone as "GMT+8 14:30:45" rather than "GMT+814:30:45". Numeric fields written together are left alone.

  • designation: :utc on Localize.DateTime.Timezone.gmt_format/3 writes CLDR 49's alt="utc" forms, "UTC+01:00" and "UTC+?" in en, and non_location_format/3 passes it to its GMT fallback. Both spellings read back through parse_offset/2.

  • CLDR 49's dualOffsetFormat joins the two offsets a zone keeps, so the localized GMT format describes a zone that moves between them: offsets: :dual writes Los Angeles as "GMT-8/-7" and Denver as "GMT-7/-6", where Phoenix keeps one offset and stays "GMT-7". A value with no zone writes its single offset.

  • A skeleton whose time half is only a zone joins its date through CLDR 49's Date-Timezone item, and one whose date half is only a weekday joins its time through Time-Day-Of-Week, as TR35 now specifies. en :yMMMdz renders "Jul 6, 2024 EDT", where it errored.

  • Localize.DateTime.SemanticSkeleton implements TR35 semantic skeletons, which ask for a date by meaning ("YMDE") rather than by field, and :format accepts one everywhere. :time_precision, :zone_length, :hour_cycle and alignment: :column shape the result.

  • MessageFormat 2's :date, :time and :datetime take TR35's options, from fields and length to timeZoneStyle, and the overrides timeZone, hour12 and calendar: {$d :date fields=month-day} in en-AU is "14 Jun".

  • Localize.DateTime.Timezone.parse_offset/2 reads a fixed UTC offset from a zone string — ISO 8601, or the localized GMT format in any locale's spelling — inverting gmt_format/3. Localize.DateTime.parse/2 uses it, so "May 16, 2026 2:30 PM GMT+10:30" parses without calendrical.

  • Localize.Date.parse/2, Localize.Time.parse/2 and Localize.DateTime.parse/2 parse natively, no longer requiring calendrical — parse("March 22, 2026", locale: :en) gives {:ok, ~D[2026-03-22]}. Localize.Interval.parse/2 and Localize.DateTime.Parser.parse/2 join them.

  • Localize.MinimalPairs exposes CLDR's minimal pairs — the phrases demonstrating a locale's plural, ordinal, case and gender forms. cardinal/1, ordinal/1, grammatical_case/1 and grammatical_gender/1 return them; format/3 picks the one a number selects.

  • CLDR's symbol-alt-variant currency symbols are ingested as Localize.Currency.variant_symbol and selected with currency_symbol: :variant — :TRY in en gives "TL". CLDR 49's new Unicode 18 signs resolve through it: :AED "⃃", :MVR "⃂", :OMR "⃄" and :SAR "⃁".

  • CLDR 49's numeric date and time separators, a technical preview: Localize.DateTime.numeric_separators/2 returns a locale's pair, and :numeric_date_separator and :numeric_time_separator set them on the date, time and interval formatters.

  • CLDR 49's -u-hc-c12 and -u-hc-c24, a technical preview, ask for the locale's own 12- or 24-hour cycle, resolved from the cycles its region allows: ja-u-hc-c12 writes 00:30 as "午前0:30".

  • Ordinal dates from CLDR 49, which CLDR designates a technical preview: the ddd field formats the day from the locale's dayOfMonths data, so en :yMMMddd renders "Jul 6th, 2024". Locales without that data, and patterns with a numeric month, format the plain day.

  • Date-time skeletons resolve through TR35's append items when no available format carries every requested field, appending the missing ones from the locale's appendItems templates. en has no quarter format, so :yMMMdQ now renders "Jul 6, 2024 (quarter: 3)" where it errored.

  • Localize.DateTime.Timezone.location_name/3 returns the place the generic location format names for a timezone — a country for a single-zone territory or a CLDR primary zone, a city otherwise — and generic_location_format/2 renders it through the locale's regionFormat.

  • The g pattern symbol formats the modified Julian day and the deprecated l is ignored, as TR35 specifies, where both returned a tokenize error.

  • mix localize.verify_locales checks a directory of locale files against the release's SHA-256 manifest, exiting non-zero on a mismatch so it can gate a build. It reports the CLDR tag the reference data came from, since the hashes are not overridable.

  • Localize.cldr_repo_ref/0 returns the CLDR repository tag the bundled data was generated from, such as "release-49", and the tag now ships in the hex package. Together with Localize.version/0 it traces a locale file to the source it came from.

  • A self-hosting guide covers provisioning, verifying and serving locale data from a private CDN. Configure the cache location before downloading, since the download target follows it.

Changed

  • Localize.Interval.parse/2 reads text as Localize.Interval.to_string/3 writes it at the given :format, so "Jun 16 – 20, 2026" at :medium parses, where only two dates written whole did.

  • A relative time uses its named form only for a whole offset: Localize.DateTime.Relative.to_string(0.9999, unit: :day) is "in 1 day", where it was "tomorrow".

  • Localize.Date.parse/2 without a format keeps its compiled patterns for each locale and calendar: "Jun 16, 2026" in en takes about 0.4 ms, where it took 5.

  • Localize.Interval.parse/2 keeps its compiled patterns too, so an en interval takes about 2 ms, where it took up to 80. They use up to about 300 KB of :persistent_term for each locale and calendar.

  • The Japanese calendar keeps all 237 eras, from 大化 (645) to 令和, where CLDR 49 has Meiji onwards only; the earlier eras take curated dates and CLDR 48.2's names. 白鳳 carries private_era: true, and four unattested eras unverified: true.

  • Skeleton matching ranks narrow and short widths nearer abbreviated than wide and breaks ties by TR35's field order, as CLDR's reference generator does: ja :yMdEEEEE renders "2024/7/6(土)", not "2024/7/6土".

Fixed

  • A cached locale whose bytes do not match the release's hash manifest is stale, as one of another data version is, so mix localize.download_locales and runtime downloads replace it where it was kept as current.

  • A stale cached locale that cannot be refreshed logs a warning naming mix localize.download_locales, once per locale, where it fell back to its parent with only a debug message.

  • Regional and script locales name subdivisions, inheriting from their parents: en-GB names :gbeng "England" where it named none. Names CLDR marks unconfirmed are left out.

  • Relative months know a month by its name, cldr_month/1, again: a Julian year reckoned from 25 March counts its March as two months, and 21 February to 25 March is "next month", where it was "in 2 months".

  • A fiscal calendar's date is written as the Gregorian date it is, through the calendar's cardinal_year/3: 10 August 2026 in a year begun in July is "Aug 10, 2026", where it was "Aug 10, 2027". It reads back through date_from_cardinal_date/3, and an interval across 1 January writes both years.

  • A locale's -u-mu-fahrenhe overrides the unit to Fahrenheit, where the override was dropped: BCP 47 caps a value at eight characters, so CLDR spells it fahrenhe, which named no unit.

  • A currency amount uses the locale's currency separators where CLDR gives them: fr-CH writes 1'234.56 CHF and de-AT € 1.234,56, where both used their plain-number separators.

  • Locale generation fails on a CLDR key its structs do not define, where struct/2 dropped it silently, which is how the currency separators went missing.

  • A missing locale falls back to its parents and then to :en rather than to the und root, which has no currency data and no localized names. Only an explicit request for :und loads the root, and the error of a locale that cannot be loaded names the locale requested rather than the last fallback tried.

  • A pre-validated Localize.Number.Format.Options struct takes the sign from each number it formats. The sign of the number it was validated with was reused, so options validated with 0 formatted -70 as "CHF 70.00".

  • Localize.validate_locale/1 returns an error for an unknown -u-tz- timezone id, a -t- numeric value that is not a 4, 6 or 8 digit date and a -t- keyword without a value, where it raised. A -u-vt- value of several codepoints is accepted, where it raised too.

  • Localize.Message.format_to_safe_list/3 returns a data-model error rather than raising WithClauseError: a duplicate declaration parses and then fails validation, which its with had no clause for.

  • RBNF plural-keyed substitutions select with the plural rules of the locale the rule set came from, since the forms they choose between are that locale's own words.

  • Localize.Locale.gettext_locale_id/2 caches its match per locale and known Gettext locales, so a repeated call takes about 0.4 µs where it took about 34 µs.

  • Localize.Currency.currency_strings/2 reads the index of currency-naming strings from the locale data, where the pipeline now generates it, so a call takes about 0.6 µs where it built the index each time for about 1.2 ms. A filtered call is still built, because a filter changes which strings name a single currency.

  • Localize.validate_locale/1 returns Localize.InvalidLocaleError for an unknown -u-tz- zone, a -t- date that is not 4, 6 or 8 digits and a -t- keyword without a value, where it raised. A multi-codepoint -u-vt- value is accepted.

  • A display name is that of the code it is keyed by, not of a retired alias: ak is "Akan", where it was "Twi", and fil is "Filipino" on every OTP release (#59).

  • Inflection data is stored under its data version, so an earlier version is never read for a current one, as a stale zh file was for zh-TW. mix localize.download_inflection --prune removes superseded versions.

  • A locale that :persistent_term has no room for returns a Localize.LiteralMemoryError naming the VM flag +MIscs, where loading it stopped the VM. All CLDR locales take about 814 MB of its 1 GB default; the performance guide's "Memory" section gives the sizes.

  • A skeleton asking a non-Gregorian date for a field its formats lack has it appended, after matching the calendar's formats and then the Gregorian's: a Hebrew :yMMMdw is "1 Tamuz 5786 (week: 39)", where it was an error. The Chinese and Dangi calendars match only their own.

  • A week date in every ISO 8601 form is the calendar's own week, where "2026W252" and "2026-W25" were ISO 8601's in every calendar. A week or day the calendar lacks is an error, where "2026-W53-1" was read in a 52-week year.

  • A year, or a year and quarter, written for a calendar other than Calendar.ISO reads as a map of those fields: "2023 AD" in a calendar of weeks is %{year: 2023}, where it was an error, and "Q2 2023 AD" is the year and quarter, not the quarter's first day.

  • A related Gregorian year reads as the calendar year that begins in it, however far from the reference date: the Islamic "AH 1699-12-28" is 16 June 1700, where it was an error. Of two years beginning in one Gregorian year, the nearer is read.

  • A cyclic year beside an era is that era's year of the name: Calendrical.LunarJapanese's "Genroku (1688–1704) geng-chen 4 29" is 1700, where it was 2000. A name the era has no year of is an error.

  • An inverted range read for a calendar whose dates are read in another names its two dates in the calendar asked for: Localize.Interval.parse/2 of "Feb 5, 2024 – Feb 1, 2024" for a calendar of weeks returned a Localize.DateRangeParseError holding the Gregorian dates the text was read as.

  • A year below 1 is read where its numbering spells its sign: ja writes a Japanese date before the first era with the jpanyear numbering, "大化マイナス688年3月15日", and it was read as no date, alone and in an interval.

  • A week without its year is a week of the reference date's week-based year: "Sun (week: 53)" read on 3 January 2021 is that day, where it was no date. Two such weeks read as an interval.

  • A pattern's '' is read inside and outside quoted text, as TR35 has it, so mt's "25 ġimgħa ta' 2026" reads back, where its "ta'" was read as "ta".

  • A weekday beside a week is that day of the week in every calendar, where "Tue, week 25 of 2026" was read as the week's first day.

  • An interval at a standard format is written in that format's numbering, as the date alone is: he's Hebrew medium interval is "א׳–ה׳ בתמוז ה׳תשפ״ו", where it used digits. It reads back, and an interval at a skeleton stays in digits.

  • A year a format names is never written as a number, nor a numbered year as a name, as TR35's adjustments require: en's Chinese medium interval is "Mo5 2 – 6, bing-wu", where the related year took the name's place. Between two numbered years the one asked for is written, "5/2/2026 – 5/6/2026".

  • An interval whose dates are in two sixty-year cycles of the Chinese or Dangi calendar writes both dates in full, "5/2/2026 – 5/2/2086" for en's yMd, as ICU does, where the interval's pattern wrote the two alike, "5/2/43 – 5/2/43". A format that writes no year takes one first, as it does for any two years.

  • G on a Chinese or Dangi date writes the number of its sixty-year cycle, "78" in 2026, as ICU does, where it returned Localize.ItemNotFoundError. Read beside the year's place it names one year: "77 60" began in 1983.

  • An interval written without a year across the new year ends in the year after: "Dec 28 – Jan 3" was refused as an inverted range of the reference date's year. The later date is a year on only where it would otherwise come before the earlier, by its calendar's own days, and never an earlier day of the same month.

  • A date without its year in a range takes the other date's year, or the next across the new year: "June 16 to August 20, 2031" is that summer, where it was five years earlier, and "Dec 28 – Jan 3, 2027" reads, where it was refused.

  • A zone keeps its metazone from 1970 on, as ICU has it: New York in 1965 is "GMT-05:00" and "New York Time", and Los Angeles in 1850 "GMT-07:52:58", not "Pacific Standard Time". Localize.DateTime.Timezone.metazone_for/2 is nil before 1970.

  • A pattern that writes a year's name beside its number keeps the name when a skeleton asks for another numbered year. The skeleton r wrote a Chinese year as "2026(2026)" in most locales, root's "r(U)" turned to "r(r)", and writes "2026(bing-wu)".

  • Localize.DateTime.parse/2 reads with a Localize.DateTime.SemanticSkeleton given as :format, where the skeleton was ignored. A skeleton whose minutes are optional reads "2 PM", which Localize.Time.parse/2 refused.

  • A date and time is written with the numbering its date format states, CLDR's numbers attribute, as the date alone is. he's Hebrew date and time was "1 בתמוז 5786, 14:30" beside the date's "א׳ בתמוז ה׳תשפ״ו", and zh's Chinese day "2" beside "初二".

  • An interval whose CLDR item names the year is read with the related year written in the name's place. en's Chinese "Mo5 2 – 6, 2026", beside the item "MMM d – d, U", was an error, as "2026 M05 2–6" was in 487 locales.

  • A date two of a locale's formats read is read by the one whose own separators it has. af's "10-7-2569 BE" is its GyMd's, "M-d-y G", the seventh of October, where its "d/M/y GGGGG" read it first, a hyphen for its slash, as the tenth of July.

  • An interval written without a year is read as dates of the reference date's year, as a date alone is. "Jun 16 – 20", which the month and day fields write, was read only as two partial dates with as: :map.

  • An interval reads with or without spaces about its dash, so en's "Jun 16–20, 2026" and de's "16. – 20.06.2026" read, where they were errors.

  • An interval in a year two eras share is read where one of its dates is written without its month. en's "May 1 – 5, 1 Reiwa" asked the calendar about the year 2019 alone, which is Heisei 31 until April, and was an error; each date now asks with the month and the day it takes from the other.

  • A numeric field takes the abbreviated width of a named one where the calendar has no numeric format, as CLDR's conformance data and ICU do: GyMd for a Chinese date in en is "Mo5 2, 2026", where it was "Fifth Month 2, 2026(bing-wu)".

  • An interval's values are written and read in the order the locale's fallback pattern states, as ICU does: kek writes 16 June 2026 to 20 August 2027 as "20/8/2027 – 16/6/2026", where the earlier date came first. A date written once beside two times stays before them.

  • The first year of a Japanese era is read as ja writes it. Its full, long and medium dates take CLDR's jpanyear numbering, whose year 1 is 元, so "令和元年5月1日" was an error.

  • A year of an era that began before a calendar changed how it numbers its years is read. Calendrical.Reform.Japan counts its years from 645 until 1872 and as the Gregorian calendar does from 1873, so every date from the reform to the end of Meiji in 1912, "Jun 16, 6 Meiji", was an error.

  • A range is cut at every place its separator occurs, where only the first was tried, so da's "2026-W25-2-2026-W27-1" and "October 5, 2026 to October 10, 2026" read. Text before the first date (fr-CH's "du {0} au {1}") and a later-date-first pattern read too.

  • An interval is read by the pattern whose own separators are in the text before one that reads it through a lenient separator. ha's yMd writes a year's difference with root's pattern, "26-06-16 – 27-08-20", which its own day-first pattern read as 26 June 2016 to 27 August 2020.

  • An interval date without an era takes the other date's era, where it took the reference date's: ja's "H5/06/16~5/06/20" is five days of 1993, where it ran to 2023. A narrow era name reads where it names one era, so "R 8/06/16 – 8/06/20" reads back.

  • A Hebrew date in Elul is read beside its era. The year of an era is settled by asking the calendar about the date, and it was asked about CLDR's number for the month, 13, in a common year of twelve months, so de's "07.13.5786 AM", af's "07 Elul 5786 AM" and every interval ending in Elul were errors.

  • A narrow month name reads where the format writes one and the calendar's narrow names are distinct: mn's "2026 VI" and "2026 оны VI/16 – VIII/20" read back. en's "J", shared by three months, is still no month.

  • A format with a variant is read as the formatter writes it, standard pattern first: en-CA's Chinese short date "5/2/2026" is the fifth month's second day, where the variant was tried first, and its yMd interval "6/16/2026–6/20/2026" reads back.

  • A skeleton's year is a number at every width and U a name at every width, so a y request never takes the year-name format: yMd for a Chinese date in en is "5/2/2026", where it was "5/2/bing-wu". yyyy and yy stay apart, so yyyy in en is "2024".

  • A 24-hour hour beside a day period reads only where the two agree, as TR35 checks, so ksh's "4:00:00 n.M. GMT+5:30", four in the afternoon, is no longer read as 04:00.

  • Localize.DateTime.Timezone.resolve/3 takes the reading whose zone is written so at the date: it's "Ora dell’Europa orientale (Kaliningrad)" is Kaliningrad in 2026, where it was Minsk. A name qualified by a city is that city's zone, so "Israel Time (Gaza)" is Gaza.

  • A country or city in parentheses is read as a zone's qualifier only after a name the locale writes, so text that is no name before it is no zone. A 24-hour pattern took a time's day period for part of its zone: en-GB read "4:00:00 pm (India)" as 04:00 in India.

  • A locale's GMT literal alone is read as GMT, as TR35's parsing reads "the absence of a numeric offset" in "localized or global formats": "غرينتش" in ar and ga's "MAG" are offset 0, where only "GMT", "UTC" and "UT" were.

  • Localize.DateTime.Timezone.parse_zone/2, parse_offset/2 and resolve/3 read a number with no sign after the GMT literal as east ("GMT 3", "UTC5:30"), as TR35 allows. A zone inside a date or time still needs its sign, since the number could be the year or the hour.

  • An offset's seconds are written by the localized GMT format and by Z to ZZZ: Los Angeles before standard time is "GMT-07:52:58", where it read back 58 seconds out. parse_offset/2 reads seconds after a one-digit hour and hours up to 23, so Juneau's "+15:02:19" reads.

  • The long localized GMT format (OOOO, ZZZZ, and a zone name's fallback to it) has a two-digit hour in cs, fi and vmw, as TR35 gives the long format always: "GMT+05:30" and "UTC+05.30", where the one-digit hour of those locales' hourFormat, the short format's, wrote "GMT+5:30" and "UTC+5.30".

  • resolve/3, parse_zone/2 and parse_offset/2 read an offset in any numbering system's digits, as TR35 has it: ar-EG's "غرينتش+٥:٣٠" and ne's "GMT+५:३०" read, where they were an unknown zone in the 67 locales with digits of their own.

  • Localize.DateTime.Timezone.parse_zone/2 and resolve/3 return an error for bytes that are not UTF-8, where they raised an ArgumentError.

  • A custom MF2 function receives its options under their names as strings, as Localize.Message.Function documents. It received an atom for any name an atom existed for, so the documented func_opts["format"] read nil.

  • A zone name qualified by a place reads as that place's zone even where its country has a primary zone: "Chile Time (Punta Arenas)" is Punta Arenas, where it was Santiago. A metazone name shaped like a region format reads as the name, so ko's "사모아 표준시" is American Samoa.

  • A standard or daylight name reads with the time its zone keeps at the date, as the formatter names it, where "Chile Summer Time (Punta Arenas)" read back an hour out. A standard name that is its metazone's only name ("Kyrgyzstan Time") follows the zone's clock.

  • The location format writes a country the locale has no name for by its code, as TR35 and ICU4C do: Havana's zone is "CU" in su and "ora de CU" in oc, where it was "Havana". The code reads back in a locale that writes it.

  • A location whose country's name has the shape of the fallback format is read as that country: fr-CA's "heure : Saint-Martin (France)" was read as France's zone, and ru's "Виргинские о-ва (Великобритания)" as the United Kingdom's.

  • Localize.Interval.parse/2 reads two-digit years with the era written once: nl's Buddhist "16-06-69 – 20-06-69 BE" reads, where its second year was the year 69. The reading nearest the reference date is taken.

  • The six zones whose data key is not their lowercase name, such as America/Port-au-Prince and Antarctica/McMurdo, take the city names their locale gives them, "بلانك-سابلون" in ar, where every locale used their own name.

  • A zone name qualified by a place is read where the place or the name holds the fallback format's own text: pt-AO's "Hora de Greenwich (Côte d’Ivoire (Costa do Marfim))" was an error, and ko's "중부유럽 하계 표준시(독일)", whose name has the shape of the standard region format, was read as standard time.

  • A date and time in a calendar other than Calendar.ISO takes the zone names of the instant it names, where its fields were read as ISO's: a Persian date in January 2026 in Asia/Almaty is "Kazakhstan Time", where it was "GMT+05:00". metazone_for/2 reads it in its calendar too.

  • A time reads only an hour its field allows, as TR35 and a strict ICU do, where "45:30 PM" was 21:30 and "13:30 AM" 01:30 in 618 locales. "13:30 PM" is an error too.

  • Localize.Time.parse/2 reads ISO 8601's times in every locale: "14:30" without seconds, which fi, da and 13 more did not read, and "T14", "T1430" and "T143045". z for Z and U+2212 in an offset read too.

  • Localize.DateTime.parse/2 reads every ISO 8601 date-time form: week dates and ordinal days before the T, times without seconds or minutes ("2026-W25-2T10:30"), and the basic form ("20260616T103000"). MessageFormat 2's date and time literals take the same forms.

  • Localize.Date.parse/2 reads the rest of ISO 8601's date forms: a week alone ("2026-W25"), as its Monday in ISO 8601's weeks, and basic-format week and ordinal dates ("2026W252", "2026166"). A year and month, or a year, reads as: :map in every locale.

  • Localize.Time.parse/2 reads an hour only in the range TR35 gives its field, h 1 to 12 and K 0 to 11: "0:30 AM" at h:mm a and "12:30 AM" at K:mm a are errors, where they were read as 00:30 and 12:30. H and k were held to their ranges already.

  • An interval writes only the fields its format names, as TR35's algorithm has it: :MMMd from 28 December to 3 January is "Dec 28 – Jan 3", and :yMMMHm on two June days at 10:00 is "Jun 2026, 10:00". A year, month or era the values differ in is written where the format asks for it.

  • Localize.Time.to_string/2 writes a Time or NaiveDateTime at a standard format with the locale's standard time pattern, where it took its skeleton's, which differs in 44 locales at :short: bg's short time is "10:30", not "10:30 ч.".

  • A date and time with no zone is written at :long and :full with Localize.Time.to_string/2's time, so it reads back in every locale: ja's full is "2024年4月3日水曜日 10:30:00", where the time was "10時30分00秒". fa, es and zh-Hant no longer leave "()" or "[]" for the missing zone.

  • A date interval compares its two dates in the fields its format writes, as TR35 does, so a week format writes both weeks (format: :yw is "week 25 of 2026 – week 30 of 2026", where only the first was written) and a quarter is written once for two dates within it ("Q2 2026", where "Q2 2026 – Q2 2026" was written).

  • Localize.Interval.parse/2 reads the year of a Chinese or Dangi interval as the related Gregorian year or as the calendar's own, whichever is nearer the reference year, so "11/8/2023 – 11/18/2023", which the formatter writes for two days of the Chinese year 4660, reads back, where it was the Chinese year 2023.

  • An ISO 8601 offset or Z written hard against a time is kept: Localize.DateTime.parse("11/22/2023 14:30:45+02:00", locale: :en) is a DateTime two hours ahead of UTC, where the offset was dropped and a NaiveDateTime came back.

  • Compiling Localize on Elixir 1.17 and 1.18 no longer logs "The on_load function for module Elixir.Localize.Nif returned: {:undef, …}" and an UndefinedFunctionError: Localize.Priv, which the NIF module's @on_load callback calls, is compiled and loaded first.

  • Localize.Interval.to_string/3 and to_parts/3 take dates with only some fields, in CLDR's interval format for them: two months are "Jun – Aug" and a year and month "Jun – Aug 2026", where they were errors. A date and time with no year keeps its date.

  • w and Y take a date without its day when every day it could be is in one week.

  • A specific time zone name takes standard or daylight time from TR35's stdOffset and dstOffset where a metazone period names them, as CLDR 49 does for Europe/Dublin and America/Winnipeg, rather than from the time zone database's flag: Dublin's summer is "Irish Standard Time" however std_offset is set.

  • Parsing no longer depends on map iteration order, which varies between VMs: standard formats are tried in Localize.DateTime.Format.standard_formats/0 order, and a place name two zones share resolves to the one Localize.Territory picks, else the alphabetically first.

  • Localize.Locale.parent/1 returns a parent that names its CLDR locale, where it cleared the CLDR locale id with the child's subtags, so a caller walking up the locales no longer validates each parent against every locale, about 20 ms a step.

  • Localize.Number.to_string/2 with an RBNF rule only an ancestor locale has, such as Hebrew and Roman numerals, takes a fraction of a millisecond where it took 40 to 50: the chain of locales a rule is looked up in is found once a locale, where each call revalidated every parent tag.

  • A date or time pattern is read by code points, so a combining mark after a quote is literal text: nnh's long and full dates ("lyɛ̌ʼ 4 na …"), whose caron follows a quoted "lyɛ", parse, where the quote never closed.

  • A numeric month written in an algorithmic numbering parses, as haw writes its short date's month in Roman numerals ("31/xii/24").

  • A comma that is the only separator between two date fields is no longer read as optional, so en-ZW's "May 2019" is May 2019, where its "dd MMM,y" read it as 20 May 2019. A comma beside a space may still be left out ("May 5 2026").

  • A Hebrew date written in Hebrew numerals parses, as he writes it ("כ״ב בטבת ה׳תשפ״ד" is 22 Tevet 5784): a hebr year is read by its letters' values, its thousands marked by a geresh, where none of he's Hebrew dates read back.

  • Localize.Time.parse/2, and the time of Localize.DateTime.parse/2, read the time formats of the :calendar given before the Gregorian calendar's, so de's Chinese "10 vorm." parses as the formatter writes it. A :calendar that is not a calendar module is a Localize.UnknownCalendarError, as it is for a date.

  • The localized GMT format writes its offset in the locale's digits, as TR35 and ICU do ("GMT-४" in ne, "غرينتش-٤" in ar-EG), or in those of -u-nu- or the formatter's :number_system. Localize.DateTime.Timezone.gmt_format/3 and non_location_format/3 take a :number_system option.

  • Localize.Territory.territory_from_locale/1 returns the region of a -u-rg- subdivision, so en-u-rg-gbsct (Scotland) is :GB where it was :gbsct, and such a locale's currency, unit preferences and time zone names are its region's.

  • The week fields Y, w, W, e and c honour a locale's -u-fw- and -u-rg-, which they ignored: Friday is e 5 in en-u-fw-mon. first_day_for_locale/1 and min_days_for_locale/1 follow TR35's algorithm in full, -u-ca-iso8601 and -u-sd- included.

  • A datetime interval splits a skeleton into date and time fields, as TR35 does: one day's :yMMMdHm is "Jun 15, 2026, 10:00 – 14:30", where it was an error, and :Hm writes the times alone. A skeleton of date fields formats as a date interval, "Jun 15 – 16, 2026".

  • A date or time its calendar lacks is a Localize.InvalidValueError in formatting, intervals, Localize.Calendar.localize/3 and relative time, by the calendar's valid_date?/3 and valid_time?/4. Some fields raised for one and others wrote it, "Feb 30, 2019" or hour 25 as "1:00 AM".

  • A calendar whose year begins in another month, such as a fiscal year beginning in July, names its months for the months they are, period 1 "Jul" and "7", where they were named by their place in the year, "Jan" and "1".

  • Localize starts inside an escript built with include_priv_for: [:localize], reading its data from a copy extracted into the user cache directory that rebuilds share; one built without it stops at startup saying so, where every process crashed on its first read. Closes #58.

  • A collation table file that cannot be read is logged as a warning, as a missing one already was, where any other read error stopped Localize with a CaseClauseError.

  • Localize.DateTime.Relative.to_string/2 counts the quarters, and the calendar weeks from the locale's first day for :mon to :sun, between two dates, where it formatted their difference in seconds as that many quarters or weekdays.

  • Localize.DateTime.Relative.to_string/2 counts a number of seconds beyond the range of a float, where it raised ArithmeticError.

  • Localize.Duration.new/2 counts years, months and days with the calendar's diff/3 and shift/6, giving the span Date.shift/2 adds to the earlier date: 14 January to 13 July is 5 months and 29 days, where it was 30. A date the calendar lacks is an error, where it raised.

  • Localize.Duration.new/2 measures two zoned date-times as two moments, as ECMA-262 Temporal does: 10:00 UTC to 18:00 in Karachi is 3 hours, where it was 8. A time paired with a naive date-time no longer raises, and no pairing gives a negative duration.

  • Localize.DateTime.Relative.to_string/2 returns an error for a DateTime built by hand without its offsets from UTC or its time zone, where it raised ArithmeticError.

  • u writes the calendar's extended year, its extended_year/3, where it wrote the date's year: 15 June 1 BC in the Julian calendar, whose year is -1, is "0", as TR35 defines it. A calendar without extended_year/3 is refused, as one without any other answer is.

  • A parsed range of dates is ascending or inverted by its calendar's days, where the dates' fields decided: in a calendar whose year turns after its first month, as a Julian year reckoned from 25 March does, 31 December to 1 January of the same year is no longer refused as inverted.

  • Localize.Substitution.substitute/2 substitutes into a template of any shape, where it raised on one it did not list, such as el's zone fallback format "[{1} ({0})]".

  • A zone in parsed text reads in the locale's own words and spelling, "heure d’été de l’Est nord-américain" or "غرينتش-4", and a standard or daylight name keeps its own offset, as ICU reads it. Of 52,560 zoned date-times formatted in every locale, all but 80 in nnh round-trip, where 14,099 did.

  • Localize.Time.parse/2 reads a time written in the digits of the locale's number system, as the date parser does, so bn "১০:০৫ AM" and fa "۱۰:۰۵" parse, and with them the date-times of 67 locales that write their own digits.

  • The date parser reads month, quarter, weekday and era names written in the locale's digits, as dz's abbreviated months ("༤" is April), ckb's short weekdays and ff-Adlm's Japanese eras are, where transliterating the input's digits left them unmatched.

  • Localize.Time.parse/2 tries the locale's standard formats first, then its available formats with those carrying a zone or a flexible day period last, where their order followed a map, so ms "11:59 PTG" is 23:59 rather than 11:59 in a zone "PTG".

  • A flexible day period reads as the period its hour falls in, so fr's "9:05 matin" is 09:05, not 21:05, and as AM or PM where the locale names none. A word the locale does not name is no longer read as the morning.

  • Localize.Interval.to_string/3 and to_parts/3 format with the formats of the endpoints' calendar — its interval patterns, date and time formats and date-time pattern — where they used the Gregorian calendar's, and return an error for endpoints in two calendars.

  • An interval across a change of era writes each date's era where its format has one: format: :GyMMMd is "Dec 31, 1 BC – Jan 1, 1 AD", and the Japanese calendar's formats, which write the era with the year, "Apr 30, 31 Heisei – May 1, 1 Reiwa" across the change within 2019.

  • An interval whose endpoints differ in no field it shows is the date at the requested standard format, as Localize.Date.to_string/2 and ECMA-402 write it: ko "2023. 4. 1.", where it was "2023/4/1". A date-time interval formatted whole honours :style.

  • Localize.DateTime.parse/2 returns the date-time where it raised for every calendar but Calendar.ISO, and reads a date-time pattern that puts the time first, as vi's "{0} {1}" does.

  • The date parser tries the locale's standard formats first and then its available formats most specific first, where their order varied between runs, and reads a yy year beside the era of a calendar numbering its years as they are in the century around the reference year ("01.04.66 BE" is 2566 BE).

  • Localize.DateTime.to_string/2 and Localize.Time.to_string/2 resolve formats in the value's calendar — its date-time pattern, available formats and append items — where they used the Gregorian calendar's, and Localize.DateTime.parse/2 reads the calendar's date-time pattern as well as the Gregorian one.

  • The date and interval parsers read a lunisolar date as the formatter and ICU4C write it: r, U, leap months ("Mo2bis", "闰二月") and zh's and ja's Han numerals. Where a locale writes both the calendar year and the related year, as ko does, the nearer reading is taken.

  • A date read without its year is completed with the reference date's year in the calendar it is read in, so "15 Nisan" in the Hebrew calendar is in 5786 where it was in the Hebrew year 2026, and a two-digit year is read in the century around it in every calendar.

  • Era names come from the CLDR calendar a calendar's info/0 names as :era_calendar_type, so Calendrical's lunisolar Japanese calendar names its eras (令和), where it returned an error. A year written without its era reads as one of the reference date's era, as ICU reads it.

  • The date parser reads a year of an era that begins before the calendar's first year from the calendar year it begins in, so "Hedar 15, 5495 AA" is the Ethiopic year -5, as Calendrical numbers it.

  • A lunisolar month written as a number is its traditional number, and a leap month is in CLDR's numeric leap pattern, as ICU4C writes them — the Chinese leap second month is "2bis" in en and "闰2" in zh where it was its place in the year. A partial lunisolar date writes its related and cyclic year.

  • The date parser reads a year written with its sign, as a calendar without a before era writes one below 1 — "Mar 15, -456 BE" in the Buddhist calendar — and never pivots a signed year.

  • The date and interval parsers read a year with its era in every calendar, as the formatter writes it — "Jun 1, 44 BC" is -0043-06-01 where it was 2044-06-01 and "1 BC" is year 0 — and no longer pivot a two-digit year its era qualifies.

  • A year before a Calendrical calendar's first era counts back from the era, as TR35 and Calendar.ISO count it — Calendrical.Gregorian year 0 is "1 BC" and Calendrical.Roc year -5 "6 B.R.O.C." — where it showed the year as it is, "0 BC".

  • A partial date shows its era and year of era as its days do — %{year: 2025, month: 1} with :GyMMM is "Jan 2025 AD" where the era was blank, and %{year: 0} is year "1" — and a Japanese one whose days span two eras returns an error naming the fields that settle it.

  • The short localized GMT format of a whole hour keeps the offset pattern up to its hour field, as ICU does, so he's "GMT-5" carries one left-to-right mark where it carried two.

  • A binary that is not UTF-8 is an error wherever Localize reads text — number and unit parsing, number and date/time patterns, territory names, collation and inflection — where it raised ArgumentError or UnicodeConversionError. Localize.Unit.parse(<<255>>) returns Localize.InvalidValueError.

  • A MessageFormat 2 date/time literal keeps the UTC offset it carries, where |2006-01-02T15:04:06-05:00| was read as a floating time and its offset dropped.

  • A fixed UTC offset carried under a UTC identifier, as a parsed ISO 8601 offset and Calendrical's fixed offsets are, names its zone in the localized GMT format ("GMT-5"), where it was named "UTC".

  • Localize.DateTime.Timezone.metazone_for/2 takes a map whose fields name no real date and time, returning the zone's current metazone, where it raised.

  • Localize.Locale.parent/1 gives a parent found by dropping a subtag its own CLDR locale rather than the child's, so ar-SA, de-AT, fr-CA, es-419 and zh-Hant-HK spell numbers out with their language's RBNF rules instead of failing. A validated und walks to root rather than to English.

  • Localize.DateTime.parse/2 and Localize.Time.parse/2 with as: :map give a fixed offset such as "GMT+5" the DateTime zone fields the struct form resolves, where the map kept time_zone: "GMT+5" with no offset; a named zone resolves too when the input gives its full date.

  • Localize.Time.parse/2 and Localize.DateTime.parse/2 read a time in the locale's -u-hc- hour cycle ("24:30" under h24) and resolve a flexible day period's hour by CLDR's day period rules. Japanese "夜中0:30" is 00:30, where it parsed as 12:30.

  • Localize.Date.parse/2, Localize.DateTime.parse/2 and Localize.Date.parse_range/2 parse a date with an era, whose patterns never compiled, and one whose weekday does not lead its pattern. A month abbreviation that is also a weekday name (es "mar") is no longer stripped.

  • Localize.Date.parse/2 resolves a month name to that month in the parsed year, so a Hebrew month name parses to its position in an ordinary or a leap year, and "Adar II" parses.

  • A semantic skeleton passed as :format for a partial date or time is used rather than replaced by a skeleton derived from the fields present.

  • prefer: :menu on Localize.Language.display_name/2 composes CLDR's menu="core" and menu="extension" halves where a locale ships no alt="menu" string of its own, so "ku" renders "Kurdish (Kurmanji)" rather than the unqualified "Kurdish". Affects 571 language entries across 446 locales.

  • Localize.DateTime keeps a width the matched format's own skeleton asks for, as Localize.Date did, so fr :yyMd renders "06/07/24" not "6/7/24". A zone keeps its width the same way, so el :yMdHmsv names the zone in full, as its Hmsv format does.

  • A skeleton's weekday matches the locale's weekday formats whether spelled E, e or c, and a year whichever of y, Y, u, U or r, taking the width asked for. en :EEEE renders "Saturday" where it gave "Sat", and ja :U "2024年".

  • j and C carry TR35's day-period width, three or four letters wide and five or six narrow, so en :jjjjj renders "2 p". An a beside an hour sets only the width of the locale's own day period, TR35 treating ha as h, so zh-Hant :ha keeps its "Bh時".

  • A skeleton no available format can build on starts from its first field and appends the rest, as CLDR's reference generator does, so :G renders "AD" and :QQQQ "3rd quarter" where both errored.

  • A locale passed to Localize.Inflection must be a well-formed identifier. One containing "../" reached files outside the inflection data directory, loaded them, and created a new atom for every spelling of the path.

  • Quoted text in a matched format survives width adjustment: dsb's jjm format "'zeg'. H:mm" rendered "eg. 14:30", its quoted z read as a zone field and deleted.

  • Skeleton resolution is covered by CLDR's four skeleton conformance files, 2,871 Gregorian cases, which had not been exercised. All agree with CLDR's reference generator but 116, each recorded with its TR35 reason: hour widths, J padding, C in Indian locales and en yyyy.

  • Full UCA conformance restored: all 210,155 pairs in both CLDR collation conformance files sort correctly, where 637 and 536 failed. FractionalUCA.txt and the UCD property files had been hand-vendored at Unicode 17 and are now copied by the pipeline.

  • Localize.Locale.LocaleDisplay.display_name/2 renders a locale in root (or und) as bare subtag codes per TR35's code fallback — display_name("nl-BE", locale: :root) is "nl (BE)", where it answered in English. root is now accepted wherever und is.

  • Month names come from the calendar's cldr_month/2, so Hebrew months are named correctly in ordinary and leap years ("Adar II" included) and a Chinese leap month takes the leap-month pattern ("Second Monthbis"), where they were looked up by month number.

  • The Localize.FormatError message for a selector without an annotation is rendered. Its example declaration was being formatted as a live placeholder, which failed, so the raw template was returned and a warning logged.

  • Public functions return {:error, exception} — usually Localize.InvalidValueError — for options that are not a keyword list and for arguments or option values of the wrong type, where they raised; predicates answer false.

  • Localize.Message.format/3 returns a Localize.FormatError where it raised when a placeholder, :string or a :string selector was given a value with no string form — a tuple, map, pid, function, a list that is not chardata, or a bitstring that is not whole bytes.

  • Localize.to_string/1,2 and Localize.List return Localize.InvalidValueError for an improper list and for a bitstring that is not whole bytes, where they raised FunctionClauseError.

  • Localize.Message.format/3 with backend: :nif falls back to the Elixir interpreter for bindings other than strings, 64-bit integers and floats, where some raised and others were misformatted. The NIF decodes JSON \b, \f and \u escapes and exponent-form numbers, which reached ICU as text.

  • Localize.Nif.mf2_format/3 returns {:error, exception} for arguments that are not a map or JSON object of strings, 64-bit integers and floats, and for a message or locale that is not a string, where it raised.

  • Localize.Message.format/3 and format_to_iolist/3 return an error for an improper binding list, where both backends raised, and the NIF backend skips a list element that is not a {name, value} pair, as the interpreter does.

  • Localize.Currency.current_currency_for_territory/1 returns CLDR's primary currency where several are current, where it depended on atom-creation order (LS gave LSL, ZW USD), and gives ML its XOF. The territory currency data carries each currency's CLDR position as :order.

  • Localize.Currency.currency_strings/2, and so currency parsing, leaves out a narrow symbol that several current currencies share, such as "kr" in en, where it went to whichever currency came first in atom-creation order. One current currency among historic ones still takes it.

  • Currency parsing takes the longest matching currency string by bytes rather than graphemes, so a plural spelled with as many graphemes as its singular (in te, mr, ml and 14 other locales) no longer matches as the singular and leaves the rest of the word in the remainder.

  • Localize.Number.System.number_system_names_for/1 and Localize.Number.Format.format_system_types_for/1 return systems in type order (default, native, traditional, finance), where they followed atom-creation order.

  • The module mix localize.unit.gen_conversions generates lists each unit once in known_units/0, where the simple base units appeared twice.

  • Localize.Territory.to_territory_code/2 matches a name as written before its normalised form and, where territories share it, prefers a country to a region, then the first code alphabetically. In scn the name South Africa and Southern Africa share gives :ZA.

  • Fuzzy currency matching takes the alphabetically first of equally close currency strings, where it took whichever came first in map order.

  • Functions taking a Localize.LanguageTag, including every :locale option, no longer raise on a hand-built struct with wrong-shaped fields: a nil list or map is empty, a string subtag is its atom, and anything else returns Localize.InvalidLocaleError.

  • Interpolating a parsed Localize.LanguageTag into a string gives its BCP 47 form, where it raised because the tag had no canonical id yet.

  • Localize.Territory.territory_from_locale/1 takes the territory of a language tag struct that has none from its likely subtags, so a parsed "fr" gives :FR where it gave the default locale's :US.

  • A locale that Localize holds data for now resolves to itself. resolve_cldr_locale/1 asked the language matcher even for an exact identity, so ar-EG, which formats in arab digits, resolved to ar, which uses latn.

  • root is accepted wherever und is. TR35 makes it a synonym for und, but CLDR's alias data does not carry it, so Localize.validate_locale("root") failed while Localize.Locale.cldr_locale_id_from("root") succeeded.

  • mix localize.download_locales rejects a name that is not a CLDR locale rather than turning it into an atom, and unit preference lookups no longer create atoms from the prefixes of a usage.

  • Locale downloads retry a CDN server error, timeout or dropped connection up to three times with exponential backoff, where one failure was final; a 404 still fails at once. :http_retries, :http_retry_delay and :http_ip_family configure the retries and the address family.

  • Skeleton formats honour the field widths asked for. TR35 matches a skeleton then adjusts its widths to the request; only the first step happened, so format: :MMMM rendered "Jul" in en where the literal pattern "MMMM" rendered "July".

  • A skeleton combining a date and a time adjusts both halves' field widths to the request, where a requested zone symbol took the matched format's — :MMMMdjmsO gave "GMT" not "GMT+0". Every Gregorian case in CLDR's datetime conformance fixture now passes, from 47 of 78.

  • Skeleton matching is deterministic. Where two formats sat the same distance from a skeleton the winner came from the formats map's iteration order, which depends on when the atom keys were created — so one skeleton could resolve differently between runs.

  • A matched format keeps a field width the locale states deliberately. TR35 leaves a pattern field alone where the matched availableFormats id already carries the requested width, so ru's yMd renders dd.MM.y rather than being narrowed to d.M.y.

  • Resolving a date-time skeleton no longer creates atoms. Localize.DateTime.Format.Match.split_fractional_seconds/1 interned the remainder left after stripping the fractional-second field; it now resolves against existing atoms only, with identical output.

  • A skeleton whose date and time halves no single available format covers joins them at the length TR35 derives from the requested date fields rather than always at :medium.

  • :date_format and :time_format resolve a skeleton through TR35 matching, where a missing skeleton errored. A skeleton date format joins through the locale's date-time pattern, so ja renders "2026年6月15日 14:30".

  • Localize.DateTime.to_string/2 and to_parts/2 keep both halves of a map holding some date and some time fields, joined through the locale's wrapper, where %{year: 2026, month: 6, hour: 14} rendered "6/2026". A partial value given a skeleton is formatted with it.

  • Partial times keep the locale's hour cycle, so %{hour: 14, minute: 30} is "14:30" in de rather than "2:30 PM". A -u-hc- override now applies to partial times and throughout Localize.DateTime, which ignored it.

  • Localize.Time, Localize.DateTime and Localize.Interval render a -u-hc-h11 or -u-hc-h24 cycle with the K or k hour ("0:30 AM", "24:30") and resolve j in an interval skeleton. Under -u-hc-, an explicit h or H keeps the cycle it names, as in ICU.

  • Time skeletons resolve day periods as ICU does: ha renders "h a" and hb "h b" where both gave a flexible day period, and an H or k skeleton drops a requested one. J drops it as TR35 requires and C ignores a -u-hc- override.

  • Localize.DateTime.to_string/2 applies a -u-hc- hour cycle to a skeleton that matches an available format exactly or takes append items, and renders an X or x zone field requested by a skeleton, which it dropped.

  • b and B judge noon and midnight at the precision their pattern shows, as ICU does, so "h B" renders 12:05 as "12 noon". VVV renders Etc/UTC as the unknown location, "Unknown Location" in en, rather than "UTC".

  • S has exactly as many digits as the field has letters, as TR35 specifies, where it stopped at six and at the value's precision, so ss.SSS on 45.9 seconds is "45.900". U without cyclic year names formats as y, and YYYYYY pads rather than rendering empty.

  • Timezone format symbols follow TR35: V is the BCP 47 short identifier ("usnyc"), VVV the exemplar city ("New York") and VVVV the generic location format, where all three returned the IANA name or a GMT offset. An alias like US/Eastern resolves to its canonical zone.

  • The generic location format names a country where TR35 requires one — a single-zone territory, or a zone in primaryZones — so VVVV renders Europe/Rome as "Italy Time". primaryZones is new supplemental data, read from metaZones.xml because the JSON omits it.

  • v and vvvv fall back to the generic location format before the localized GMT format, which TR35 specifies for the generic symbols only, so Asia/Kolkata is "India Time" rather than "GMT+05:30". The specific symbols z and zzzz still fall straight through to GMT.

  • ZZZZ spells a zero offset as "GMT+00:00", matching OOOO as TR35 requires of the localized GMT formats. A metazone with no daylight name also resolves a generic request to its standard name, so vvvv is "Greenwich Mean Time" for Etc/GMT and "India Standard Time" for Asia/Kolkata.

  • An unknown timezone offset renders the locale's gmtUnknownFormat — "GMT+?", translated where the locale translates it — instead of a hard-coded English "GMT".

  • The short localized GMT format drops the hour's leading zero when the offset has minutes, as TR35 specifies, so O for India is "GMT+5:30" rather than "GMT+05:30".

  • Localize.Duration.to_time_string/2 no longer raises on a field longer than two letters, such as "hhh:mm"; the field formats as U+FFFD, as in a date pattern.

  • Date intervals use the locale's interval pattern where CLDR ships a matching one, adjusted to the requested widths, rather than gluing two full endpoints — de at :medium renders "03.–05.05.2026". TR35's closest-match step was skipped.

  • An interval whose endpoints differ in no field it shows is one value, as TR35's interval algorithm has it, where fields: :year_and_month gave "Jul – Jul 2024". A selection of fields without a year writes none for dates of two years, so fields: :month_and_day across the new year is "Dec 30 – Jan 2".

  • Time intervals across noon take CLDR's day-period pattern, "10:00 AM – 2:00 PM" rather than "10:00 – 2:00 PM", and an :h interval within one hour formats a single time. A skeleton the interval table lacks, such as :hms, joins both times through the fallback pattern.

  • Same-day datetime intervals join the date, shown once, to CLDR's time interval pattern as TR35 specifies, so en :short is "7/6/24, 10:00 – 10:30 AM" rather than "7/6/24, 10:00 AM – 10:30 AM". Datetimes differing only in seconds format as an interval at :medium rather than as one value.

  • Localize.DateTime.Relative.to_string/2 and to_parts/2 return {:error, %Localize.InvalidValueError{}} for a value that is not a number, date, time or datetime, and for options that are not a keyword list, rather than raising FunctionClauseError.

  • Localize.DateTime.Relative.to_string/2 and to_parts/2 measure a value against a :relative_to of another type where the conversion is unambiguous, and return Localize.InvalidValueError otherwise. The baseline was silently replaced by the current time.

  • Localize.DateTime.Relative.to_string/2 and to_parts/2 format the number for the locale ("in 1,000 days", with ECMA-402 parts) and select the pattern by the displayed number's plural category. A float with :unit is a count of that unit, where it was read as seconds.

  • A display name with no entry for the requested preference and none for :standard falls back to a stable key rather than whichever the map yielded first, which depended on atom creation order in the VM.

  • Localize.Number.Rbnf.rule_names_for_locale/1 returns its names sorted. On OTP 26 and later their order followed the order the VM had created the atoms, so it varied between runs.

  • Localize.Number formats a non-default numbering system with the data CLDR gives the locale for it: fa-u-nu-latn is "1,000.5", not "1٬000٫5", and en-u-nu-arab "١٬٠٠٠٫٥", not "١,٠٠٠.٥".

  • The long currency format no longer raises in sd and ckb, whose arab has no unit patterns and now takes their latn ones, and writes a currency with no display name by its code, as TR35 specifies ("٣٫٠٠ USD" in ckb).

  • Number patterns give each currency sign width its TR35 meaning — ¤¤ the ISO code, ¤¤¤ the plural name, ¤¤¤¤¤ the narrow symbol — and take the currency's decimal places and CLDR's currency spacing, so ¤#,##0.00 gives "CHF 1,234.56" and "¥1,235".

  • Localize.Number.to_string/2 rounds significant digits and scientific mantissas half-even, or by :rounding_mode, where it rounded half-up. A mantissa shows the digits TR35 derives from its pattern, so "##0.##E0" renders 12345 as "12.3E3".

  • Localize.Number.to_string/2 and to_parts/2 format a pattern with quoted literal text, such as #,##0 'units', where the pattern lexer looped and exhausted memory. A pattern with a character no pattern syntax accepts, such as a trailing *, returns an error.

  • Localize.Number.to_string/2 keeps the whitespace a pattern ends with, as ICU does and as to_parts/2 already did. A :wrapper function receives a quoted pattern character tagged :literal, which it never received.

  • Localize.Number.resolve_currencies/2 and resolve_currency/2 match a currency name or code only as a whole word, so "100 US dolars" no longer resolves to the Argentine peso from the "ars" it ends with.

  • -0.0 keeps its sign, :max_fractional_digits applies to a scientific mantissa, and compact formatting keeps two significant digits below 1 and groups on ICU's MIN2 rule.

  • Localize.Number.to_string/2 no longer raises on a very small float with a large :max_fractional_digits — to_string(1.0e-308, max_fractional_digits: 400) raised ArithmeticError because reconstructing the value divided by a power of ten outside the double range.

  • Compact number formatting in Swahili was wrong by a factor of ten, and of a thousand for some ranges. The compact divisor counts zeros in the pattern, which included the negative subpattern — elfu 000;elfu -000 read as six zeros rather than three.

  • Plural selection now keeps a Decimal's fraction operands. pluralize/3 converted to a float first, and TR35 selects on the visible fraction digits and their value, so 1.2 was treated as "1.200" and chose other where Serbian, Croatian and Bosnian require few.

  • Localize.Unit.to_string/2 selects the plural form of a rounded number by the digits the formatter displays, so hr 0.0045 hours is "0,004 sata" rather than "0,004 sati".

  • Unit preferences read the -u-ms and -u-mu keywords, in TR35's order mu > ms > rg > (likely) region: en-u-rg-uszzzz-ms-metric gives celsius and en-US-u-rg-uszzzz-ms-uksystem imperial gallons.

  • A unit whose quantity CLDR ships no preferences for falls back to base units rather than erroring: preferred_units/2 on ampere gives [:ampere], on candela-per-cubic-foot ["candela-per-cubic-meter"]. Derived compound base units come back as strings, not atoms.

  • A unit usage supplied as a string resolves instead of silently falling back to :default. Every usage CLDR defines now has an interned atom, which Localize.Unit.Preference.known_usages/0 lists, so usage: "fluid" returns imperial units for en-GB where it returned cubic inches.

  • RBNF alternation branches are evaluated against the number rather than the remainder. A branch carrying a quotient substitution saw 0, so Russian's 201 spelled "нольсти" instead of "двести"; Ukrainian, Polish, Czech and others were affected the same way.

  • RBNF fraction digits spell with the current rule set, a fraction rule's bracketed integer part survives, plural selection uses the numerator, and the >>> preceding-rule chain is built in full.

  • RBNF optional rule text renders unconditionally where ICU would not have split the rule in two — its base value must be positive and an even multiple of its divisor — so Afrikaans spells the year 1100 "elf honderd nul".

  • RBNF rule selection implements ICU's rollback rule: a rule taking a remainder is passed over for the one below when the number divides exactly but the rule's own base value does not. Bulgarian's financial cardinals no longer spell a trailing "и нула".

  • Localize.Message.format/3 selects a plural or ordinal variant by the number as its :number, :integer, :offset or :percent function displays it, so Russian {2 :number minimumFractionDigits=1} is other. An :integer operand given as a Decimal formats rather than raising.

  • MessageFormat 2 literals keep all their code points, as TR35 requires, where the parser normalized them to NFC; keys still match a selector's value in NFC. Localize.Message.Print.to_string/2 quotes a literal that is not an MF2 name, so a canonical message parses back unchanged.

[1.3.0] — September 21st, 2026

Added

  • Localize.Date.parse/2, Localize.Time.parse/2 and Localize.DateTime.parse/2 parse a localized date, time or datetime string back to a value. They delegate to calendrical, which the application must add as a dependency.

  • Localize.DependencyRequiredError is returned when an operation needs a companion package the application does not depend on, naming the :package and the :operation. It is what the new parse/2 functions return when calendrical is absent.

Changed

  • Breaking. Localize.validate_territory/1 returns the canonical territory code, so validate_territory("AN") is now {:ok, :CW} where it was {:ok, :AN} — likewise SU to :RU, DD to :DE, and CS and YU both to :RS. This matches validate_locale/1, which already resolved them.

Fixed

  • Localize.Utils.Math.mod/2 and amod/2 carry overloaded contracts, so integer arguments type as integer results under dialyzer. Downstream calendar arithmetic no longer types as float-possible when it flows through these functions.

  • Localize.validate_territory/1 accepts territory codes that CLDR replaces rather than lists: "UK", and the alpha-3 and numeric forms "GBR" and "826", were all rejected and now resolve to :GB. This matters for anything mapping a ccTLD to a territory, since .uk is the domain while GB is the code.

  • Localize.Interval.to_string/3 and to_parts/3 no longer raise FunctionClauseError for en-CA with fields: :month_and_day, format: :short. CLDR publishes an alt="variant" interval pattern for en-CA, and it is now resolved as a single date's pattern is, honouring :prefer.

  • en-CA skeleton formats such as :yMd and :MEd render the locale's standard pattern, 2026-05-03, rather than the day-first variant 3/5/2026 they returned whatever :prefer asked for. prefer: :variant still selects the variant.

[1.2.0] — August 16th, 2026

Changed

  • Breaking. Localize.Number.Parser.scan/2 now treats a space as a grouping separator in locales that group with one, so runs of digits that it previously returned separately can come back as a single number. Under fr, "chambres 12 14 16" was ["chambres ", 12, " ", 14, " ", 16] and is now ["chambres ", 121416]; pass lenient: false to require exact grouping sizes and get the old result. This is what lets "1 234,5" be found as one number, which is the point of the change.

  • Breaking. Localize.Number.parse/2 and scan/2 validate that grouping separators sit in plausible positions, matching ICU, so input that was previously accepted can now fail. Under fr, parse("3 4") was {:ok, 34} and is now an error, because a single digit is not a group in any locale.

  • Localize.Number.parse/2 and Localize.Number.Parser.scan/2 take a :lenient option governing that validation. true, the default, requires each group to be at least two digits, which is ICU's lenient rule; false requires exactly the locale's grouping size — three for most locales, and the 3-then-2 of en-IN.

Fixed

  • Timezone display names use the exemplar city CLDR ships rather than one derived from the IANA identifier. The lookup matched %{city: _} where the zone data keys :exemplar_city, so it never fired: 48 zones in en and 190 in ja were named from the identifier instead, losing renames (America/Godthab is "Nuuk"), accents ("Córdoba"), and in non-Latin locales the script entirely.

  • Timezone display names resolve three-part identifiers such as America/Indiana/Knox, whose leaf CLDR keys by string one level deeper. They were split into two parts, so the lookup sought a city named "Indiana/Knox" and fell back to "Knox" rather than CLDR's "Knox, Indiana".

  • Localize.Number.Parser.scan/2 finds a grouped number written with an ordinary keyboard space in a locale that formats with U+202F. Under fr, scan("1 234,5") was [1, " ", 234.5] and is now [1234.5].

  • Localize.Number.parse/2 treats every character in [:Zs:] as a grouping space, per TR35's loose matching for lenient parsing. It previously accepted only U+0020 and the locale's own separator, so a fr number carrying U+00A0 or U+2009 — as copied out of formatted output — failed to parse.

  • Localize.Number.parse/2 ignores every character in [:Cf:], per the same passage's instruction to ignore format characters, "in particular ... any RLM, LRM or ALM used to control BIDI formatting". CLDR writes the minus sign of ar, he, fa and 16 other locales as a BIDI mark followed by the sign, so a negative number copied out of that text previously failed to parse.

  • Localize.Number.parse/2 applies CLDR's parseLenients character folds, which were generated into the locale data but never read. The minus, plus, comma and full-stop families now fold to their canonical form, so the 18 locales writing minusSign as U+2212 — fa, fi, sv and no among them — parse their own negative numbers back. Per TR35 the fold applies to the locale's own separators as well as the input, which is what lets de-CH accept both spellings of its apostrophe group separator.

Added

  • Localize.DateTime.Timezone.exemplar_city/3 returns the localized exemplar city for an IANA timezone identifier. derive: false returns an error rather than deriving a name from the identifier, distinguishing a name CLDR vouches for from one this library invented.

[1.1.1] — August 16th, 2026

Fixed

  • Localize.Unit.to_string/2 selects the plural category from the rendered digits rather than the input value, as TR35 defines the plural operands over the source number — "the visual appearance of the digits of the result". The float 1.0 renders as 1 and is now "1 hectare" rather than "1 hectares", and conversely 1 with fractional_digits: 2 renders as 1.00 and is now "1.00 hectares" rather than "1.00 hectare".

  • Localize.Unit.localize/2 now resolves preferred units whose name is more than one word. It converted the atoms from Localize.Unit.Preference.preferred_units/2 with Atom.to_string/1, yielding "cubic_inch" where the parser wants "cubic-inch", so 35 of CLDR's 90 preferred units failed with a Localize.ParseError — taking the default usage of speed, volume, area, energy and pressure with them.

Changed

  • The package now declares Unicode-3.0 alongside Apache-2.0, and LICENSE.md gains a "Unicode Data" section setting out which CLDR and UCD data is embedded, what it becomes, and reproducing the Unicode copyright and permission notice as that license requires. Modules emitted by mix localize.unit.gen_conversions carry the same dual-license SPDX header, so a consumer running reuse lint over the generated file no longer needs a .license sidecar.

[1.1.0] — August 11th, 2026

Fixed

  • The Localize.Currency.t/0 type now declares :iso_digits as non_neg_integer() | nil. Historic currencies (for example :BGN) have no ISO 4217 minor-unit definition and carry nil, which the module's own is_nil/1 guards already handle; the previous non_neg_integer() type caused false pattern_match warnings in downstream code that guards against nil.

Added

  • mix localize.unit.gen_conversions generates a dependency-free unit conversion module from CLDR data, for embedded targets where compiling Localize costs more than the project itself. There is no unit selection to make: Localize's own parser is inlined into the generated module, so it accepts every identifier Localize.Unit accepts — prefixed, powered and -per- compound alike — through to_base/2, resolve/1 and a compile-time ~u sigil.

[1.0.1] — August 2nd, 2026

Fixed

  • Localize.LanguageTag.best_match/3 now prefers a territory's own language when candidates are otherwise equidistant, so sgs (Samogitian, spoken in Lithuania) matches lt rather than en-LT. The distance trie scores every candidate sharing the desired script and territory identically, which previously left the winner to depend on the order of the supported list.

  • Localize.ParseError reports an over-cap input as reason: :input_too_large with the byte counts in new :size and :limit fields, instead of interpolating them into a prose :reason string that its own type never allowed. The message, number and unit-identifier parsers all use it, and the oversized input is no longer retained on the exception.

[1.0.0] — July 31st, 2026

The first stable release. MF2 message validation is separated from parsing: Localize.Message.Parser.parse/1 checks syntax, Localize.Message.Validator.validate/1 checks the TR35 data-model rules, and everything that formats or serializes a message runs both. A message like .local $count = {$count :number} — a declaration that reads the variable it declares, which is a Duplicate Declaration error rather than a valid annotation of an external variable — is now rejected wherever it appears rather than only when formatted. The construct for annotating an external variable is .input {$count :number}.

Added

  • An ecosystem guide sets out the library's shape: a set of structured, locale-aware types, and the four operations — formatting, parsing, ordering and serialization — that apply both to them and to most of Elixir's own types. It tabulates which library provides each operation for each type.

  • Localize.Message.Validator.validate/1 is public, checking a parsed message against the TR35 data-model rules. Localize.Message.Parser.parse/1 remains syntax-only, so tooling that must accept invalid input — to_tokens/2 and the highlighters built on it — parses without validating, while everything that formats or serializes composes the two and reports syntax errors before data-model ones.

Fixed

  • Duplicate Declaration is now caught wherever a message is used, not only when it is formatted. .local $count = {$count :number} reads the variable it declares, so it was rejected by format/3 but serialized happily by canonical_message/2 and accepted at compile time as a Gettext msgid. Use .input {$count :number} to annotate an external variable.

  • Three further TR35 data-model rules are now enforced: Missing Selector Annotation (a selector that does not resolve, directly or through a chain of locals, to a declaration carrying a function), Variant Key Mismatch (a variant whose key count differs from the selector count), and Missing Fallback Variant (no variant with only * keys). None was detected previously — the first two were documented as enforced during match evaluation and were not, and all three could be formatted or serialized without error.

  • Localize.Message.canonical_message/2 no longer serializes a message that violates the data-model rules; it previously round-tripped every invalid form, including a .local declaration reading the variable it declares. The compile-time Gettext interpolator validates too, so a mis-authored msgid fails the build rather than surfacing at runtime.

  • The MF2 working-group data-model-errors.json conformance cases now assert the specific error reason rather than merely that some error occurred. The WG cases pass no bindings, so an incidental unbound-variable error satisfied the old assertion — which is how three unimplemented rules stayed green.

  • Localize.validate_territory/1 now accepts integer M49 codes as its typespec and documentation always promised — validate_territory(1) returns {:ok, :"001"}. An integer previously raised FunctionClauseError because the validator's guard omitted is_integer/1, even though its normalization already zero-padded integers. Numeric codes may also be given as strings ("001"); note that country-level M49 numbers such as 840 are not in CLDR's territory validity set, which uses the alpha-2 code ("US") there.

[1.0.0-rc.7] — July 28th, 2026

This release settles the :format / :style option naming across the library. The rule is now uniform: :format is how a value is rendered, :style is which variant of a name or pattern you get, and an option that selects something else again — Localize.Interval's choice of which date fields appear — is named for what it selects. Every rename below is a hard break with no alias; each entry names its replacement.

Changed

  • Breaking: Localize.Duration.to_string/2 and to_parts/2 rename the per-unit width override from :styles to :formats, so the whole-duration width and its per-unit overrides share one name (format: :short, formats: [hour: :long]). The :styles key, introduced in 1.0.0-rc.2 and never in a stable release, is now ignored like any other unknown option.

  • Breaking: Localize.Calendar.localize/3 returns {:ok, name} or {:error, exception} instead of a bare string, nil, or an error tuple, and gains localize!/3 for the bare name — the old union meant a template interpolating the result raised Protocol.UndefinedError when the locale was invalid. It now derives the part's value and delegates to display_name/3, so the two agree: its width option is :style (was :format), its default width is :wide (was :abbreviated), and an unknown part returns an error rather than raising FunctionClauseError.

  • Breaking: The calendar part and type names are unified on :day_of_week and :day_period: Localize.Calendar.display_name/3 takes :day_of_week (was :day) and localize/3 takes :day_period (was :am_pm). An unknown part or type now returns an error listing the valid names rather than raising FunctionClauseError.

  • Breaking: The day-period variant selector is day_period: :variant on Localize.Calendar.display_name/3 and localize/3, and on the date/time formatters that pass it through (Localize.Time.to_string(~T[15:00:00], format: "h a", day_period: :variant) renders "3 pm"). The :am_pm and :period aliases are removed, so every variant option is now named for the part it applies to, as :era already was.

  • Breaking: Localize.Interval.to_string/3 renames its :style option to :fields, because it selects which date fields appear (:date, :month, :month_and_day, :year_and_month) rather than a width — :format remains the width axis, and the two are now named for what they do. Localize.Interval.date_styles/0 becomes known_fields/0, and Localize.DateTimeIntervalFormatError carries :fields with reason :unknown_fields in place of :style / :unknown_style.

Documentation

  • Localize.DateTime.to_string/2 documents its :style, :date_format, and :time_format options, which were live but absent from the docs. :style selects the date/time wrapper (:at renders "April 8, 2026 at 12:00:00 PM", and falls back to the standard wrapper for :medium and :short, which CLDR does not define it for).

Fixed

  • Localize.Calendar.display_name/3 reports an unusable :style as a :style error listing the widths the field actually has, instead of blaming the value: display_name(:month, 3, style: :bogus) said value: 3, expected: "1..13". style: :short now resolves wherever CLDR carries it (the day parts) rather than silently returning nil.

  • Localize.Unit.to_string/2 no longer lets a stray :style option reach the SI-prefix and custom-unit paths, where it produced "5 MegaJoule" for style: :short instead of the correct "5 Megajoule". The option has been ignored since 1.0.0-rc.0 and is now ignored everywhere, as documented.

  • Localize.Duration.to_string/2 and to_parts/2 join duration parts with CLDR's unit list patterns matched to the format width, per ECMA-402 Intl.DurationFormat, instead of the standard "and" conjunction: :en now renders "3 days, 2 hr" (was "3 days and 2 hr") and "3d 2h" for format: :narrow.

  • Per-compound units whose denominator carries a constant keep that count in every width: curr-usd-per-30-day renders "$10.00/30 days" for `format: :short`, where the denominator's precomposed `per_unit_pattern` ("{0}/d") previously swallowed the count and gave "$10.00/d". Counted denominators now compose through the locale's compound.per pattern, which also stops a numerator whose symbol contains the denominator noun being corrupted (narrow candela-per-30-day gave "10c30 d/30 d", now "10cd/30 d").

[1.0.0-rc.6] — July 27th, 2026

Fixed

  • A cached locale left over from an earlier release is now reported as stale rather than missing: Localize.LocaleIsStaleError names both data versions and how to refresh, where the previous Localize.LocaleNotFoundInCacheError sent the reader looking for a file that was present all along. A genuinely absent locale still reports as not found.

Changed

  • Metazone data is updated to tzdata 2026c: Africa/Casablanca and Africa/El_Aaiun resume Western European Time from 2026-09-20, so time zone names for those zones are correct on and after that date.

[1.0.0-rc.5] — July 27th, 2026

Fixed

  • All sixteen CLDR unit grammar cases are now normalized into the locale data (previously only seven were), so Localize.Unit.to_string/2 with grammatical_case: values like :prepositional (ru), :partitive (fi), or :terminative (hu) formats correctly instead of silently falling back to nominative. The locale data version is bumped to 48.2:2 and all locale data is regenerated.

  • Compound unit grammatical-case and gender patterns are now merged deterministically during locale data generation; the previous shallow merge silently dropped case entries, with the surviving subset depending on OTP map iteration order.

  • The BCP 47 validity data generator no longer crashes on deprecated timezone codes (those with a preferred replacement, such as camtr → cator); the generated validity data is unchanged, but it can once again be regenerated from source.

  • Localize.Unit.parse/2 resolves unit symbols that map to compound identifiers — "1 m/s" → meter-per-second, "3 kWh" → kilowatt-hour — instead of failing on the internal underscore key form. Closes #42.

  • Times-compound units with no precomposed CLDR pattern (tonne-kilometer) now format as their localized name ("5 metric ton-kilometers", "5 t⋅km", "5 tonnes-kilomètres") instead of the raw identifier, composed per the CLDR grammatical derivation loaded from grammaticalFeatures.xml. Closes #43.

  • Per-compound units whose denominator has no per-unit pattern now localize through the locale's compound.per pattern (Polish "5 jardów na milimetr") instead of falling back to an English "per".

  • Localize.Unit.define_unit/2 accepts a derived unit as base_unit (e.g. "day") and folds it to its fundamental base at registration, so the custom unit is convertible and compatible?/2 returns true. Closes #44.

  • Person-duration units (year-person, month-person, week-person, day-person) format as their base unit ("5 years"), matching ICU, instead of the raw identifier.

  • SI-prefixed units with no precomposed CLDR pattern (megajoule, gigajoule, nanogram) now compose their display from the prefix and base unit ("5 MJ", "5 megajoules", with CLDR combineLowercasing for capitalising locales) instead of the raw identifier. Closes #46.

Changed

  • Generated locale ETF files and the download-integrity hash manifest are now serialized with the term_to_binary :deterministic option, making the bytes reproducible across operating systems, architectures, and OTP versions. This lets the manifest generated during development match the data generated in CI and served from the CDN, so runtime download verification succeeds regardless of where each was produced. All bundled supplemental, validity, and collation ETF data is regenerated with the same option.

  • Compiling a number format pattern is ~40% faster on a cache miss: the regexes used to analyse the pattern are compiled once at startup rather than recompiled on every call. Formatting is unaffected (compiled patterns are already cached).

  • Formatting and parsing read locale data with the validated Localize.LanguageTag.t/0 rather than its locale id, so none of the reads a single call makes — 18 for a date, 100 for an interval of two date-times — resolves the locale again. Localize.Date.to_string/2 is about 2.5 times faster and Localize.Interval.to_string/3 about a quarter faster; a locale given as an id or a string is still validated once, at the boundary.

  • A standard format name is no longer matched as though it were a skeleton. Asking for :short, :medium, :long or :full on a value holding fewer fields than that format writes spelled the name out as fields — "medium" as month, weekday, day — and ranked it against the locale's formats before failing, so an interval of two partial date-times is now about five times faster (342 µs to 70 µs for two values of one day holding an hour apiece).

  • Skeleton matching breaks a tie between two equally ranked formats by format id, where the winner followed the order the locale's format table happened to be read in and so could differ between VM instances.

  • Localize.Locale.get/3 reads the locale data and loads the locale only where that read does not resolve, rather than confirming it is loaded first: an already-loaded locale is by far the common case and the check cost more than the read. A key path that is genuinely absent pays one extra read.

  • Locale data files are about 81% smaller — en.etf is 121 KB where it was 655 KB — as they are compressed, and each carries its format tables' keys already tokenized for skeleton matching, which matching derived on every call. Loading a locale pays about 8% more for the one decode it does.

  • Reading a time keeps the patterns it may be written with, in the order it tries them, beside the regexes already compiled from them. Ranking those patterns runs a regex over each of them, and a date and time is read by trying its time half at every boundary the text allows, so the list was gathered and ranked again for each of those attempts: Localize.Time.parse/2 is about seven times faster (302 µs to 44 µs) and Localize.DateTime.parse/2 about twice (505 µs to 236 µs).

  • A time half carrying no digit is refused before the locale's patterns are tried. A time is read from an hour, so no pattern can read text with no digit in it, where splitting a date from a time leaves halves like "AEST" to be read against every pattern the locale has before failing.

  • A date and time is read with each distinct time half read once, where a reading ran for every split that offered it. The splits a text allows share time halves — a date half with its trailing separator and one without leave the same text behind — and the time half is read first, so text that no split reads at all is about twice as fast to refuse.

[1.0.0-rc.4] — July 24th, 2026

Added

  • Localize.Unit.parse/2 and parse_unit_name/2 (with bang variants) parse strings like "1kg", "2,5 kg" or a localized "2 Tage" into units, matching locale unit names, canonical identifiers and custom units, with :only/:except filters disambiguating names like "2w" (watt versus week) — the migration path from Cldr.Unit.parse/2.

Fixed

  • Custom units with hyphenated names ("double-cubit") resolve everywhere a unit identifier is accepted: registered names match wholesale in the unit parser instead of being split at hyphens by the compound grammar.

[1.0.0-rc.3] — July 23rd, 2026

Added

  • Localize.Interval.to_parts/3 and to_parts!/3 decompose date, time, and datetime intervals into typed parts with ECMA-402 :source tagging, across split patterns, fallback patterns, and equal-endpoint collapse. Open intervals (a nil endpoint) are not supported in parts form.

  • Localize.Unit.to_range_parts/3 and to_range_parts!/3 decompose a unit range: the numeric range parts keep their sources and the unit pattern text carries source :shared.

  • Localize.Duration.to_parts/2 and to_parts!/2 decompose a duration into per-field unit parts (numeric segments carry a :unit key) joined by :literal list separators.

  • Date, time, and datetime formatting accept a :number_system option: any CLDR numbering system renders all numeric fields ("Mar ๑๕, ๒๐๒๕" with :thai), equivalent to a -u-nu- locale extension.

[1.0.0-rc.2] — July 23rd, 2026

Added

  • Date/time skeletons accept fractional seconds (S–SSS): per TR35 the field is stripped for matching and appended to the resolved pattern's seconds field ("9:30:12.34 AM" for :hmsSS in en).

  • Localize.Date.to_parts/2, Localize.Time.to_parts/2 and Localize.DateTime.to_parts/2 (plus bang variants) decompose dates and times into typed field parts per ECMA-402 formatToParts, across standard formats, skeletons, pattern strings and combined date+time wrappers.

  • Localize.Number.to_range_parts/3 and to_range_parts!/3 return typed range parts with ECMA-402 :source tagging (:start_range, :end_range, :shared) and :approximately_sign handling.

  • Localize.Number.to_parts/2 decomposes the :currency_long and :currency_long_with_symbol formats; the pluralized currency name is a :currency part.

  • Localize.Unit.to_parts/2 and to_parts!/2 decompose a unit into its number parts plus :unit and :literal segments per ECMA-402.

  • Localize.Unit.to_range_string/3 and to_range_string!/3 format two like units as a range ("2–5 kilometers", fr "0–1 jour"), selecting the pattern's plural category from the TR35 plural-range rules.

  • Localize.Number.PluralRule.Range selects the TR35 plural category for numeric ranges (plural_rule/3 for categories, plural_rule_for/3 for numbers).

  • Localize.List.to_parts/2 and to_parts!/2 return :element and :literal parts for list formatting per ECMA-402 Intl.ListFormat formatToParts.

  • Localize.DateTime.Relative.to_parts/2 and to_parts!/2 return relative-time parts; the number part carries a :unit key matching the JS part shape.

  • Localize.Duration.to_string/2 accepts per-unit :display (:auto / :always) and :styles (:long / :short / :narrow) overrides, mirroring ECMA-402 DurationFormat per-unit options.

Fixed

  • :rounding_priority :auto (and the unset default) ignores fraction-digit bounds entirely when a significant-digit bound is present, per ECMA-402; previously a binding fraction bound still truncated the significant-digit result.

  • The separator between adjacent seconds and fractional-second pattern fields is the locale's decimal separator per TR35 (de renders "09:30:12,34"); previously a period was hardcoded.

[1.0.0-rc.1] — July 23rd, 2026

Added

  • Localize.Number.to_parts/2 and to_parts!/2 format a number into typed segments per ECMA-402 formatToParts, covering decimal, currency, percent, scientific, compact and algorithmic-system output.

  • Localize.Number.to_string/2 accepts :minimum_integer_digits (zero-pad the integer part, ECMA-402 minimumIntegerDigits), :trailing_zero_display (:strip_if_integer per trailingZeroDisplay), and :rounding_priority (:more_precision / :less_precision per roundingPriority).

  • Localize.DateTime.Relative.to_string/2 accepts numeric: :always to force numeric output ("1 day ago" instead of "yesterday"), per ECMA-402 RelativeTimeFormat.

  • Localize.Collation.known_collations/0 and Localize.DateTime.Timezone.known_timezones/0 (re-exported on Localize) return the CLDR collation and canonical IANA time zone inventories for Intl.supportedValuesOf/1.

  • The MF2 numeric functions accept the TR35 minimumIntegerDigits, minimumSignificantDigits, maximumSignificantDigits, trailingZeroDisplay and roundingPriority options, mapped onto the corresponding Localize.Number.to_string/2 options.

Changed

  • The :currency_long formats apply the currency's fraction digits and select the plural name from the displayed value per ECMA-402 currencyDisplay: "name": to_string(1, format: :currency_long, currency: :USD) renders "1.00 US dollars" (previously "1 US dollar").

  • Relative time offsets of zero format with the future pattern ("in 0 days") per ECMA-402 and ICU.

[1.0.0-rc.0] — July 22nd, 2026

Added

  • Localize.Number.to_string/2 accepts a :sign_display option (:auto, :always, :except_zero, :negative, :never) mirroring ECMA-402's signDisplay across decimal, currency, percent, scientific and compact formats.

Changed

  • Number.System.system_name_from/2 resolves any CLDR numbering system for any locale instead of returning a :not_for_locale error, and Format.formats_for/2 and Symbol.number_symbols_for/2 inherit missing data from the locale's default system per CLDR root aliasing.

Fixed

  • The implicit negative subpattern is now the minus sign prefixed to the whole positive pattern per TR35, so Localize.Number.to_string(-1, currency: :USD) renders "-$1.00" (previously "$-1.00") matching ICU and Intl.NumberFormat.

  • Localize.Number.to_string/2 honours any CLDR numbering system via -u-nu- or :number_system, per TR35/ICU: en-u-nu-thai renders Thai digits instead of an UnknownRbnfRuleError, and algorithmic systems (zh with :hans, en-u-nu-roman) format via their RBNF rules.

  • The MF2 numeric functions honour an algorithmic numberingSystem (hans, roman, …) via the system's RBNF rules, matching Localize.Number.to_string/2 and ICU.

  • Localize.Unit.to_string/2 with backend: :nif honours the :format option: the NIF call now receives the requested width, so format: :short renders "100 m" on both backends instead of falling back to the long form.

  • Localize.Duration.to_time_string/2 honours TR35 single-quote literals in patterns, so format: "h'h' m'm'" renders "37h 48m" instead of substituting the quoted letters as field symbols.

Removed

  • The deprecated locale-scoped delegates are removed: Language.available_languages/1 and known_languages/1 (use languages_for/1 / language_names_for/1), Script.available_scripts/1 and known_scripts/1 (use scripts_for/1 / script_names_for/1), Subdivision.available_subdivisions/1 and known_subdivisions/1 (use subdivisions_for/1 / subdivision_names_for/1), and Territory.available_styles/0 (use known_styles/0).

  • The Currency positional filter forms are removed: currencies_for_locale/3, currencies_for_locale!/3, currency_strings/3 and currency_strings!/3, along with the positional second argument of the arity-2 forms. Pass the :only and :except options instead; a positional filter now raises ArgumentError so it cannot be silently misread as empty options.

  • The deprecated option aliases are removed: Localize.Duration.to_string/2 :style (use :format), Localize.Unit.to_string/2 :style (use :format), Localize.quote/2 :style (use :format), and Localize.Unit.display_name/2 :format (use :style). The former alias keys are now ignored like any other unknown option.

[0.50.0] — July 15th, 2026

Added

  • Localize.LanguageTag.remove_likely_subtags/2 and !/2 accept favor: :script | :region to select the TR35 removal variant (zh-Hant-TW minimizes to "zh-Hant" or "zh-TW"), verified against the full CLDR likely-subtags test data.

  • The MessageFormat working group's :test:function, :test:format and :test:select registry functions are implemented, so the WG conformance suite's selection-mechanics and fallback cases now run.

  • The MF2 numeric functions accept the signDisplay option (auto, always, exceptZero, negative, never), with the plus sign taken from the locale's number symbols.

  • An MF2 pattern expression that re-annotates a declared variable operates on the declaration's original value, and numeric functions inherit unset options from a numeric declaration — .local $x = {41 :integer signDisplay=always} rendered with {$x :offset add=1} produces "+42".

  • The locale download base URL can be overridden with config :localize, locale_base_url: "..." for deployments mirroring the locale files; downloads are verified against the bundled hash manifest regardless of source.

Changed

  • An unannotated MF2 placeholder with a numeric operand formats with the locale-aware :number default ({$pi} renders "3.142" in en and "3,142" in fr), matching the MF2 implicit formatting rules; previously the value was stringified verbatim.

  • MessageFormat 2 follows the specification's error semantics: unknown functions are an error (Localize.FormatError reason :unknown_function) instead of formatting the operand, and data-model validation rejects duplicate declarations, duplicate option names, and duplicate variants (NFC-normalized keys) with dedicated error reasons.

  • MessageFormat 2 function validation is strict per TR35: the select option must be a literal set directly on the selector expression, digit-size options must be non-negative integers, string operands of numeric functions must match the number-literal production, and :currency, :unit, :date, :time and :datetime are rejected as selectors.

  • Localize.Number.Format.Options is documented again: validate_options/2 is the supported way to pre-resolve formatting options for hot loops (as the Performance guide describes), so the module is part of the public API.

  • The TR35 conformance guide documents the MessageFormat 2 working-group suite exclusions precisely: unknown-function fallback, duplicate declaration/option validation, select and digit-size option validation, u:dir/u:id expression options, and the :isolate bidi strategy deviation.

Deprecated

  • Localize.Currency.currencies_for_locale/2,3, currencies_for_locale!/2,3, currency_strings/2,3 and currency_strings!/2,3 take the include/exclude filters as :only and :except options instead of positional arguments. The positional forms still work but are deprecated and will be removed by Localize 1.0 and no later than December 2026.

Fixed

  • Calendar- and provider-module probes in date/datetime formatting (era year, day of era, ISO week and day of year, day of week, CLDR calendar type) and Localize.Locale.Provider.allow_download?/1 ensure the module is loaded before function_exported?/3, so a cold module no longer silently takes the gregorian or default fallback branch.

Security

[0.49.0] — July 12th, 2026

Fixed

  • Change the namespaces on the leex lexers and yecc parsers to avoid module name clash with ex_cldr versions. Thanks to @jerryluk for the PR. Closes #40.

  • The coverage ignore list follows the renamed lexer/parser modules, restoring measured coverage above the 90% CI threshold; a guard test now fails when an ignore-list entry names a module that no longer exists.

[0.48.0] — July 8th, 2026

Added

  • Localize.Unit.to_string/2 accepts humanize: true to render any unit in the unit people expect for its magnitude: bit-, byte-, hertz- and watt-based units scale through the humanize/2 prefix ladder (honouring system: :si | :iec) and all other units render through usage-based preferences. An explicit :usage option keeps precedence for preference-scaled units.

  • Localize.Unit.humanize/2 and humanize!/2 also scale hertz- and watt-based units through the SI prefix ladder ("2.5 gigahertz", "3.2 gigawatts") — like bits and bytes, these are locale-invariant quantities that CLDR's unit preferences do not cover. IEC prefixes remain restricted to bit- and byte-based units.

[0.47.0] — July 7th, 2026

Added

  • Localize.Unit.humanize/2 and humanize!/2 convert a byte- or bit-based unit to the prefix that best fits its magnitude, so 1_500_000 byte formats as "1.5MB" with format: :narrow. The :system option selects :si (powers of 1000, default) or :iec (powers of 1024) prefixes.

  • Localize.Number.to_string/2 accepts format: :short and format: :long as ex_cldr-compatible aliases, resolving to :decimal_short / :decimal_long, or to :currency_short / :currency_long when a :currency is specified.

  • mix localize.update_cldr orchestrates the CLDR data-update pipeline (copy sources → generate supplemental → compile gate → generate locales → test gate), with --check preflight, --locales subsetting, and each step isolated in a fresh VM. The full update process is documented in the consolidated CLDR Update Guide (CLDR_UPDATE_INTEGRATION.md).

  • A Claude Code skill covering all major subsystems (numbers, dates/times, units, lists, collation, MessageFormat 2, locale validation and configuration), installable via /plugin marketplace add elixir-localize/localize. All 300+ skill examples are execution-verified against the library.

Fixed

  • Timezone formatting consults zone-level names before metazone names per TR35, so a UTC DateTime renders "UTC" / "Coordinated Universal Time" instead of "GMT" / "Greenwich Mean Time", and Europe/London in summer renders "British Summer Time" instead of "GMT+01:00". Thanks to @gmile for the PR. Closes #37.

  • Date and time formatting applies the locale's default numbering system to all numeric fields, so locale: :mr renders "१५ मार्च, २०२१" (likewise bn, fa, my and others) matching ICU. Pattern-level numbers= and caller :number_system_overrides keep precedence. Thanks to @gmile for the PR.

  • A -u-nu- locale extension overrides the default numbering system in date and time formatting per TR35: "mr-u-nu-latn" renders latn digits and "en-u-nu-deva" renders deva digits, including when the locale is set with Localize.put_locale/1.

  • Territory-specific collation tailorings apply from locale data: sorting with locale: "fr-CA" now uses French Canadian backwards accent comparison. Previously the lookup used only the bare language, silently dropping fr-CA's [backwards 2] override.

  • Localize.Calendar.first_day_for_locale/1 honours the -u-fw- extension per TR35: "en-u-fw-mon" returns 1 (Monday) instead of the territory default.

[0.46.0] — July 6th, 2026

Changed

  • Full compile time drops from ~66s to ~3.5s: the RBNF lexer grammar no longer explodes the generated DFA (its generated Erlang shrinks from 891KB to 41KB), and the language/subdivision validity checks compile to a single map lookup instead of thousands of guard clauses. Token streams and validation results are unchanged.

  • The collation tailoring table loads at runtime on first use instead of being embedded at compile time, shrinking the library's largest beam from 1.7MB to 44KB.

  • Every module that embeds ETF data at compile time now declares the file with @external_resource, so a CLDR data regeneration correctly recompiles the timezone, time-preference, validity and script-mapping modules.

[0.45.0] — July 5th, 2026

A quality release: the codebase now passes mix credo --strict with zero findings (enforced in CI), the full MessageFormat 2 working-group conformance suite runs against the formatter, and the documentation gains four new guides. A test-coverage push from 70% toward 90% surfaced — and this release fixes — more than twenty conformance and correctness bugs across date/time, number, unit, collation and language-tag handling.

Upgrading from 0.42 or earlier? Version 0.43.0 renamed the inventory functions from the available_*/known_* names to the uniform known_*/supported_*/*_for scheme and swapped two :format/:style option names. Every old name still works as a deprecated delegate until Localize 1.0 — see the 0.43.0 entry below for the full list.

Added

  • The complete MessageFormat 2 working-group conformance suite (16 test files including all nine functions/ suites) now runs against the formatter: 250 cases asserting formatted output and expected errors, with unimplemented features excluded and documented per case.

  • Four new guides: plural rules, list formatting, locale validation and matching, and display names. Every example is execution-verified.

  • Localize.Territory.territory_names_for/1 and territories_for/1 return the localized territory-name inventory for a locale, completing the *_for family across languages, scripts, subdivisions and territories.

  • CLDR metazone data joins the supplemental-data pipeline: Localize.DateTime.Timezone.metazone_for/2 returns the metazone for an IANA zone at a given instant, and zone_for_metazone/2 returns a metazone's representative zone per territory. The z/v format symbols now draw on the full 191-metazone CLDR mapping instead of a 20-zone builtin table.

  • A documentation depth pass adds around 150 execution-verified doctest examples and 40 ### Options sections across the number, unit, date/time, locale, territory, list, collation and message modules, and corrects stale claims (RBNF number-system conversion, locale defaults, the validate_locale/1 matching warning).

  • A test-coverage push adds around 1,700 tests across exceptions, collation, date/time formatting, MessageFormat 2 tooling, language-tag validity, number/unit internals and the runtime plumbing, raising runtime-library line coverage from 70% to 91%. The 90% threshold is now enforced in CI.

Changed

  • The MF2 :offset function follows the specification: it takes exactly one of the add or subtract options (non-negative integer) and adjusts the operand for both formatting and selection. The previous ICU-style offset= option, which only subtracted during selection, is replaced.

  • The MF2 numberingSystem function option accepts any valid CLDR numbering system, matching Intl/ICU: {$n :number numberingSystem=thai} renders Thai digits in any locale. Unknown system names still return an error; the :number_system option of Localize.Number.to_string/2 is unchanged.

  • Credo (strict) added to the CI lint stage with a committed .credo.exs. Policy: the Design.AliasUsage check is disabled (Localize module names such as List, Date and String shadow the stdlib when aliased; aliasing is applied opportunistically where safe), nesting depth stays at the default maximum of 2, and complexity/apply/arity exceptions are documented inline at each site.

  • Deeply nested functions across the library were refactored into multi-clause private helpers with pattern matching (~120 helpers). Behavior is unchanged; the full test suite, dialyzer and doc builds gate the refactor.

  • Logger configuration for this project's dev/test environments now renders the domain: :localize metadata attached to Localize log messages.

Fixed

  • The U (cyclic year) and r (related Gregorian year) format symbols work: U renders the localized sexagesimal name (丙午, "bing-wu") from the calendar's cyclic name sets and r the Gregorian year in which the calendar year begins, both probing the date's calendar module per the Calendrical protocol. Previously both rendered the raw year number.

  • Date/time skeleton matching compared candidate format fields against the wrong skeleton fields, making format selection nondeterministic across VM runs (format: :yMdHm could render "Jul" or "July" run to run). Selection is now deterministic, prefers the exact skeleton, and matches text-field widths by class (E ≡ EEE) per TR35.

  • The "alt" language code (Southern Altai) was unreachable: the data pipeline stored it as the atom :alt (a collision with the alt-variant style key), so Localize.Language.display_name("alt") returned an error. Fixed at data load; the pipeline fix lands with the next CLDR data regeneration.

  • -t- extension dates encode with zero padding (ja-t-it-m0-ungegn-20070315 round-trips exactly); previously single-digit months and days encoded with an embedded space, producing an invalid tag.

  • Localize.validate_locale("en-u-vt-00a4") no longer raises during canonical-id formatting, and Localize.LanguageTag.parse("zh-min-nan") no longer raises ArgumentError (the grammar dropped the extended-language tags for multi-subtag forms).

  • CLDR {1}…{0}… substitution patterns with two literals placed the substituted items in the wrong order.

  • Collation reorder: [..., :others] places unmentioned scripts at the :others position per TR35; previously they kept unremapped weights, producing orderings incomparable with the reordered scripts.

  • The collation max_variable option takes effect in the Elixir backend: :space narrows and :symbol/:currency widen the shifted range per the UCA. Previously the setting was ignored and shifting was fixed at the punctuation boundary.

  • Compound unit identifiers canonicalize in the TR35 unitQuantity order, so "kilowatt-hour" keeps its CLDR pattern and renders "2 kilowatt-hours" instead of "2 hour-kilowatt".

  • :currency_long_with_symbol renders both the symbol-formatted number and the currency name ("$123.00 US dollars"); both long currency formats now derive the currency from the locale when no :currency option is given.

  • Localize.Interval.to_string/3 returns an error for mixed endpoint kinds (e.g. a Date and a Time) instead of rendering malformed output, and equal Time endpoints honour the :time_format option in the single-time fallback.

  • The Q and q date-format symbols were swapped: Q now renders the format-context quarter and q the stand-alone quarter per TR35. Visible only in locales whose quarter names differ by context.

  • Doubled apostrophes inside quoted date-format text render a literal apostrophe ('o''clock' → "o'clock"); previously the escape was dropped.

  • Zone format symbols (Z, O, v, V, x, X) render empty for values without a time zone, matching z; previously they fabricated a UTC zone.

  • Localize.Duration.new/2 accepts NaiveDateTime pairs (previously raised) and rejects reversed Time pairs with an error instead of returning a negative duration.

  • RBNF plural substitutions ($(cardinal,…)$) select the plural category on the quotient of the rule divisor per ICU: Russian 2,000,000 spells out as "два миллиона", not "два миллионов".

  • Requesting a spellout ruleset a locale does not have returns Localize.UnknownRbnfRuleError — with its available: field now populated — instead of silently formatting digits.

  • The :spellout format resolves to spellout-numbering (ICU's default ruleset) deterministically; previously the choice among a locale's gendered rulesets varied across OTP releases (German 1.5 could spell "eine", "ein" or "eins" depending on the VM).

  • Numeric collation computes the value of non-ASCII digits from their Unicode block's zero digit: Arabic-Indic "٢" sorts before "١٠" and "٠" compares equal to "0"; previously digit values were computed modulo 10 of the codepoint, wrapping mid-block.

  • :fractional_digits applies to numbers with a zero integer part: Number.to_string(0.4, fractional_digits: 0) returns "0" (previously "0.4"), with float and Decimal inputs rounding identically across all rounding modes.

  • Localize.Utils.Map.extract_strings/2 no longer drops strings that precede a nested list or map, and underscore_keys(nil) returns nil instead of raising.

  • Number.to_range_string/3 with approximate: true formats the full range ("~3–5"); previously the range end was silently dropped.

  • Number.PluralRule.plural_type/2 returns a bare category atom on both backends; the NIF backend previously returned an {:ok, atom} tuple.

  • Converting a mixed unit to another mixed unit works (foot-and-inch → yard-and-foot-and-inch); previously it always returned a :mixed_units error.

  • Unit plural categories for Decimal values honour the visible fraction (Decimal "1" renders "1 meter"), and denominator units keep their SI prefix and power ("per 100 kilometers", not "per 100 meter").

  • Localize.Utils.Math.power/2 with a fractional exponent between 0 and 1 returned the reciprocal (power(4, 0.5) gave 0.5); Decimal bases now always return Decimals.

  • Locale downloads run in a dedicated :localize httpc profile with the proxy set per request, so a configured proxy no longer leaks into later requests or into the host application's default httpc profile.

  • Display names of parsed (unvalidated) language tags with -t- extensions or multi-part -u- values no longer crash or mangle subtags, and multiple -u-dx- scripts render translated and list-separated.

  • Localize.Date.to_string/2 no longer overwrites a user-supplied :number_system_overrides option.

  • Small fixes: UnknownSubdivisionError carries the original input instead of nil; UnknownCurrencyError for a binary code always carries the binary (previously the atom when it happened to exist); MF2 match results deduplicate their bound-variable lists; Localize.Utils.Json.decode!/1 raises ArgumentError on trailing data instead of a bare MatchError.

  • Language tags with duplicate singleton subtags (en-u-ca-gregory-u-nu-thai) are rejected with a parse error per RFC 5646; previously the second occurrence was silently dropped.

  • Grandfathered language tags resolve to their preferred values from the CLDR alias data: i-klingon canonicalizes to tlh (displaying "Klingon"), zh-min-nan to nan, en-GB-oed to en-GB-oxendict, and the sign-language tags to their ISO codes. Previously irregular grandfathered tags parsed to an empty tag.

  • The MF2 Elixir backend reports the same errors as the NIF backend: an unbound variable used as a function option value returns a bind error, and an invalid :locale option returns a locale error; both were previously silently ignored.

  • Per-compound units without a direct CLDR pattern compose from the numerator and the per-pattern: foot-per-second renders "2 feet per second" instead of the raw identifier.

  • MF2 :percent selection multiplies the operand by 100 before plural-category selection, matching the formatted value per the specification.

  • An MF2 .match on an unbound variable returns an unresolved-variable error instead of silently selecting the fallback variant.

  • A duplicated boolean assertion in the message custom-function tests masked half of an intended check; general mechanical cleanups (map_join, empty-enum checks, number underscores, redundant with clauses) applied throughout.

[0.44.0] — July 4th, 2026

This release closes the TR35 conformance gaps in the date/time formatting layer and fixes locale-fidelity bugs surfaced by newly-wired conformance data.

Added

  • Flexible day periods: the B format symbol selects CLDR day periods ("in the morning", "mittags", "noon", "midnight") from the day-period rules now in the supplemental data pipeline, and b renders noon/midnight at the exact points. Languages without rules fall back to AM/PM.

  • Localize.LanguageTag.U.parse/1 and parse!/1 parse a standalone BCP 47 -u- extension string (with or without the u- singleton) into a validated Localize.LanguageTag.U struct, using the same canonicalization as Localize.validate_locale/1. Localize.LanguageTag.U.encode/1 is now a documented public function.

  • Downloaded locale files are verified against a SHA-256 hash manifest bundled with the package (priv/localize/locale_hashes.etf, generated by mix localize.generate_locale_hashes at release time), before any decode or cache write. A mismatch or missing entry fails the download with Localize.LocaleIntegrityError; when no manifest is present a one-time warning is logged and verification is skipped.

  • The rbnf conformance suite now runs the vendored ICU reference data for de, es and fr in addition to en, and the likely-subtags suite asserts the CLDR FAIL rows.

Fixed

  • Localize.Validity.U.encode/2 emits the preferred BCP 47 spelling instead of a deprecated alias (:islamic_civil encodes as "islamic-civil", not "islamicc"), and decode/2 accepts struct-field atom keys and the canonical hyphenated calendar spellings ("islamic-civil").

  • Week fields honour the locale's week configuration: w and Y compute week-of-year from the locale's first day and minimum days (2023-01-01 is week 1 in en, week 52 of 2022 in de), and W computes week-of-month accordingly. Previously all three were ISO-only.

  • Multi-replacement territory aliases follow TR35 Annex C: hy-SU canonicalizes to hy-AM via the likely territory, uk-SU to uk-UA, sr-YU to sr-RS; previously the first entry (RU) was always taken.

  • RBNF decimal-format substitutions (=#,##0=) format with the rule's locale instead of the process locale, so fr digits-ordinal renders "1 141e" and de "1.141." rather than the English "1,141".

  • Likely-subtags no longer fabricates mappings for languages without one: private-use languages (qaa) pass through unmaximized per the CLDR test data instead of inheriting the und mapping.

  • Locales whose language has no CLDR match resolve to root (und) data instead of the alphabetically nearest wrong language — tlh no longer formats with Afar data.

  • Compact formats no longer raise on a negative :fractional_digits option.

Changed

  • mix test --cover now excludes build-time tooling (the data-generation pipeline, mix tasks, compile-time parser generators) from coverage measurement so the number reflects the runtime library.

[0.43.0] — July 4th, 2026

This release settles the public API naming and error conventions ahead of 1.0. Every renamed function keeps its old name as a deprecated delegate that will be removed by Localize 1.0 and no later than December 2026.

Deprecated

The naming rule is now uniform: known_* is the locale-independent CLDR universe, supported_* reflects configuration, and *_for is data localized into a display locale. The renamed functions are:

  • Language.available_languages/1 → Language.languages_for/1.

  • Language.known_languages/1 → Language.language_names_for/1.

  • Script.available_scripts/1 → Script.scripts_for/1.

  • Script.known_scripts/1 → Script.script_names_for/1.

  • Subdivision.available_subdivisions/1 → Subdivision.subdivisions_for/1.

  • Subdivision.known_subdivisions/1 → Subdivision.subdivision_names_for/1.

  • Territory.available_styles/0 → Territory.known_styles/0.

Two option names are also swapped for consistency; the deprecated keys remain accepted until 1.0:

Changed

  • Language.display_name/2 and Script.display_name/2 return {:error, %Localize.InvalidValueError{}} for an invalid :style or :fallback option instead of raising; the ! variants raise as before.

  • Localize.quote/2 returns an error for an unknown format instead of silently returning the input string unquoted.

  • The backend: :nif paths of Number.to_string/2, Number.PluralRule.plural_type/2, Unit.to_string/2 and Message.format/3 now validate the locale through Localize.validate_locale/1 and pass ICU the canonical BCP 47 string, so both backends resolve aliases and -u- extensions identically.

  • Internal implementation modules (Localize.Utils.*, collation internals, data plumbing) are no longer part of the documented API. The hex package metadata links now point at hexdocs.

Added

[0.42.0] — July 4th, 2026

Changed

  • Compact decimal/currency formats (:decimal_short, :decimal_long, :currency_short) now apply the ECMA-402/ICU default precision of at most two significant digits on the mantissa: 1234 renders "1.2K" (previously "1K"). Pass :fractional_digits or :max_fractional_digits to override.

  • The json_polyfill dependency is no longer declared conditionally at publish time. On OTP 26, add {:json_polyfill, "~> 0.2 or ~> 1.0"} to your own dependencies; the supervisor raises at application start with instructions if the :json module is missing.

Fixed

  • Compact-format plural selection now uses the mantissa as displayed, per TR35, fixing grammatically impossible output such as es "1 millones" for 1,050,000. Exact-match count="1" compact patterns (fr 1000 → "mille") are now selected, matching ICU.

  • Plural rules for compact values {mantissa, exponent} now compute the n/i/v/w/f/t operands after shifting by the exponent per the TR35 operand table, and Decimals with positive exponents (1e6) no longer report phantom visible fraction digits.

  • BCE years render era-relative for Calendar.ISO dates: ISO year -1 formats as "2 BC", year 0 as "1 BC" (previously "-1 BC"/"0 BC").

  • Significant-digit rounding no longer forces a trailing fraction digit when the rounded value is integral: 1234.567 at 3 significant digits renders "1,230" (previously "1,230.0"), and minimum significant digits now pad trailing zeros ("1.00" at min 3).

  • A corrupt or truncated locale cache file is treated as a cache miss/stale instead of raising, and cache writes are atomic (write-temp-then-rename) so a crash mid-write can no longer tear a cache file.

  • Locale downloads no longer follow HTTP redirects — the CDN URL is fully known, so a redirect is always refused.

  • An unknown :usage option to Localize.Unit.Preference.preferred_units/2 no longer creates atoms from user input; it falls back to :default preferences per TR35.

Added

  • The hex package now includes the NIF sources (c_src/), so LOCALIZE_NIF=true builds from the published package.

[0.41.3] — July 1st, 2026

Changed

  • Localize.LanguageTag's canonical_locale_id field — and therefore Localize.LanguageTag.to_string/1 — now holds the canonical-syntax form of the requested locale (aliases resolved and subtags ordered, but neither maximized nor minimized), so "en" stays "en" and "en-US" stays "en-US" instead of collapsing to the minimal identity form. Locale identity and data lookup are unaffected; they key off cldr_locale_id.

  • Localize.Language.display_name/2 now follows the TR35 display-name algorithm, which canonicalizes rather than adds likely subtags: a bare language keeps its own name ("en" → "English", "es" → "Spanish"), while an explicitly supplied region or script still resolves to the region-specific CLDR name ("en-GB" → "British English", "pt-BR" → "Brazilian Portuguese"). A string and an equivalent Localize.LanguageTag return the same result.

  • Localize.Language.display_name/2 and Localize.Locale.LocaleDisplay.display_name/2 both now validate their input through Localize.validate_locale/1 and drive the display off canonical_locale_id, so a string and an equivalent (maximized) Localize.LanguageTag render identically and no likely subtags leak into the output (a validated "en" tag is "English", not "English (United States)").

Fixed

[0.41.2] — July 1st, 2026

Fixed

[0.41.1] — June 30th, 2026

Fixed

[0.41.0] — June 3rd, 2026

Changed

  • Breaking: Localize.Number.Format.Compiler now rejects scientific patterns that contain a grouping separator (e.g. "#,##0.###E0") at compile time. TR35 forbids grouping in scientific patterns; the comma was previously silently ignored at output time. Fix: drop the comma, or use a non-scientific pattern.

Added

  • TR35 engineering notation. Patterns like "##0.#####E0" now correctly shift the mantissa so the exponent is a multiple of the pattern's max integer-digit count: 12345 → "12.345E3", 123456 → "123.456E3". Fixed-width mantissa patterns ("00.###E0") similarly shift to expose min_integer_digits integer digits. The shift is exact for Decimal (via the %Decimal{exp: …} field) and lossless for Decimal-promoted floats/integers.

  • New :engineering format atom resolving to "##0.######E0". CLDR ships no engineering pattern, so this is a Localize-supplied default — pass an explicit pattern string for different mantissa precision.

  • Scientific-pattern emission now reads options.symbols.exponential, options.symbols.minus_sign, and options.symbols.plus_sign from the locale's CLDR data instead of hardcoding ASCII "E", "+", "-". ar/arab renders "أس" with Arabic Letter Mark signs, fa/arabext renders the "×۱۰^" superscripting form, ar/latn wraps signs with U+200E LRM.

  • Localize.Number.Format.Compiler now normalises @-significant scientific patterns per TR35: "@@###E0" ≡ "0.0###E0", "@@@##E0" ≡ "0.00##E0", etc. The compiler rewrites these to the canonical 0-prefixed form so the runtime formatter does not need a separate @-aware path.

  • New :exponent_style option on Localize.Number.to_string/2. :e (the default) emits the standard 1.234E3 form; :superscript emits 1.234 × 10³ using CLDR's superscriptingExponent symbol and Unicode superscript digits (⁰¹²³⁴⁵⁶⁷⁸⁹⁻⁺). Ignored for non-scientific patterns.

Fixed

  • Localize.Utils.Math.round_significant/2 no longer crashes when called with a Decimal and n <= 0 or with a Decimal zero of any sign. Previously these reached :math.log10/1 deep in the Decimal square-root path and raised :invalid_operation; both cases now return the input unchanged.

  • Localize.Number.Format.Compiler.compile/1 propagates compile-time errors from format_to_metadata/1 instead of crashing with MatchError.

[0.40.0] — June 1st, 2026

Changed

  • Breaking: A bare relative :locale_cache_dir (no :otp_app set) is now refused at app start with Localize.LocaleCacheDirError — it resolved against the BEAM's CWD, which differs between mix tasks, mix test, and a release. Fix: add :otp_app to anchor the relative path, or use an absolute path.

Fixed

Added

  • New :otp_app config key. Three supported forms for the locale cache directory: (1) :otp_app only → Application.app_dir(<otp_app>, "priv/localize/locales"); (2) :otp_app + relative :locale_cache_dir → Application.app_dir(<otp_app>, <relative>); (3) absolute :locale_cache_dir → used verbatim, :otp_app ignored.

[0.39.0] — May 31st, 2026

Fixed

  • Scope download_locales skip check to the configured cache dir, not the bundled fallback. Thanks to @allenwyma for the issue. Closes #35.

[0.38.0] — May 23rd, 2026

Added

  • Localize.Locale.expand_locale_list/2 (and therefore the :supported_locales configuration) now accepts a Gettext backend module like MyApp.Gettext as an entry; it expands to Gettext.known_locales/1 with each returned string re-resolved through the existing POSIX-normalisation and likely-subtag canonicalisation path. The check uses Code.ensure_compiled/1 so the entry is safe to place in either config.exs or runtime.exs.

[0.37.0] — May 17th, 2026

Fixed

  • Localize.DateTime.Formatter audit: removed remaining :gregorian / Calendar.ISO hardcoding. The {0} / {1} date-time placeholders now derive the CLDR calendar from the date instead of always asking for the gregorian pattern, ISO week-of-year (w / Y) prefers the date's own iso_week_of_year/3 callback before falling back to converting to Calendar.ISO, and iso_day/1's dead silent-fallback clause for unknown calendars was removed.

  • Localize.Date.to_string/2 now honours CLDR number_system overrides carried alongside date patterns — Hebrew dates render in Hebrew numerals (י״ב באייר ה׳תשפ״ד), Japanese imperial year-1 renders as 元年, and numeric overrides like :arab transliterate per-field via the system's digit set. Algorithmic systems resolve through CLDR's numberingSystems.json to the right RBNF rule (:hebr → :hebrew, :jpanyear → :spellout-numbering-year-latn under :ja).

  • Localize.Date.to_string/2 with a skeleton format (e.g. :yMMMM) against a non-Gregorian calendar no longer infinite-loops. Match.best_match/3 now receives the calendar explicitly and resolve_skeleton carries a seen set, terminating any residual match cycle.

  • Localize.Date.to_string/2 now honours non-Gregorian calendars carried in the date's :calendar field — previously every call hard-coded :gregorian. A Date built with Calendrical.Japanese now formats as 平成12年1月1日 under :"ja-JP" instead of 2000/01/01.

  • Localize.Calendar.localize/3 for :era now derives the correct era by delegating to the date's year_of_era/3 callback. Previously it always returned era 1, producing 白雉 (Hakuchi, AD 650) for every Calendrical.Japanese date.

  • Localize.DateTime.Formatter y token now uses the calendar's calendar_year/3 (or year_of_era/3 as fallback) for non-Calendar.ISO dates, so era-relative year numbering — Heisei 12, Reiwa 6, AH 1420, BE 2543 — renders correctly.

  • Localize.DateTime.Format.resolve_variant/2 now handles the CLDR %{format: pattern, number_system: ns} variant shape. Without this, locales whose date patterns carry a number_system override (Japanese jpanyear, Hebrew hebr) silently fell back to Gregorian.

[0.36.0] — May 15th, 2026

Fixed

[0.35.0] — May 14th, 2026

Fixed

  • Localize.Unit.localize/2 and a list-of-units overload of Localize.Unit.to_string/2 close the parity gap with Cldr.Unit.localize/3: passing :usage to to_string/2 now resolves the locale-preferred unit set (territory derived from :locale via Localize.Territory.territory_from_locale/1), decomposes the value across it, and joins the parts with the locale's standard list pattern in a single call (e.g. Localize.Unit.to_string(Localize.Unit.new!(1.83, "meter"), usage: :person_height, locale: "en-US") returns {:ok, "6 feet and 0.047 inches"}).

  • Localize.Unit.Conversion.convert/3 and Localize.Unit.decompose/2 now preserve Decimal precision end-to-end: Decimal inputs flow through Decimal arithmetic instead of round-tripping through float.

  • Localize.Unit gains a :format_options struct field that localize/2 populates from the skeleton attribute on CLDR's unitPreferenceData (e.g. [round_nearest: 50] for usage: :road distances of 300 m+ in region 001). to_string/2 merges these per-unit options with caller-supplied options and forwards them to the number formatter, so a 311 m road distance now renders as "300 meters" rather than "311 meters". Caller-supplied options win on conflict; usages whose CLDR preferences carry no skeleton (e.g. person-height) are unchanged.

  • Localize.Unit.Math.round/3 now accepts a rounding mode (:half_up default, plus :half_even, :half_down, :up, :down, :ceiling, :floor); float values are routed through Decimal so every mode produces consistent results. New Localize.Unit.Math.trunc/1 truncates toward zero with the same Decimal/float/integer dispatch as floor/1 and ceil/1.

  • Localize.Unit.measurement_system_for_territory/2 accepts a category second argument: :default (existing behaviour), :temperature (e.g. :US → :us for Fahrenheit), or :paper_size (:US → :us_letter, :FR → :a4). The category-specific maps in CLDR's measurementData.json only enumerate territories whose category system differs from their default measurement system, so the lookup falls through to the default for any territory not listed in the category map.

  • Localize.Unit.decompose/3 accepts an optional third format_options argument and stamps it on the trailing (smallest) decomposed unit; intermediate units whose integer part rounds to zero are now skipped, matching cldr_units' behaviour. This is what Localize.Unit.localize/2 uses internally to thread CLDR preference skeletons through to to_string/2.

  • Localize.Unit.to_string/2 documents :grammatical_gender and :grammatical_case as accepted options, matching the naming used by Cldr.Unit.to_string/3. :grammatical_case selects a case-keyed pattern variant (existing functionality, now documented). :grammatical_gender is accepted for cldr_units API parity but only meaningful for compound-unit patterns; for simple units the gender is fixed by CLDR data and the option has no effect on output.

  • Localize.Unit.Preference.preferred_units/2 documents :scope and :alt as forward-compatible placeholder options accepted but unused, matching cldr_units API surface. CLDR 48 ships no <unitPreference> carrying these attributes.

[0.34.0] — May 14th, 2026

Fixed

  • Localize.Utils.Http now resolves the HTTPS trust store via :public_key.cacerts_get/0 before falling back to the existing cacertfile chain (configured path → CAStore → :certifi → well-known Unix paths). mix localize.download_locales previously failed on Windows because the resolver only searched Unix file paths even though the OS-native trust store was reachable. Thanks to @LostKobrakai for the report. Closes #30.

  • Localize.Number.System no longer bakes the build host's absolute ETF path into its compiled BEAM which caused exceptions when the build host and deployment host were different. The lookup now happens at runtime via Application.app_dir/2 from a function body. Thanks to @neilberkman for the PR. Closes #28.

Added

  • Localize.Supervisor is now a publicly documented module that owns the library's runtime supervision tree and runs the one-time post-start work (supplemental-atom interning, :supported_locales resolution). Consumers can keep the default OTP auto-start, or mark the dependency runtime: false and mount Localize.Supervisor directly under their own application supervisor. See the new guides/supervision.md for the full pattern.

[0.33.0] — May 13th, 2026

Changed

  • Breaking: Localize.Message.Sigil is renamed to Localize.Message.Sigils to make room for additional MF2 sigils. Update any import Localize.Message.Sigil to import Localize.Message.Sigils.

Added

  • Localize.Message.Sigils adds a new ~t sigil for compile-time MF2 translation. Elixir #{expr} interpolations are rewritten as MF2 {$name} placeholders with bindings derived automatically (fruit.name → fruit_name, String.upcase(x) → string_upcase, etc.), the canonical msgid is registered with Gettext for translation lookup, and modules opt in via use Localize.Message.Sigils, backend: MyApp.Gettext. Requires the backend to use Localize.Gettext.Interpolation.

  • Localize.Gettext.Interpolation.skip_interpolation_sentinel/0 — public sentinel used by Localize.HTML.t/1 (and other markup-aware renderers) to retrieve a translated MF2 source without running the markup-stripping interpolation. Both runtime_interpolate/2 and compile_interpolate/3 short-circuit when this sentinel is passed as bindings.

Fixed

  • Localize.Application.start/2 now eagerly reads every bundled supplemental dataset (languages, scripts, territories, variants, subdivisions, units, currency codes, calendars, timezones, territory subdivisions, locale ids, number systems) so the atoms they reference are interned at app start. The 0.30.0 atom-DOS hardening switched many lookups to binary_to_existing_atom, which assumes the legitimate atoms already exist. Without the eager-load, valid input like numberingSystem=arab could surface as a bogus "unknown numbering system" error on a fresh BEAM where no prior code path had triggered the relevant binary_to_term/1 read. Manifested as a transient CI failure in Localize.Message.NumberOptionsTest; cause was identical in shape to issue #26.

  • Localize.default_locale/0 no longer hangs when LANG/LC_* hold values that fail validation (e.g. POSIX on minimal CI runners): the resolver pre-seeds the :persistent_term cache with :en before walking the env-var/app-config chain so a recursive locale lookup during warning formatting short-circuits, and the two warning sites now use a non-localised message to avoid the Exception.message/1 → Gettext-backend → get_locale/0 recursion that produced the original 60-second hang in localize_translate CI.

[0.32.0] — May 12th, 2026

Fixed

  • mix localize.download_locales no longer evaluates config/runtime.exs of the consumer application. Previously the task ran Mix.Task.run("app.config"), which transitively evaluated runtime.exs, which was not necessary. The task now loads only compile-time config (config/config.exs and any imported env-specific file), matching the build-time contract its docstring already advertised. Thanks to @whatyouhide for the PR.

  • Hardened two further sites that pattern-matched {:ok, _} = <fallible Localize call> and could have surfaced the same MatchError class as issue #26: the per-unit format loop in Localize.Duration.to_string/2 now short-circuits on the first formatter error, and Localize.Number.Formatter.Decimal's digit-transliteration step now uses with to fall through to untransliterated digits if either the requested or :latn number-system data is unavailable.

  • New Localize.LintTest source-level lint that scans lib/ and fails the test suite if any file pattern-matches {:ok, _} = against a known-fallible Localize call. The list of fallible calls and an empty allowlist live in the test; future occurrences fail loudly on the offending PR rather than waiting for a runtime regression report.

  • New Localize.Locale.FallbackResilienceTest exercises the load → store → get pipeline for seven representative regional locales under a provider that only serves :en, asserting that the data ends up under the canonicalised requested key and that provider.get/3 succeeds.

  • New Mix.Tasks.Localize.DownloadLocalesTest calls DownloadLocales.banner/2 directly with default_locale: :"en-ZA" set, reproducing the exact scenario from issue #26 without invoking the network. banner/2 is now @doc false so the test can reach it.

  • New Localize.AtomInterningTest exercises the public lookup paths that previously surfaced the supplemental-atom bug — Localize.Number.System.system_name_from/2 and friends, plus Localize.Currency.validate_currency/1, Localize.validate_calendar/1, Localize.validate_territory/1, Localize.validate_script/1 — each driven with binary input. A regression in intern_supplemental_atoms/0 would surface here as a structured error from one of these accessors.

[0.31.0] — May 12th, 2026

Fixed

  • Localize.Locale.Loader now stores fallback locale data under the requested locale id rather than the resolved fallback id, restoring 0.29 behaviour. The regression introduced in 0.30.0 caused provider.get(requested_locale, _) to miss in-memory fallback data and surface as a spurious ItemNotFoundError — most visibly crashing mix localize.download_locales with a MatchError when default_locale named an unavailable locale. Locked down with test/localize/locale/loader_fallback_test.exs. Closes #26.

  • mix localize.download_locales no longer pattern-matches on the result of Localize.Message.format/2 when building its progress banner; if formatting fails for any reason the task falls back to a plain ASCII banner and continues with the actual download.

Changed

[0.30.1] — May 12th, 2026

Fixed

  • Revert the [:safe] option on :binary_to_term/2 since we cannot guarantee all the required atoms are materialized at application start. Thanks to @bigardone for the report. Closes #25.

[0.30.0] — May 12th, 2026

Security

  • Localize.LanguageTag.parse/1 no longer calls String.to_atom/1 on raw parser output, closing an atom-table-exhaustion DOS vector on untrusted locale inputs. Atomisation is now gated behind the CLDR validity sets after alias resolution, and unrecognised language/script/territory subtags return Localize.InvalidSubtagError.

  • Localize.Locale.to_locale_id/1 renamed to Localize.Locale.cldr_locale_id_from/1 and now returns {:ok, atom()} | {:error, Exception.t()}, gating atom creation behind Localize.validate_locale/1 and closing a second atom-table-exhaustion vector on locale inputs.

  • Localize.Currency.validate_currency/1, territory_currencies/1, current_currency_for_territory/1, and the binary-code branch of currencies_for_locale/3's filter no longer atomise input before checking validity. Unknown currency or territory binaries are rejected via Helpers.existing_atom/1 and never grow the atom table.

  • Localize.Script.display_name/2 and Localize.Unit.Formatter no longer atomise binary input before checking validity. Unknown script codes return Localize.UnknownScriptError without growing the atom table; the unit formatter's currency atomisation is gated as defence-in-depth behind the upstream Localize.Unit.validate_currency_codes/1 check.

  • MF2 :list function and unit parser no longer atomise user-controlled binaries. The :list function's binary style= fallthrough now sets a sentinel atom that surfaces as an InvalidValueError in Localize.List, and SI prefix names are resolved through a compile-time lookup map (Localize.Unit.Data.si_prefix_atom/1) rather than String.to_atom/1 at parse time.

  • Localize.Number.System (system_name_from/2, number_system_digits/1, to_system/2), Localize.Number.Symbol.number_symbols_for/2, and the datetime-formatter's time_preferences_for/1 no longer atomise user-supplied binary number-system or locale names before validation. Lookups go through Helpers.existing_atom/1 against pre-atomised CLDR data sets.

  • Closed additional Atom DOS vectors in Localize.Locale.LocaleDisplay.display_name/2 (now routes through cldr_locale_id_from/1), Localize.Territory.Subdivision.display_name/2, the -u-co- and -u-kr- extension parsers in Localize.Collation.Options, and the redundant String.to_atom(to_string(...)) round-trip in plural-rule fallback.

  • Closed three further atom-DOS sites called out by the security audit's findings 1.4 and 1.5: LocaleDisplay.U.find_exemplar_city/2 (-u-tz- IANA region/city splitter), LocaleDisplay.T.to_atom_safe/1 (-t- extension subtag normalisation), and Gettext.Interpolation.safe_to_atom/1 (missing-binding name reporting). All three previously fell through to String.to_atom/1 on a miss, which defeated the helper's name; they now return the original binary unchanged when no atom exists.

  • Locale cache files and downloaded ETFs are decoded with :erlang.binary_to_term(_, [:safe]). Closes a node-crash vector for any deployment with :locale_cache_dir set to a writable directory: a malicious or corrupted cache file can no longer resurrect arbitrary atoms, funs, or refs. Failed safe decodes surface as LocaleNotFoundInCacheError (or LocaleDownloadError for the download path) and the file is treated as stale.

  • Public parser entry points now reject oversized input before invoking the grammar, capping the parser's CPU exposure on hostile input. Defaults are 256 bytes for Localize.LanguageTag.parse/1 and Localize.Unit.Parser.parse/1, 64 KB for Localize.Message.Parser.parse/1, and 1 KB for Localize.Number.Parser.parse/2. Each cap is configurable via app env (:max_locale_id_bytes, :max_message_bytes, :max_unit_bytes, :max_number_bytes). Number.Parser.parse/2 additionally rejects Decimal results whose exponent magnitude exceeds :max_decimal_exponent (default ±100) so downstream multiplication or formatting cannot materialise huge mantissas.

  • Localize.FormatCache ETS table switched from :public to :protected; writes are routed through the cache GenServer. The size cap (:format_cache_max_entries, default 2 000) is now enforced synchronously on each insert rather than by a 10-second sweeper, replacing the previous biased-random eviction that could leave the cache oversized. New Localize.FormatCache.clear/0 and size/0 helpers added for tests and maintenance.

  • NIF (ICU bindings) hardened. All NIF entries except nif_plural_rule now run on the dirty CPU scheduler pool (ERL_NIF_DIRTY_JOB_CPU_BOUND); the collator pool is sized for schedulers + dirty_cpu_schedulers and reserve_coll refuses overflow rather than reading past the array end. The reorder-codes branch caps numCodes at 256 and checks enif_alloc before use; every std::stoll/std::stod/std::stoi is wrapped in try/catch so out-of-range C++ exceptions cannot unwind through the NIF boundary; the hand-rolled JSON arg parser guards each access after skip_ws; per-call input lengths are capped at the NIF boundary (MAX_MF2_BYTES = 64 KB, MAX_COLLATION_BYTES = 1 MB, MAX_NUMBER_STR_BYTES = 1 KB).

  • Localize.Unit.CustomRegistry.load_file/1 now refuses to evaluate the file in :prod (or any environment without a loaded Mix module — typical for releases) unless config :localize, :allow_runtime_unit_files, true is explicitly set. Outside :prod the function works as before. The flag exists so an unintended feature switch in production cannot accidentally surface arbitrary code execution via Code.eval_file/1.

  • :localize_locale_cache ETS table switched from :public to :protected, owned by Localize.Locale.Loader. Writes are routed through the owner via cast (so the hot validate path doesn't block and writes triggered from inside the owner's own handle_call cannot deadlock). Reads remain direct ETS lookups. Combined with the format cache fix above, both ETS caches are now :protected against multi-tenant or other-library interference.

  • Localize.Utils.Http.get/2 and get_with_headers/2 now reject responses larger than 50 MB by default (configurable via :max_http_body_bytes app env or per-call :max_body_bytes option). Without the cap a malicious or compromised CDN could feed a multi-gigabyte response and OOM the BEAM. Oversized responses log an error and return {:error, :response_too_large}. Additionally, when peer certificate verification has been disabled (via LOCALIZE_UNSAFE_HTTPS), a one-time Logger.warning is emitted so a misconfigured production deployment cannot silently downgrade TLS without leaving an audit trail.

[0.29.0] — May 11th, 2026

Changed

Added

[0.28.0] — May 9th, 2026

Changed

  • Localize.Interval.to_string/3 Date intervals (style :date, the default) now resolve their skeleton per-locale instead of using a hard-coded locale-independent mapping. The interval looks up Localize.DateTime.Format.date_formats(locale_id)[format] — the same mapping Localize.Date.to_string/2 uses — so date intervals follow the same conventions as single dates for the same :format. Visible effect on locales whose single-Date skeletons differ from the previous Interval table — most notably:
    • ja :medium is now numeric ("2012/01/05~2012/01/06"), aligning with single Date :medium ("2012/01/05"); was previously the abbreviated month form "2012年1月5日~6日". The richer Japanese-character form is now :long (matching single Date :long).
    • de :medium is now numeric ("15.01.2022 – 20.03.2022"); was previously the abbreviated month form. To get "15. Jan. – 20. März 2022" request the :yMMMd skeleton explicitly.
    • en :long uses full month name with no weekday ("January 5, 2012 – January 6, 2012"); was previously the :yMMMEd form "Thu, Jan 5 – Fri, Jan 6, 2012". The weekday now appears at :full. Per-locale skeletons that aren't shipped in CLDR's interval_formats data fall back to formatting each endpoint with Localize.Date.to_string/2 and joining via the locale's interval_format_fallback template. Non-:date styles (:month, :month_and_day, :year_and_month) remain locale-independent — they describe a deliberate field selection unrelated to standard date styles. The static Localize.Interval.date_styles/0 no longer includes a :date entry.

Fixed

  • Strip zone token for NaiveDateTime too in Localize.Time.to_string/2 since they also do not have a time zone field. Relates to #22.

  • Fix Localize.Interval.to_string/3 silently ignoring the :date_format option on Date-only intervals. The option now overrides :format on the date axis, mirroring the precedence used for :time_format on time intervals. Relates to #22.

  • Fix Localize.Interval.to_string/3 raising Localize.DateTimeIntervalFormatError when called with format: :full on a Date interval. Relates to #22.

  • Localize.Utils.Math.sqrt/2 now respects the current Decimal.Context.get/0 precision when called with a Decimal — the result is rounded to the configured precision and Newton's-method convergence scales accordingly.

  • Added the test suite for Localize.Utils that are derived from the Cldr.Util equivalents.

[0.27.0] — May 8th, 2026

Fixed

  • Fix Localize.Interval.to_string/3 :short time-interval format using the wrong hour cycle on 24-hour locales. :short now picks :hm or :Hm per the locale's preferred hour cycle (honouring any -u-hc- Unicode-extension override), matching the cycle used by Localize.Time.to_string/2 :short. Thanks to @woylie for the follow-up report. Fixes #22.

  • Fix Localize.Time.to_string/2 silently ignoring the -u-hc- Unicode-extension override on the locale. The override now applies to both standard formats (:short/:medium/:long/:full) — remapped to the locale's cycle-appropriate :hm/:hms/:hmsv (12-hour, with the locale's AM/PM marker) or :Hm/:Hms/:Hmsv (24-hour) skeleton — and to user-supplied skeleton atoms like :Hms, where the hour symbols are substituted.

  • Fix zone-field artefacts on %Time{} standard formats. A Time carries no zone information, so Localize.Time.to_string/2 now strips zone characters (z, Z, O, v, V, x, X) from the resolved skeleton ID before formatting. Localize.Time.to_string!(~T[21:00:00], format: :long, locale: :ja) returns "21:00:00" (was "21:00:00 " — trailing space), and :es :full returns "21:00:00" instead of "21:00:00 ()" (parens around the empty zone field).

  • Adds support for Decimal version 3.0 to address a CVE. Thanks to @mitchellhenke for the PR.

Added

[0.26.0] — May 6th, 2026

This release fixes user-reported bug #22 in time interval formatting and a number of RBNF conformance bugs arising from a more complete conformance testing process. The RBNF bugs have been around unreported and undiagnosed for many years.

Changed

  • Breaking: The default Localize.Interval.to_string/3 format output (which is :medium) for time-only inputs now includes seconds. Localize.Interval.to_string!(~T[12:00:00], ~T[14:00:00], locale: :ja) shifts from 午後0時00分~2時00分 to 12:00:00~14:00:00. :en's :medium shifts from 12:00 – 2:00 PM to 12:00:00 PM – 2:00:00 PM. Users who relied on no-seconds output should explicitly pass time_format: :short.

Fixed

  • Fix Localize.Interval.to_string/3 collapsing the :short, :medium, :long, and :full time styles to the same :hm skeleton on Time inputs. :short keeps CLDR's interval-format dispatch (collapsed AM/PM); :medium and above route through the locale's per-style time-format pattern, restoring per-style differentiation and including seconds in the default :medium output. Thanks to @woylie for the report. Fixes #22.

  • Fix :time_format being silently ignored on Localize.Interval.to_string/3 for Time inputs. The option now takes precedence over :format, matching the precedence used on datetime intervals. Thanks to @woylie for the report. Fixes #22.

  • Fix Localize.Interval.to_string/3 crashing with FunctionClauseError when called with a binary :format (or :time_format) on Time inputs. Binary patterns are now applied to both endpoints and joined via the locale's interval-format fallback template, matching the behaviour of datetime intervals. Thanks to @woylie for the report. Fixes #22.

  • RBNF parser now distinguishes >> from >>> so CJK locales emit fractional digits without an inter-digit separator — Localize.Number.Rbnf.to_string(3.14, "spellout-numbering", locale: :zh) is 三点一四 instead of 三点一 四.

  • RBNF leading and embedded zeros in fractional digits, and very small magnitudes, are now preserved — 0.05 en is "zero point zero five", 3.04 zh is 三点〇四, and 0.000001 en is "zero point zero zero zero zero zero one".

  • RBNF 0.x special-base rule now matches when the integer part is zero and the value is non-zero, routing ko 0.5 spellout-numbering through its locale-correct sino-Korean 영점오 instead of the previous x.x-fallback 공점공오.

  • RBNF negative floats no longer double their output or silently drop the sign — ko -0.5 spellout-numbering is -영점오 instead of 공점공점오, and locales that lack a -x rule now get an ASCII - prefix.

  • RBNF integer <#,##0< quotient and float >%name> / >#,##0> / <%name< modulo and quotient no longer crash, completing case-clause coverage for every TR35 substitution-argument shape.

  • RBNF $(cardinal,…) and $(ordinal,…) plural-keyed substitutions now use the requested locale's plural rules instead of hard-coding English — fr Localize.Number.Rbnf.to_string(21, "digits-ordinal-masculine", locale: :fr) is "21e" instead of "21er".

  • RBNF fraction-with-rule numerator/denominator algorithm is now spec-correct — ky 1.5 spellout-cardinal is бир бүтүн ондон беш instead of бир бүтүн беш.

Added

  • Add :minimum_significant_digits and :maximum_significant_digits options to Localize.Number.to_string/2.

  • Localize.Number.Rbnf.to_string/3 now accepts Decimal inputs in addition to native integers and floats, with whole-valued Decimals routed through the integer path with no precision loss.

  • RBNF >>> integer modulo now applies the source-preceding rule per TR35 §RBNF_Syntax, closing a latent gap; no current CLDR locale exercises this path.

[0.25.0] — May 1st, 2026

Fixed

  • Fix en-CA :short date crash; teach :prefer about CLDR variant/standard alts. Thanks to @dabaer for the report. Closes #21.

Changed

  • Module refactoring to remove many compile-time cycles.

[0.24.0] — April 29th, 2026

Fixed

  • Localize.DateTime.Formatter stand-alone pattern helpers now pass context: :stand_alone to Localize.Calendar.localize/3 instead of an invalid type: option. Thanks to @timpritlove for the PR. Closes #20.

  • Rename calendar :format/:stand_alone typespec and docs from :type to :context.

  • Clarify Unit.display_name/2 vs to_string/2 in docs.

[0.23.0] — April 25th, 2026

Fixed

  • Fix Cldr.Number.to_string/2 for Decimal numbers to produce the correct decimal digits.

  • Fix LOCALIZE_UNSAFE_HTTPS env-var contract — values like "FALSE", "nil", an empty string, or unset all keep TLS verification on; only a truthy value disables it. Thanks to @rubas for the PR. Closes #15.

  • Fix Localize.Locale.load/2 and Localize.Locale.get/3 to honor the :provider option — load/2 no longer calls provider.store/2 with the locale id, and get/3 now loads through the same provider it reads from. Thanks to @rubas for the PR. Closes #16.

  • Localize.Locale.get/3 now honors the :fallback option by walking the CLDR parent locale chain when a key is missing in the requested locale. Fallback is handled in Localize.Locale so provider modules stay focused on store-and-fetch semantics. Thanks to @rubas for the PR. Closes #17.

  • Public formatters (Localize.Date, Localize.Time, Localize.DateTime, Localize.DateTime.Relative, Localize.Interval, Localize.List, Localize.Calendar) now accept raw parsed Localize.LanguageTag structs whose :cldr_locale_id is not yet populated. The seven per-module locale resolvers collapse to one shared Localize.Locale.cldr_locale_id_from/1. Thanks to @rubas for the PR. Closes #18.

  • Fix Localize.available_locale_id?/1, Localize.validate_calendar/1, and Localize.validate_number_system/1 to never intern caller-supplied strings as new atoms. Lookups now use compile-time string→atom maps for O(1) safe membership. Thanks to @rubas for the PR. Closes #19.

  • Localize.supported_locales/0 now lazily resolves config :localize, supported_locales: [...] from the application environment when the :persistent_term cache has not yet been populated, instead of falling back to the full CLDR locale list. The cache is populated on application startup, but callers that run before the application has started — notably compile-time macro expansion in dependent applications like localize_web's ~q sigil — previously saw the full CLDR list during partial recompiles. This caused Localize.validate_locale/1 to best-match against all CLDR locales rather than the configured subset, producing incorrect cldr_locale_id resolutions.

[0.22.0] — April 22nd, 2026

Fixed

  • Fix Cldr.Number.to_string/2 for Decimal numbers to produce the correct decimal digits.

[0.21.0] — April 22nd, 2026

Fixed

  • Fix normalizing CLDR locale names to our standard atom format in Localize.validate_calendar/1.

  • Localize.Calendar.iso_day_of_week/1 no longer crashes with MatchError on non-ISO calendars. The generic branch destructured calendar.day_of_week/4 as a 2-tuple but the Calendar behaviour returns {day, first, last}.

  • Localize.Time.to_string/2 with a partial time and a standard format atom (:short/:medium/:long/:full) now derives a CLDR skeleton from the fields actually present (:h, :hm, :ms) instead of returning DateTimeUnresolvedFormatError.

  • Localize.DateTime.to_string/2 no longer silently drops the hour when given a partial datetime such as %{year: _, month: _, day: _, hour: _} without :minute. Partial datetimes render via a split date + time path composed with the locale's datetime wrapper.

  • Localize.DateTime.to_string/2 with hour + minute (no second) under :medium no longer emits a stray trailing : before the AM/PM marker — the partial path derives the :hm skeleton instead of using the full h:mm:ss a pattern with an empty seconds slot.

  • The datetime formatter's AM/PM handler now accepts any map with :hour, not just maps that also have :minute.

  • Localize.Number.to_string/2 now produces identical output for equivalent Decimal and float values. Previously Decimal.new("1234.56") rendered as "1,234.560" under the standard pattern because Decimal.round/3 returns a result padded to the requested scale; the formatter now normalizes after rounding so only the digits actually needed are emitted. Currency and other formats with a mandatory minimum scale still pad correctly via adjust_trailing_zeros/2.

[0.20.0] — April 22nd, 2026

Fixed

  • Fixes mapping CLDR calendar types to the implementation module name.

[0.19.0] — April 19th, 2026

Fixed

  • Restored the support of RBNF locales in Localize.Number.to_string/2. They are implemented in Localize.Number.Rbnf.to_string/1 but the delegation was lost on the ex_cldr transition. Thanks to @tangulip for the report. Closes #11.

[0.18.0] — April 18th, 2026

Changed

  • Breaking: MF2 highlighter token class atoms renamed to match the tree-sitter capture taxonomy used by mf2_wasm_editor, so one stylesheet now styles both server-rendered HTML and the browser editor.

  • Breaking: Localize.Message.to_html/2 now emits the new canonical class names with _ converted to - on output (.mf2-variable, .mf2-punctuation-bracket, .mf2-string-escape, .mf2-constant-builtin, etc.).

  • Breaking: Localize.Message.to_ansi/2 default palette keys renamed to the new atoms.

Removed

  • The mf2_theme_css/ directory and scripts/generate_mf2_themes.exs generator. Themes now live canonically in mf2_wasm_editor.

Fixed

  • Localize.Gettext.Interpolation.runtime_interpolate/2 no longer raises Localize.ParseError when a translated string is not valid MF2. It now returns the message unchanged and logs a warning, matching gettext's own "fall back to the msgid" behaviour for missing translations. Dev-facing UI copy that happens to contain MF2-like syntax (e.g. {{…}} or .match) no longer crashes callers.

[0.17.0] — April 17th, 2026

Added

[0.16.0] — April 17th, 2026

Fixed

  • Fix locale download infinite recursion loop. Thanks to @woylie for the report. Closes #10.

[0.15.0] — April 17th, 2026

Added

Fixed

  • Fix the exception and message when formatting a number and specifying a number system that is not valid for the given locale.

[0.14.0] — April 16th, 2026

Changed

  • Breaking: Remove @derive for Jason since Jason is no longer configured or used anywhere in the application.

Fixed

  • Fix locale downloader to ensure it only uses the :cldr_locale_id field to construct the download URL.

[0.13.0] — April 15th, 2026

Fixed

  • Localize.Interval.to_string/3 now correctly formats datetime intervals — matching ex_cldr_dates_times behaviour. Previously any interval between two datetime values was rendered as a date-only range, discarding the time portion. Now same-day intervals render as "Apr 8, 2026, 12:00 PM – 2:00 PM" (date once, time range) and different-day intervals render as "Apr 15, 2026, 12:49 AM – Apr 16, 2026, 1:49 AM" (full datetime on both sides). Time-only intervals (Time values on both sides) use the locale's time-interval patterns.

[0.12.0] — April 15th, 2026

Fixed

  • Chinese collation tailorings (zh-u-co-pinyin, zh-u-co-stroke, zh-u-co-zhuyin) now produce correct locale-specific ordering for Han characters. .

  • Han radical-stroke ordering (UAX #38) under -u-co-unihan now applies correctly. The Localize.Collation.Han module was previously orphaned — its data was never loaded in consumer apps and the sort path never consulted it. Radical data is now pre-generated in the build pipeline and shipped in priv/localize/collation_table.etf; the sort path invokes Han.collation_elements/1 for CJK codepoints when the :han_ordering option is :radical_stroke (set automatically for the -u-co-unihan collation type).

Added

  • Localize.Collation.Options.han_ordering option — :implicit (default, UCA codepoint-based) or :radical_stroke (UAX #38). Automatically set to :radical_stroke for -u-co-unihan locales.

  • Persistent-term cache for parsed tailorings. First call to Tailoring.get_tailoring/2 parses the rule string (~70 ms for zh-pinyin); subsequent calls read from persistent_term in microseconds.

  • Differential tests for zh-u-co-pinyin, zh-u-co-stroke, zh-u-co-zhuyin, and ja-u-co-unihan that assert output differs from root codepoint order for specific character pairs — guards against silent regressions.

Changed

  • Localize.Collation.Han is no longer a GenServer. Radical data is loaded alongside the main collation table by Localize.Collation.Table (one ETF, one load step).

[0.11.0] — April 14th, 2026

Added

[0.10.0] — April 14th, 2026

Changed

  • Mix tasks now only do Mix.Task.run("app.config") followed by Application.ensure_all_started(:localize), avoiding starting any consumer application. Thanks to @lostkobrakai for the report. Closes #7,

[0.9.0] — April 14th, 2026

Changed

[0.8.0] — April 14th, 2026

Added

[0.7.0] — April 14th, 2026

Changed

  • Territory subdivision functions are now in the Localize.Territory.Subdivision module. Some functions have been renamed, see Localize.Territory.Subdivision. The translate functions have been removed.

[0.6.0] — April 13th, 2026

Added

  • Public function wrappers in Localize.Unit.Math for all dimensionless functions: sin/1, cos/1, tan/1, asin/1, acos/1, atan/1, sinh/1, cosh/1, tanh/1, asinh/1, acosh/1, atanh/1, exp/1, ln/1, log/1, log2/1. Previously these were only accessible via apply_dimensionless/2.

Fixed

  • apply_dimensionless/2 now validates that the unit is actually dimensionless before computing. Previously sin(1 meter) would silently return a result; it now returns {:error, "sin requires a dimensionless value, got unit with base: meter"}. Units must reduce to revolution (angles) or part (ratios) to be accepted.

[0.5.0] — April 13th, 2026

Added

  • :special conversion support in CustomRegistry. Custom units can now be registered with factor: :special plus :forward and :inverse {module, function} tuples for nonlinear conversions. This enables logarithmic scales (decibels), temperature functions, density hydrometers, wire gauges, and other conversions that cannot be expressed as value * factor + offset.

  • Conversion.do_convert/3 now uses a generalised special_unit/1 lookup that checks both CustomRegistry and a compiled @built_in_special map, replacing the previous hardcoded :beaufort pattern match.

[0.4.0] — April 13th, 2026

Fixed

  • Localize.all_locale_ids/1 (:modern, :moderate, :basic) now returns the correct expanded list of locales. Thanks to @cw789 for the report.

[0.3.0] — April 13th, 2026

Fixed

  • Load custom units in a single batch to avoid churning :persistent_store

[0.2.0] — April 13th, 2026

Fixed

  • SI prefix parsing for custom units. Custom units can now be prefixed with SI prefixes and power prefixes.

Changed

  • Custom unit category validation relaxed from a fixed allowlist to any non-empty string. The faciliates importing a broader range of unit definitions such as those from Gnu units.

[0.1.0] — April 13th, 2026

Initial release.

Highlights

  • Full CLDR v48.2 locale data with lazy runtime loading from ETF files cached in :persistent_term. No compile-time backend configuration required.

  • Number formatting — integers, decimals, percentages, currencies, ranges, and rule-based number formats (RBNF) including Roman numerals and CJK ideographs.

  • Date, time, and datetime formatting using CLDR calendar patterns with :short, :medium, :long, and :full styles, custom skeleton patterns, and interval formatting.

  • Unit formatting with plural-aware patterns, SI/binary prefixes, compound units, measurement system conversion, custom unit registration, and Localize.Unit.Operators for natural arithmetic (km + m).

  • List formatting with locale-appropriate conjunctions, disjunctions, and unit list styles. Per-element formatting via Localize.Chars.

  • ICU MessageFormat 2 (MF2) parser and interpreter with custom function registry, offset selection, JSON interchange, and bidirectional text support.

  • Gettext integration — Localize.Gettext.Interpolation provides MF2-based interpolation for Gettext backends.

  • Localize.Chars protocol — polymorphic locale-aware formatting with built-in implementations for 14 types and Any fallback to Kernel.to_string/1.

  • Currency metadata, ISO 4217 validation, custom currency registration (private-use and extended codes), and territory-to-currency mapping.

  • Display names for territories, languages, scripts, calendars, and full locale display names per the CLDR algorithm.

  • Unicode Collation Algorithm (UCA) with CLDR locale-specific tailoring for 97 languages, including digraph expansion and script reordering.

  • BCP 47 / RFC 5646 language tag parser with full Unicode extension support (-u-, -t-), locale distance matching, and parent chain resolution.

  • On-disk locale cache with HTTPS download provider, version-based staleness detection, and mix localize.download_locales for build-time cache population.

  • Optional NIF backend for faster Unicode normalisation and collation sort-key generation.

  • Calendar data for all CLDR calendar systems including Buddhist, Hebrew, Islamic (5 variants), ROC, Indian, Persian, Coptic, Ethiopic, Chinese, Japanese, and Dangi.

  • All public API functions return a standardized {:error, exception} (except bang variants). The exception is a standard Elixir exception struct populated with semantic information about the error. The error message can be returned by Exception.message(exception). The exception messages are all Gettext messages using the MF2 format and can be localized.

See the README for full documentation, configuration options, and usage examples.