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
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.
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.
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.
Time zones are parsed and resolved by
Localize.DateTime.Timezone.parse_zone/2,resolve/3andparse_offset/2, andLocalize.TimeZoneDatabasehandles fixed offsets.Calendars other than
Calendar.ISOcome from Calendrical 2.0, which ships the same day, and a locale's-u-ca-converts a value into its calendar.Localize.Inflectionis added as a technical preview: inflected words, pronouns and quantities, MessageFormat 2'si:functions and inflected units.CLDR 49's technical previews are supported and marked as such: ordinal days, numeric date and time separators, the
c12andc24hour cycles, semantic skeletons and rational number formats.Number parsing reads currency amounts and a locale's alternative separators (
:currency,:separators).Locale data is verifiable and self-hostable:
mix localize.verify_locales,Localize.cldr_repo_ref/0and a self-hosting guide.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.ISOmust implement the Calendrical 2.0 behaviour, and any other is refused withLocalize.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-hebrewwrites a Gregorian date as a Hebrew one. The calendar comes from the provider registered withLocalize.Calendar.register_provider/1, which Calendrical 2.0 registers when it starts.MessageFormat 2's
calendaroption names the calendar on the message's locale, as-u-ca-does, rather than a calendar module.The parse functions'
:calendaroption 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, returnsLocalize.UnknownCalendarError.The parse functions no longer take
:return_calendar. UseDate.convert/2on the result.Localize.DependencyRequiredErroris 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".yin 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".uwrites 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.ISOkeeps the locale's weeks.Wnumbers 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" inen-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/0names as:parsing_calendar, then converted: "Feb 1, 2024" inCalendrical.ISOWeekis 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/2joins date and time with the locale's "at" pattern by default:en:longis "July 6, 2024 at 2:30:45 PM".style: :defaultrestores the previous output;:mediumand:shortare 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: :atrestores it.An interval at a standard format writes the fields and widths of that format's single-value pattern:
vi:shortis "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
availableFormatsonly, as CLDR's pattern generator does, not its standard formats:ko:yMdis "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
eandccount from the locale's first day of the week: Saturday is 7 inenand 6 inde, where both gave 6.ccis one digit.A pattern field at a width TR35 does not list, such as
MMMMMMorHHH, formats as U+FFFD.Localize.Calendar.localize/3returns aLocalize.DateTimeInvalidInputErrorfor 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/3drops its:zero_formatoption.EST5EDT,CST6CDT,MST7MDTandPST8PDTare 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/3gives 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/4andDateTime.shift/3on it need the newLocalize.TimeZoneDatabase.A named zone in parsed text resolves to a
DateTimethrough 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.Relativecounts the calendar periods between two dates with the calendar'sdiff/3andshift/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=secondrestores the seconds. A date or time option other thantimeZone,hour12andcalendarset from a variable is an error.
Locale data and display names
CLDR 49 drops 114 locales below Basic coverage, among them
aa,ab,anandht. Such a locale resolves to a fallback,:undforaa.An unsupported
:prefervalue is an error, where it resolved to:standard.:prefernow applies toLocalize.Language,Localize.TerritoryandLocalize.Scripttoo.Collation moves to Unicode 18 (UCA 18.0.0), so any stored
Localize.Collation.sort_key/2must be regenerated.compare/3is 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/2takes:currencyand reads an amount with that currency's separators:de-ATwrites€ 1.234,56but1 234,56plain, so"1.234,56"parses only with the option.Localize.Number.Parser.parse/2takes:separators, naming the set of decimal and grouping separators to read with for a locale that has more than one:en-ZAwrites1 234,56with its:standardset and1,234.56with its:usset. A locale without the named set reads with its standard one.Localize.Locale.data_locale_id/1returns 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/2returns 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, itswritten_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'sMarch25months 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 aLocalize.DateTimeParseLengthError. The longest text any locale writes is 666 bytes, and reading cost grows with the square of the length.Localize.Territory.Hemisphere.hemisphere/1returns the hemisphere of a territory or locale,:northern,:southernor:ambiguouswhere the equator crosses it:hemisphere(:AU)is{:ok, :southern}. A region is answered from the territories it contains.Localize.Calendar.register_provider/1registers the module that supplies calendar modules for a locale's-u-ca-, andcalendar_provider/0returns it. Calendrical 2.0 registers itself when it starts.Localize.Date.parse/2,Localize.DateTime.parse/2andLocalize.Interval.parse/2read a composite calendar's date in each calendar itsparsing_calendars/0names, so "Mo5 11, 1872" inCalendrical.Reform.Japanreads back.Localize.Date.parse/2takes: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/2takes it as the interval's format.Localize.DateTime.parse/2reads the date and the time with the two halves of:format: "3/4/2024 22:05" withformat: "d/M/y HH:mm".:date_formatand:time_formatset one half.Localize.Time.parse/2takes:formattoo, a standard format, a skeleton or a pattern, and reads the text with that format alone: "14h30" withformat: "HH'h'mm". A:longor a:fullformat 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/2andresolve_currencies/2resolve a symbol several currencies share by the nearest locale that uses one, so "$" is the Canadian dollar infr. It stays unknown where no locale is a clear fit, as ines.Localize.Interval.to_string/3andto_parts/3take a skeleton or a pattern as a date interval's:formator:date_format, as CLDR keys interval formats by skeleton, so a format no standard format reaches can be named:format: :yMMMEdis "Mon, Jun 15 – Thu, Jun 18, 2026" inen.Localize.DateTime.Timezone.parse_zone/2reads 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, andresolve/3resolves it at a date and time through the configured time zone database.Localize.Inflection, a technical preview, inflects words from CLDR's inflection data, withpronoun/2,3andquantify/4:inflect("Haus", :de, %{case: "dative", number: "plural"})is "Häusern". Data for 48 languages comes frommix localize.download_inflection.MessageFormat 2 gains the Unicode inflection project's
i:functions,:i:inflect,:i:pronoun,:i:quantify,:i:listand:i:numeral, with SSML output (output: :ssml).Localize.Message.Namespacelets an application register a namespace of its own.Localize.Unit.to_string/2takes:inflect, synthesizing a pattern when the requested:grammatical_casehas no CLDR one —:safeuses attested paths only,:alwaysalso guesses from suffixes.Localize.Unit.grammatical_gender/2returns a unit's gender.Localize.Locale.LocaleDisplay.key_name/2andtype_name/3return 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'sscope="core"name, withprefer: :menu. A value may be given in its BCP 47 form,:gregoryor:level1.Localize.Locale.LocaleDisplay.display_name/2takeskey_value_display: :separate, TR35'sSeparateKeyValue, soen-u-ca-gregoryis "English (Calendar: Gregorian)".Localize.Calendar.display_name(:calendar, calendar)takesprefer: :menufor the short name a menu of calendars uses, "Gregorian", and accepts BCP 47 names such as:gregory.Localize.affirmative_responses/1andnegative_responses/1return CLDR's POSIXyesstr/nostrforms —{:ok, ["ja", "j"]}for:de— andaffirmative?/2andnegative?/2match a response against them, folding case as TR35 requires.Localize.Locale.LocaleDisplay.type_value_name/2returns the localized name for a boolean BCP 47 keyword value:"On"and"Off"inen,"Ein"and"Aus"inde.CLDR 49's
placeholderBoundarySpacingseparates 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: :utconLocalize.DateTime.Timezone.gmt_format/3writes CLDR 49'salt="utc"forms, "UTC+01:00" and "UTC+?" inen, andnon_location_format/3passes it to its GMT fallback. Both spellings read back throughparse_offset/2.CLDR 49's
dualOffsetFormatjoins the two offsets a zone keeps, so the localized GMT format describes a zone that moves between them:offsets: :dualwrites 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-Timezoneitem, and one whose date half is only a weekday joins its time throughTime-Day-Of-Week, as TR35 now specifies.en:yMMMdzrenders "Jul 6, 2024 EDT", where it errored.Localize.DateTime.SemanticSkeletonimplements TR35 semantic skeletons, which ask for a date by meaning ("YMDE") rather than by field, and:formataccepts one everywhere.:time_precision,:zone_length,:hour_cycleandalignment: :columnshape the result.MessageFormat 2's
:date,:timeand:datetimetake TR35's options, fromfieldsandlengthtotimeZoneStyle, and the overridestimeZone,hour12andcalendar:{$d :date fields=month-day}inen-AUis "14 Jun".Localize.DateTime.Timezone.parse_offset/2reads a fixed UTC offset from a zone string — ISO 8601, or the localized GMT format in any locale's spelling — invertinggmt_format/3.Localize.DateTime.parse/2uses it, so"May 16, 2026 2:30 PM GMT+10:30"parses withoutcalendrical.Localize.Date.parse/2,Localize.Time.parse/2andLocalize.DateTime.parse/2parse natively, no longer requiringcalendrical—parse("March 22, 2026", locale: :en)gives{:ok, ~D[2026-03-22]}.Localize.Interval.parse/2andLocalize.DateTime.Parser.parse/2join them.Localize.MinimalPairsexposes CLDR's minimal pairs — the phrases demonstrating a locale's plural, ordinal, case and gender forms.cardinal/1,ordinal/1,grammatical_case/1andgrammatical_gender/1return them;format/3picks the one a number selects.CLDR's
symbol-alt-variantcurrency symbols are ingested asLocalize.Currency.variant_symboland selected withcurrency_symbol: :variant—:TRYinengives "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/2returns a locale's pair, and:numeric_date_separatorand:numeric_time_separatorset them on the date, time and interval formatters.CLDR 49's
-u-hc-c12and-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-c12writes 00:30 as "午前0:30".Ordinal dates from CLDR 49, which CLDR designates a technical preview: the
dddfield formats the day from the locale'sdayOfMonthsdata, soen:yMMMdddrenders "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
appendItemstemplates.enhas no quarter format, so:yMMMdQnow renders "Jul 6, 2024 (quarter: 3)" where it errored.Localize.DateTime.Timezone.location_name/3returns 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 — andgeneric_location_format/2renders it through the locale'sregionFormat.The
gpattern symbol formats the modified Julian day and the deprecatedlis ignored, as TR35 specifies, where both returned a tokenize error.mix localize.verify_localeschecks 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/0returns the CLDR repository tag the bundled data was generated from, such as"release-49", and the tag now ships in the hex package. Together withLocalize.version/0it 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/2reads text asLocalize.Interval.to_string/3writes it at the given:format, so "Jun 16 – 20, 2026" at:mediumparses, 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/2without a format keeps its compiled patterns for each locale and calendar: "Jun 16, 2026" inentakes about 0.4 ms, where it took 5.Localize.Interval.parse/2keeps its compiled patterns too, so aneninterval takes about 2 ms, where it took up to 80. They use up to about 300 KB of:persistent_termfor 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 erasunverified: 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:yMdEEEEErenders "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_localesand 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-GBnames: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 throughdate_from_cardinal_date/3, and an interval across 1 January writes both years.A locale's
-u-mu-fahrenheoverrides the unit to Fahrenheit, where the override was dropped: BCP 47 caps a value at eight characters, so CLDR spells itfahrenhe, which named no unit.A currency amount uses the locale's currency separators where CLDR gives them:
fr-CHwrites1'234.56 CHFandde-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/2dropped it silently, which is how the currency separators went missing.A missing locale falls back to its parents and then to
:enrather than to theundroot, which has no currency data and no localized names. Only an explicit request for:undloads 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.Optionsstruct takes the sign from each number it formats. The sign of the number it was validated with was reused, so options validated with0formatted-70as "CHF 70.00".Localize.validate_locale/1returns 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/3returns a data-model error rather than raisingWithClauseError: a duplicate declaration parses and then fails validation, which itswithhad 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/2caches 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/2reads 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/1returnsLocalize.InvalidLocaleErrorfor 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:
akis "Akan", where it was "Twi", andfilis "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
zhfile was forzh-TW.mix localize.download_inflection --pruneremoves superseded versions.A locale that
:persistent_termhas no room for returns aLocalize.LiteralMemoryErrornaming 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
:yMMMdwis "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.ISOreads 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/2of "Feb 5, 2024 – Feb 1, 2024" for a calendar of weeks returned aLocalize.DateRangeParseErrorholding the Gregorian dates the text was read as.A year below 1 is read where its numbering spells its sign:
jawrites a Japanese date before the first era with thejpanyearnumbering, "大化マイナス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, somt'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'syMd, 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.Gon a Chinese or Dangi date writes the number of its sixty-year cycle, "78" in 2026, as ICU does, where it returnedLocalize.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/2isnilbefore 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
rwrote 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/2reads with aLocalize.DateTime.SemanticSkeletongiven as:format, where the skeleton was ignored. A skeleton whose minutes are optional reads "2 PM", whichLocalize.Time.parse/2refused.A date and time is written with the numbering its date format states, CLDR's
numbersattribute, as the date alone is.he's Hebrew date and time was "1 בתמוז 5786, 14:30" beside the date's "א׳ בתמוז ה׳תשפ״ו", andzh'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 itsGyMd'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" andde'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:
GyMdfor a Chinese date inenis "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:
kekwrites 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
jawrites it. Its full, long and medium dates take CLDR'sjpanyearnumbering, 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.Japancounts 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'syMdwrites 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 itsyMdinterval "6/16/2026–6/20/2026" reads back.A skeleton's year is a number at every width and
Ua name at every width, so ayrequest never takes the year-name format:yMdfor a Chinese date inenis "5/2/2026", where it was "5/2/bing-wu".yyyyandyystay apart, soyyyyinenis "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/3takes 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-GBread "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
arandga's "MAG" are offset 0, where only "GMT", "UTC" and "UT" were.Localize.DateTime.Timezone.parse_zone/2,parse_offset/2andresolve/3read 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
ZtoZZZ: Los Angeles before standard time is "GMT-07:52:58", where it read back 58 seconds out.parse_offset/2reads 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 incs,fiandvmw, 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/2andparse_offset/2read an offset in any numbering system's digits, as TR35 has it:ar-EG's "غرينتش+٥:٣٠" andne's "GMT+५:३०" read, where they were an unknown zone in the 67 locales with digits of their own.Localize.DateTime.Timezone.parse_zone/2andresolve/3return an error for bytes that are not UTF-8, where they raised anArgumentError.A custom MF2 function receives its options under their names as strings, as
Localize.Message.Functiondocuments. It received an atom for any name an atom existed for, so the documentedfunc_opts["format"]readnil.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
suand "ora de CU" inoc, 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, andru's "Виргинские о-ва (Великобритания)" as the United Kingdom's.Localize.Interval.parse/2reads 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-PrinceandAntarctica/McMurdo, take the city names their locale gives them, "بلانك-سابلون" inar, 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, andko'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.ISOtakes the zone names of the instant it names, where its fields were read as ISO's: a Persian date in January 2026 inAsia/Almatyis "Kazakhstan Time", where it was "GMT+05:00".metazone_for/2reads 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/2reads ISO 8601's times in every locale: "14:30" without seconds, whichfi,daand 13 more did not read, and "T14", "T1430" and "T143045".zforZand U+2212 in an offset read too.Localize.DateTime.parse/2reads every ISO 8601 date-time form: week dates and ordinal days before theT, 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/2reads 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, readsas: :mapin every locale.Localize.Time.parse/2reads an hour only in the range TR35 gives its field,h1 to 12 andK0 to 11: "0:30 AM" ath:mm aand "12:30 AM" atK:mm aare errors, where they were read as 00:30 and 12:30.Handkwere held to their ranges already.An interval writes only the fields its format names, as TR35's algorithm has it:
:MMMdfrom 28 December to 3 January is "Dec 28 – Jan 3", and:yMMMHmon 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/2writes aTimeorNaiveDateTimeat 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
:longand:fullwithLocalize.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,esandzh-Hantno 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: :ywis "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/2reads 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
Zwritten hard against a time is kept:Localize.DateTime.parse("11/22/2023 14:30:45+02:00", locale: :en)is aDateTimetwo hours ahead of UTC, where the offset was dropped and aNaiveDateTimecame 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_loadcallback calls, is compiled and loaded first.Localize.Interval.to_string/3andto_parts/3take 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.wandYtake 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
stdOffsetanddstOffsetwhere a metazone period names them, as CLDR 49 does forEurope/DublinandAmerica/Winnipeg, rather than from the time zone database's flag: Dublin's summer is "Irish Standard Time" howeverstd_offsetis set.Parsing no longer depends on map iteration order, which varies between VMs: standard formats are tried in
Localize.DateTime.Format.standard_formats/0order, and a place name two zones share resolves to the oneLocalize.Territorypicks, else the alphabetically first.Localize.Locale.parent/1returns 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/2with 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
hawwrites 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
hewrites it ("כ״ב בטבת ה׳תשפ״ד" is 22 Tevet 5784): ahebryear is read by its letters' values, its thousands marked by a geresh, where none ofhe's Hebrew dates read back.Localize.Time.parse/2, and the time ofLocalize.DateTime.parse/2, read the time formats of the:calendargiven before the Gregorian calendar's, sode's Chinese "10 vorm." parses as the formatter writes it. A:calendarthat is not a calendar module is aLocalize.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, "غرينتش-٤" inar-EG), or in those of-u-nu-or the formatter's:number_system.Localize.DateTime.Timezone.gmt_format/3andnon_location_format/3take a:number_systemoption.Localize.Territory.territory_from_locale/1returns the region of a-u-rg-subdivision, soen-u-rg-gbsct(Scotland) is:GBwhere 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,eandchonour a locale's-u-fw-and-u-rg-, which they ignored: Friday ise5 inen-u-fw-mon.first_day_for_locale/1andmin_days_for_locale/1follow TR35's algorithm in full,-u-ca-iso8601and-u-sd-included.A datetime interval splits a skeleton into date and time fields, as TR35 does: one day's
:yMMMdHmis "Jun 15, 2026, 10:00 – 14:30", where it was an error, and:Hmwrites 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.InvalidValueErrorin formatting, intervals,Localize.Calendar.localize/3and relative time, by the calendar'svalid_date?/3andvalid_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/2counts the quarters, and the calendar weeks from the locale's first day for:monto:sun, between two dates, where it formatted their difference in seconds as that many quarters or weekdays.Localize.DateTime.Relative.to_string/2counts a number of seconds beyond the range of a float, where it raisedArithmeticError.Localize.Duration.new/2counts years, months and days with the calendar'sdiff/3andshift/6, giving the spanDate.shift/2adds 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/2measures 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/2returns an error for aDateTimebuilt by hand without its offsets from UTC or its time zone, where it raisedArithmeticError.uwrites the calendar's extended year, itsextended_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 withoutextended_year/3is 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/2substitutes into a template of any shape, where it raised on one it did not list, such asel'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
nnhround-trip, where 14,099 did.Localize.Time.parse/2reads a time written in the digits of the locale's number system, as the date parser does, sobn"১০:০৫ AM" andfa"۱۰:۰۵" 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 andff-Adlm's Japanese eras are, where transliterating the input's digits left them unmatched.Localize.Time.parse/2tries 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, soms"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/3andto_parts/3format 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: :GyMMMdis "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/2and 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/2returns the date-time where it raised for every calendar butCalendar.ISO, and reads a date-time pattern that puts the time first, asvi'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
yyyear 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/2andLocalize.Time.to_string/2resolve formats in the value's calendar — its date-time pattern, available formats and append items — where they used the Gregorian calendar's, andLocalize.DateTime.parse/2reads 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", "闰二月") andzh's andja's Han numerals. Where a locale writes both the calendar year and the related year, askodoes, 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/0names 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
enand "闰2" inzhwhere 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.ISOcount it —Calendrical.Gregorianyear 0 is "1 BC" andCalendrical.Rocyear -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:GyMMMis "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
ArgumentErrororUnicodeConversionError.Localize.Unit.parse(<<255>>)returnsLocalize.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/2takes a map whose fields name no real date and time, returning the zone's current metazone, where it raised.Localize.Locale.parent/1gives a parent found by dropping a subtag its own CLDR locale rather than the child's, soar-SA,de-AT,fr-CA,es-419andzh-Hant-HKspell numbers out with their language's RBNF rules instead of failing. A validatedundwalks to root rather than to English.Localize.DateTime.parse/2andLocalize.Time.parse/2withas: :mapgive a fixed offset such as "GMT+5" theDateTimezone fields the struct form resolves, where the map kepttime_zone: "GMT+5"with no offset; a named zone resolves too when the input gives its full date.Localize.Time.parse/2andLocalize.DateTime.parse/2read 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/2andLocalize.Date.parse_range/2parse 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/2resolves 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
:formatfor a partial date or time is used rather than replaced by a skeleton derived from the fields present.prefer: :menuonLocalize.Language.display_name/2composes CLDR'smenu="core"andmenu="extension"halves where a locale ships noalt="menu"string of its own, so"ku"renders "Kurdish (Kurmanji)" rather than the unqualified "Kurdish". Affects 571 language entries across 446 locales.Localize.DateTimekeeps a width the matched format's own skeleton asks for, asLocalize.Datedid, sofr:yyMdrenders "06/07/24" not "6/7/24". A zone keeps its width the same way, soel:yMdHmsvnames the zone in full, as itsHmsvformat does.A skeleton's weekday matches the locale's weekday formats whether spelled
E,eorc, and a year whichever ofy,Y,u,Uorr, taking the width asked for.en:EEEErenders "Saturday" where it gave "Sat", andja:U"2024年".jandCcarry TR35's day-period width, three or four letters wide and five or six narrow, soen:jjjjjrenders "2 p". Anabeside an hour sets only the width of the locale's own day period, TR35 treatinghaash, sozh-Hant:hakeeps 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
:Grenders "AD" and:QQQQ"3rd quarter" where both errored.A locale passed to
Localize.Inflectionmust 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'sjjmformat "'zeg'. H:mm" rendered "eg. 14:30", its quotedzread 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,
Jpadding,Cin Indian locales andenyyyy.Full UCA conformance restored: all 210,155 pairs in both CLDR collation conformance files sort correctly, where 637 and 536 failed.
FractionalUCA.txtand the UCD property files had been hand-vendored at Unicode 17 and are now copied by the pipeline.Localize.Locale.LocaleDisplay.display_name/2renders a locale inroot(orund) as bare subtag codes per TR35's code fallback —display_name("nl-BE", locale: :root)is"nl (BE)", where it answered in English.rootis now accepted whereverundis.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.FormatErrormessage 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}— usuallyLocalize.InvalidValueError— for options that are not a keyword list and for arguments or option values of the wrong type, where they raised; predicates answerfalse.Localize.Message.format/3returns aLocalize.FormatErrorwhere it raised when a placeholder,:stringor a:stringselector 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,2andLocalize.ListreturnLocalize.InvalidValueErrorfor an improper list and for a bitstring that is not whole bytes, where they raisedFunctionClauseError.Localize.Message.format/3withbackend: :niffalls 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,\fand\uescapes and exponent-form numbers, which reached ICU as text.Localize.Nif.mf2_format/3returns{: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/3andformat_to_iolist/3return 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/1returns 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" inen, 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/1andLocalize.Number.Format.format_system_types_for/1return systems in type order (default, native, traditional, finance), where they followed atom-creation order.The module
mix localize.unit.gen_conversionsgenerates lists each unit once inknown_units/0, where the simple base units appeared twice.Localize.Territory.to_territory_code/2matches a name as written before its normalised form and, where territories share it, prefers a country to a region, then the first code alphabetically. Inscnthe 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:localeoption, no longer raise on a hand-built struct with wrong-shaped fields: anillist or map is empty, a string subtag is its atom, and anything else returnsLocalize.InvalidLocaleError.Interpolating a parsed
Localize.LanguageTaginto a string gives its BCP 47 form, where it raised because the tag had no canonical id yet.Localize.Territory.territory_from_locale/1takes the territory of a language tag struct that has none from its likely subtags, so a parsed "fr" gives:FRwhere it gave the default locale's:US.A locale that Localize holds data for now resolves to itself.
resolve_cldr_locale/1asked the language matcher even for an exact identity, soar-EG, which formats inarabdigits, resolved toar, which useslatn.rootis accepted whereverundis. TR35 makes it a synonym forund, but CLDR's alias data does not carry it, soLocalize.validate_locale("root")failed whileLocalize.Locale.cldr_locale_id_from("root")succeeded.mix localize.download_localesrejects 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_delayand:http_ip_familyconfigure 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: :MMMMrendered "Jul" inenwhere 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 —
:MMMMdjmsOgave "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
availableFormatsid already carries the requested width, soru'syMdrendersdd.MM.yrather than being narrowed tod.M.y.Resolving a date-time skeleton no longer creates atoms.
Localize.DateTime.Format.Match.split_fractional_seconds/1interned 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_formatand:time_formatresolve a skeleton through TR35 matching, where a missing skeleton errored. A skeleton date format joins through the locale's date-time pattern, sojarenders "2026年6月15日 14:30".Localize.DateTime.to_string/2andto_parts/2keep 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" inderather than "2:30 PM". A-u-hc-override now applies to partial times and throughoutLocalize.DateTime, which ignored it.Localize.Time,Localize.DateTimeandLocalize.Intervalrender a-u-hc-h11or-u-hc-h24cycle with theKorkhour ("0:30 AM", "24:30") and resolvejin an interval skeleton. Under-u-hc-, an explicithorHkeeps the cycle it names, as in ICU.Time skeletons resolve day periods as ICU does:
harenders "h a" andhb"h b" where both gave a flexible day period, and anHorkskeleton drops a requested one.Jdrops it as TR35 requires andCignores a-u-hc-override.Localize.DateTime.to_string/2applies a-u-hc-hour cycle to a skeleton that matches an available format exactly or takes append items, and renders anXorxzone field requested by a skeleton, which it dropped.bandBjudge noon and midnight at the precision their pattern shows, as ICU does, so "h B" renders 12:05 as "12 noon".VVVrendersEtc/UTCas the unknown location, "Unknown Location" inen, rather than "UTC".Shas exactly as many digits as the field has letters, as TR35 specifies, where it stopped at six and at the value's precision, soss.SSSon 45.9 seconds is "45.900".Uwithout cyclic year names formats asy, andYYYYYYpads rather than rendering empty.Timezone format symbols follow TR35:
Vis the BCP 47 short identifier ("usnyc"),VVVthe exemplar city ("New York") andVVVVthe generic location format, where all three returned the IANA name or a GMT offset. An alias likeUS/Easternresolves to its canonical zone.The generic location format names a country where TR35 requires one — a single-zone territory, or a zone in
primaryZones— soVVVVrendersEurope/Romeas "Italy Time".primaryZonesis new supplemental data, read frommetaZones.xmlbecause the JSON omits it.vandvvvvfall back to the generic location format before the localized GMT format, which TR35 specifies for the generic symbols only, soAsia/Kolkatais"India Time"rather than"GMT+05:30". The specific symbolszandzzzzstill fall straight through to GMT.ZZZZspells a zero offset as"GMT+00:00", matchingOOOOas TR35 requires of the localized GMT formats. A metazone with no daylight name also resolves a generic request to its standard name, sovvvvis"Greenwich Mean Time"forEtc/GMTand"India Standard Time"forAsia/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
Ofor India is "GMT+5:30" rather than "GMT+05:30".Localize.Duration.to_time_string/2no 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 —
deat:mediumrenders "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_monthgave "Jul – Jul 2024". A selection of fields without a year writes none for dates of two years, sofields: :month_and_dayacross 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
:hinterval 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:shortis "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:mediumrather than as one value.Localize.DateTime.Relative.to_string/2andto_parts/2return{: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 raisingFunctionClauseError.Localize.DateTime.Relative.to_string/2andto_parts/2measure a value against a:relative_toof another type where the conversion is unambiguous, and returnLocalize.InvalidValueErrorotherwise. The baseline was silently replaced by the current time.Localize.DateTime.Relative.to_string/2andto_parts/2format 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:unitis a count of that unit, where it was read as seconds.A display name with no entry for the requested preference and none for
:standardfalls 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/1returns 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.Numberformats a non-default numbering system with the data CLDR gives the locale for it:fa-u-nu-latnis "1,000.5", not "1٬000٫5", anden-u-nu-arab"١٬٠٠٠٫٥", not "١,٠٠٠.٥".The long currency format no longer raises in
sdandckb, whosearabhas no unit patterns and now takes theirlatnones, and writes a currency with no display name by its code, as TR35 specifies ("٣٫٠٠ USD" inckb).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.00gives "CHF 1,234.56" and "¥1,235".Localize.Number.to_string/2rounds 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/2andto_parts/2format 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/2keeps the whitespace a pattern ends with, as ICU does and asto_parts/2already did. A:wrapperfunction receives a quoted pattern character tagged:literal, which it never received.Localize.Number.resolve_currencies/2andresolve_currency/2match 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.0keeps its sign,:max_fractional_digitsapplies to a scientific mantissa, and compact formatting keeps two significant digits below 1 and groups on ICU's MIN2 rule.Localize.Number.to_string/2no longer raises on a very small float with a large:max_fractional_digits—to_string(1.0e-308, max_fractional_digits: 400)raisedArithmeticErrorbecause 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 -000read as six zeros rather than three.Plural selection now keeps a
Decimal's fraction operands.pluralize/3converted to a float first, and TR35 selects on the visible fraction digits and their value, so1.2was treated as "1.200" and choseotherwhere Serbian, Croatian and Bosnian requirefew.Localize.Unit.to_string/2selects the plural form of a rounded number by the digits the formatter displays, sohr0.0045 hours is "0,004 sata" rather than "0,004 sati".Unit preferences read the
-u-msand-u-mukeywords, in TR35's ordermu > ms > rg > (likely) region:en-u-rg-uszzzz-ms-metricgives celsius anden-US-u-rg-uszzzz-ms-uksystemimperial gallons.A unit whose quantity CLDR ships no preferences for falls back to base units rather than erroring:
preferred_units/2onamperegives[:ampere], oncandela-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, whichLocalize.Unit.Preference.known_usages/0lists, sousage: "fluid"returns imperial units foren-GBwhere 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/3selects a plural or ordinal variant by the number as its:number,:integer,:offsetor:percentfunction displays it, so Russian{2 :number minimumFractionDigits=1}isother. An:integeroperand given as aDecimalformats 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/2quotes 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/2andLocalize.DateTime.parse/2parse a localized date, time or datetime string back to a value. They delegate tocalendrical, which the application must add as a dependency.Localize.DependencyRequiredErroris returned when an operation needs a companion package the application does not depend on, naming the:packageand the:operation. It is what the newparse/2functions return whencalendricalis absent.
Changed
- Breaking.
Localize.validate_territory/1returns the canonical territory code, sovalidate_territory("AN")is now{:ok, :CW}where it was{:ok, :AN}— likewiseSUto:RU,DDto:DE, andCSandYUboth to:RS. This matchesvalidate_locale/1, which already resolved them.
Fixed
Localize.Utils.Math.mod/2andamod/2carry 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/1accepts 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.ukis the domain whileGBis the code.Localize.Interval.to_string/3andto_parts/3no longer raiseFunctionClauseErrorforen-CAwithfields: :month_and_day, format: :short. CLDR publishes analt="variant"interval pattern for en-CA, and it is now resolved as a single date's pattern is, honouring:prefer.en-CAskeleton formats such as:yMdand:MEdrender the locale's standard pattern,2026-05-03, rather than the day-first variant3/5/2026they returned whatever:preferasked for.prefer: :variantstill selects the variant.
[1.2.0] — August 16th, 2026
Changed
Breaking.
Localize.Number.Parser.scan/2now 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. Underfr,"chambres 12 14 16"was["chambres ", 12, " ", 14, " ", 16]and is now["chambres ", 121416]; passlenient: falseto 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/2andscan/2validate that grouping separators sit in plausible positions, matching ICU, so input that was previously accepted can now fail. Underfr,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/2andLocalize.Number.Parser.scan/2take a:lenientoption governing that validation.true, the default, requires each group to be at least two digits, which is ICU's lenient rule;falserequires exactly the locale's grouping size — three for most locales, and the 3-then-2 ofen-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 inenand 190 injawere named from the identifier instead, losing renames (America/Godthabis"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/2finds a grouped number written with an ordinary keyboard space in a locale that formats with U+202F. Underfr,scan("1 234,5")was[1, " ", 234.5]and is now[1234.5].Localize.Number.parse/2treats 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 afrnumber carrying U+00A0 or U+2009 — as copied out of formatted output — failed to parse.Localize.Number.parse/2ignores 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 ofar,he,faand 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/2applies CLDR'sparseLenientscharacter 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 writingminusSignas U+2212 —fa,fi,svandnoamong 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 letsde-CHaccept both spellings of its apostrophe group separator.
Added
Localize.DateTime.Timezone.exemplar_city/3returns the localized exemplar city for an IANA timezone identifier.derive: falsereturns 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/2selects 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 float1.0renders as1and is now"1 hectare"rather than"1 hectares", and conversely1withfractional_digits: 2renders as1.00and is now"1.00 hectares"rather than"1.00 hectare".Localize.Unit.localize/2now resolves preferred units whose name is more than one word. It converted the atoms fromLocalize.Unit.Preference.preferred_units/2withAtom.to_string/1, yielding"cubic_inch"where the parser wants"cubic-inch", so 35 of CLDR's 90 preferred units failed with aLocalize.ParseError— taking the default usage of speed, volume, area, energy and pressure with them.
Changed
- The package now declares
Unicode-3.0alongsideApache-2.0, andLICENSE.mdgains 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 bymix localize.unit.gen_conversionscarry the same dual-license SPDX header, so a consumer runningreuse lintover the generated file no longer needs a.licensesidecar.
[1.1.0] — August 11th, 2026
Fixed
The
Localize.Currency.t/0type now declares:iso_digitsasnon_neg_integer() | nil. Historic currencies (for example:BGN) have no ISO 4217 minor-unit definition and carrynil, which the module's ownis_nil/1guards already handle; the previousnon_neg_integer()type caused falsepattern_matchwarnings in downstream code that guards againstnil.
Added
mix localize.unit.gen_conversionsgenerates 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 identifierLocalize.Unitaccepts — prefixed, powered and-per-compound alike — throughto_base/2,resolve/1and a compile-time~usigil.
[1.0.1] — August 2nd, 2026
Fixed
Localize.LanguageTag.best_match/3now prefers a territory's own language when candidates are otherwise equidistant, sosgs(Samogitian, spoken in Lithuania) matchesltrather thanen-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.ParseErrorreports an over-cap input asreason: :input_too_largewith the byte counts in new:sizeand:limitfields, instead of interpolating them into a prose:reasonstring 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/1is public, checking a parsed message against the TR35 data-model rules.Localize.Message.Parser.parse/1remains syntax-only, so tooling that must accept invalid input —to_tokens/2and 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 byformat/3but serialized happily bycanonical_message/2and 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/2no longer serializes a message that violates the data-model rules; it previously round-tripped every invalid form, including a.localdeclaration 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.jsonconformance 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/1now accepts integer M49 codes as its typespec and documentation always promised —validate_territory(1)returns{:ok, :"001"}. An integer previously raisedFunctionClauseErrorbecause the validator's guard omittedis_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 as840are 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/2andto_parts/2rename the per-unit width override from:stylesto:formats, so the whole-duration width and its per-unit overrides share one name (format: :short, formats: [hour: :long]). The:styleskey, 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/3returns{:ok, name}or{:error, exception}instead of a bare string,nil, or an error tuple, and gainslocalize!/3for the bare name — the old union meant a template interpolating the result raisedProtocol.UndefinedErrorwhen the locale was invalid. It now derives the part's value and delegates todisplay_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 raisingFunctionClauseError.Breaking: The calendar part and type names are unified on
:day_of_weekand:day_period:Localize.Calendar.display_name/3takes:day_of_week(was:day) andlocalize/3takes:day_period(was:am_pm). An unknown part or type now returns an error listing the valid names rather than raisingFunctionClauseError.Breaking: The day-period variant selector is
day_period: :variantonLocalize.Calendar.display_name/3andlocalize/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_pmand:periodaliases are removed, so every variant option is now named for the part it applies to, as:eraalready was.Breaking:
Localize.Interval.to_string/3renames its:styleoption to:fields, because it selects which date fields appear (:date,:month,:month_and_day,:year_and_month) rather than a width —:formatremains the width axis, and the two are now named for what they do.Localize.Interval.date_styles/0becomesknown_fields/0, andLocalize.DateTimeIntervalFormatErrorcarries:fieldswith reason:unknown_fieldsin place of:style/:unknown_style.
Documentation
Localize.DateTime.to_string/2documents its:style,:date_format, and:time_formatoptions, which were live but absent from the docs.:styleselects the date/time wrapper (:atrenders "April 8, 2026 at 12:00:00 PM", and falls back to the standard wrapper for:mediumand:short, which CLDR does not define it for).
Fixed
Localize.Calendar.display_name/3reports an unusable:styleas a:styleerror listing the widths the field actually has, instead of blaming the value:display_name(:month, 3, style: :bogus)saidvalue: 3, expected: "1..13".style: :shortnow resolves wherever CLDR carries it (the day parts) rather than silently returningnil.Localize.Unit.to_string/2no longer lets a stray:styleoption reach the SI-prefix and custom-unit paths, where it produced "5 MegaJoule" forstyle: :shortinstead 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/2andto_parts/2join duration parts with CLDR's unit list patterns matched to the format width, per ECMA-402Intl.DurationFormat, instead of the standard "and" conjunction::ennow renders "3 days, 2 hr" (was "3 days and 2 hr") and "3d 2h" forformat: :narrow.Per-compound units whose denominator carries a constant keep that count in every width:
curr-usd-per-30-dayrenders "$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'scompound.perpattern, which also stops a numerator whose symbol contains the denominator noun being corrupted (narrowcandela-per-30-daygave "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.LocaleIsStaleErrornames both data versions and how to refresh, where the previousLocalize.LocaleNotFoundInCacheErrorsent 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/CasablancaandAfrica/El_Aaiunresume 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/2withgrammatical_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/2resolves 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 fromgrammaticalFeatures.xml. Closes #43.Per-compound units whose denominator has no per-unit pattern now localize through the locale's
compound.perpattern (Polish"5 jardów na milimetr") instead of falling back to an English"per".Localize.Unit.define_unit/2accepts a derived unit asbase_unit(e.g."day") and folds it to its fundamental base at registration, so the custom unit is convertible andcompatible?/2returnstrue. 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 CLDRcombineLowercasingfor 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:deterministicoption, 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/0rather 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/2is about 2.5 times faster andLocalize.Interval.to_string/3about 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,:longor:fullon 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/3reads 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.etfis 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/2is about seven times faster (302 µs to 44 µs) andLocalize.DateTime.parse/2about 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/2andparse_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/:exceptfilters disambiguating names like "2w" (watt versus week) — the migration path fromCldr.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/3andto_parts!/3decompose date, time, and datetime intervals into typed parts with ECMA-402:sourcetagging, across split patterns, fallback patterns, and equal-endpoint collapse. Open intervals (anilendpoint) are not supported in parts form.Localize.Unit.to_range_parts/3andto_range_parts!/3decompose a unit range: the numeric range parts keep their sources and the unit pattern text carries source:shared.Localize.Duration.to_parts/2andto_parts!/2decompose a duration into per-field unit parts (numeric segments carry a:unitkey) joined by:literallist separators.Date, time, and datetime formatting accept a
:number_systemoption: 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:hmsSSin en).Localize.Date.to_parts/2,Localize.Time.to_parts/2andLocalize.DateTime.to_parts/2(plus bang variants) decompose dates and times into typed field parts per ECMA-402formatToParts, across standard formats, skeletons, pattern strings and combined date+time wrappers.Localize.Number.to_range_parts/3andto_range_parts!/3return typed range parts with ECMA-402:sourcetagging (:start_range,:end_range,:shared) and:approximately_signhandling.Localize.Number.to_parts/2decomposes the:currency_longand:currency_long_with_symbolformats; the pluralized currency name is a:currencypart.Localize.Unit.to_parts/2andto_parts!/2decompose a unit into its number parts plus:unitand:literalsegments per ECMA-402.Localize.Unit.to_range_string/3andto_range_string!/3format 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.Rangeselects the TR35 plural category for numeric ranges (plural_rule/3for categories,plural_rule_for/3for numbers).Localize.List.to_parts/2andto_parts!/2return:elementand:literalparts for list formatting per ECMA-402Intl.ListFormatformatToParts.Localize.DateTime.Relative.to_parts/2andto_parts!/2return relative-time parts; the number part carries a:unitkey matching the JS part shape.Localize.Duration.to_string/2accepts per-unit:display(:auto/:always) and:styles(:long/:short/:narrow) overrides, mirroring ECMA-402DurationFormatper-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/2andto_parts!/2format a number into typed segments per ECMA-402formatToParts, covering decimal, currency, percent, scientific, compact and algorithmic-system output.Localize.Number.to_string/2accepts:minimum_integer_digits(zero-pad the integer part, ECMA-402minimumIntegerDigits),:trailing_zero_display(:strip_if_integerpertrailingZeroDisplay), and:rounding_priority(:more_precision/:less_precisionperroundingPriority).Localize.DateTime.Relative.to_string/2acceptsnumeric: :alwaysto force numeric output ("1 day ago" instead of "yesterday"), per ECMA-402RelativeTimeFormat.Localize.Collation.known_collations/0andLocalize.DateTime.Timezone.known_timezones/0(re-exported onLocalize) return the CLDR collation and canonical IANA time zone inventories forIntl.supportedValuesOf/1.The MF2 numeric functions accept the TR35
minimumIntegerDigits,minimumSignificantDigits,maximumSignificantDigits,trailingZeroDisplayandroundingPriorityoptions, mapped onto the correspondingLocalize.Number.to_string/2options.
Changed
The
:currency_longformats apply the currency's fraction digits and select the plural name from the displayed value per ECMA-402currencyDisplay: "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/2accepts a:sign_displayoption (:auto,:always,:except_zero,:negative,:never) mirroring ECMA-402'ssignDisplayacross decimal, currency, percent, scientific and compact formats.
Changed
Number.System.system_name_from/2resolves any CLDR numbering system for any locale instead of returning a:not_for_localeerror, andFormat.formats_for/2andSymbol.number_symbols_for/2inherit 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 andIntl.NumberFormat.Localize.Number.to_string/2honours any CLDR numbering system via-u-nu-or:number_system, per TR35/ICU:en-u-nu-thairenders Thai digits instead of anUnknownRbnfRuleError, and algorithmic systems (zhwith: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, matchingLocalize.Number.to_string/2and ICU.Localize.Unit.to_string/2withbackend: :nifhonours the:formatoption: the NIF call now receives the requested width, soformat: :shortrenders "100 m" on both backends instead of falling back to the long form.Localize.Duration.to_time_string/2honours TR35 single-quote literals in patterns, soformat: "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/1andknown_languages/1(uselanguages_for/1/language_names_for/1),Script.available_scripts/1andknown_scripts/1(usescripts_for/1/script_names_for/1),Subdivision.available_subdivisions/1andknown_subdivisions/1(usesubdivisions_for/1/subdivision_names_for/1), andTerritory.available_styles/0(useknown_styles/0).The Currency positional filter forms are removed:
currencies_for_locale/3,currencies_for_locale!/3,currency_strings/3andcurrency_strings!/3, along with the positional second argument of the arity-2 forms. Pass the:onlyand:exceptoptions instead; a positional filter now raisesArgumentErrorso 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), andLocalize.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/2and!/2acceptfavor: :script | :regionto select the TR35 removal variant (zh-Hant-TWminimizes to "zh-Hant" or "zh-TW"), verified against the full CLDR likely-subtags test data.The MessageFormat working group's
:test:function,:test:formatand:test:selectregistry functions are implemented, so the WG conformance suite's selection-mechanics and fallback cases now run.The MF2 numeric functions accept the
signDisplayoption (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
:numberdefault ({$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.FormatErrorreason: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
selectoption 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 thenumber-literalproduction, and:currency,:unit,:date,:timeand:datetimeare rejected as selectors.Localize.Number.Format.Optionsis documented again:validate_options/2is 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:idexpression options, and the:isolatebidi strategy deviation.
Deprecated
Localize.Currency.currencies_for_locale/2,3,currencies_for_locale!/2,3,currency_strings/2,3andcurrency_strings!/2,3take the include/exclude filters as:onlyand:exceptoptions 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?/1ensure the module is loaded beforefunction_exported?/3, so a cold module no longer silently takes the gregorian or default fallback branch.
Security
Localize.Locale.Provider.locale_file_name/1rejects locale identifiers that do not have the shape of a CLDR locale id, as defense in depth for the cache-path and download-URL interpolations.
[0.49.0] — July 12th, 2026
Fixed
Change the namespaces on the
leexlexers andyeccparsers to avoid module name clash withex_cldrversions. 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/2acceptshumanize: trueto render any unit in the unit people expect for its magnitude: bit-, byte-, hertz- and watt-based units scale through thehumanize/2prefix ladder (honouringsystem: :si | :iec) and all other units render through usage-based preferences. An explicit:usageoption keeps precedence for preference-scaled units.Localize.Unit.humanize/2andhumanize!/2also 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/2andhumanize!/2convert a byte- or bit-based unit to the prefix that best fits its magnitude, so1_500_000 byteformats as "1.5MB" withformat: :narrow. The:systemoption selects:si(powers of 1000, default) or:iec(powers of 1024) prefixes.Localize.Number.to_string/2acceptsformat: :shortandformat: :longas ex_cldr-compatible aliases, resolving to:decimal_short/:decimal_long, or to:currency_short/:currency_longwhen a:currencyis specified.mix localize.update_cldrorchestrates the CLDR data-update pipeline (copy sources → generate supplemental → compile gate → generate locales → test gate), with--checkpreflight,--localessubsetting, 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
DateTimerenders "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: :mrrenders "१५ मार्च, २०२१" (likewise bn, fa, my and others) matching ICU. Pattern-levelnumbers=and caller:number_system_overrideskeep 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 withLocalize.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/1honours 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/1andterritories_for/1return the localized territory-name inventory for a locale, completing the*_forfamily across languages, scripts, subdivisions and territories.CLDR metazone data joins the supplemental-data pipeline:
Localize.DateTime.Timezone.metazone_for/2returns the metazone for an IANA zone at a given instant, andzone_for_metazone/2returns a metazone's representative zone per territory. Thez/vformat 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
### Optionssections across the number, unit, date/time, locale, territory, list, collation and message modules, and corrects stale claims (RBNF number-system conversion, locale defaults, thevalidate_locale/1matching 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
:offsetfunction follows the specification: it takes exactly one of theaddorsubtractoptions (non-negative integer) and adjusts the operand for both formatting and selection. The previous ICU-styleoffset=option, which only subtracted during selection, is replaced.The MF2
numberingSystemfunction 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_systemoption ofLocalize.Number.to_string/2is unchanged.Credo (strict) added to the CI lint stage with a committed
.credo.exs. Policy: theDesign.AliasUsagecheck is disabled (Localize module names such asList,DateandStringshadow 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: :localizemetadata attached to Localize log messages.
Fixed
The
U(cyclic year) andr(related Gregorian year) format symbols work:Urenders the localized sexagesimal name (丙午, "bing-wu") from the calendar's cyclic name sets andrthe 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: :yMdHmcould 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), soLocalize.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-20070315round-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, andLocalize.LanguageTag.parse("zh-min-nan")no longer raisesArgumentError(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:othersposition per TR35; previously they kept unremapped weights, producing orderings incomparable with the reordered scripts.The collation
max_variableoption takes effect in the Elixir backend::spacenarrows and:symbol/:currencywiden 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_symbolrenders 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:currencyoption is given.Localize.Interval.to_string/3returns an error for mixed endpoint kinds (e.g. aDateand aTime) instead of rendering malformed output, and equalTimeendpoints honour the:time_formatoption in the single-time fallback.The
Qandqdate-format symbols were swapped:Qnow renders the format-context quarter andqthe 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, matchingz; previously they fabricated a UTC zone.Localize.Duration.new/2acceptsNaiveDateTimepairs (previously raised) and rejects reversedTimepairs 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 itsavailable:field now populated — instead of silently formatting digits.The
:spelloutformat resolves tospellout-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_digitsapplies 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/2no longer drops strings that precede a nested list or map, andunderscore_keys(nil)returns nil instead of raising.Number.to_range_string/3withapproximate: trueformats the full range ("~3–5"); previously the range end was silently dropped.Number.PluralRule.plural_type/2returns 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_unitserror.Unit plural categories for
Decimalvalues 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/2with 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
:localizehttpc 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/2no longer overwrites a user-supplied:number_system_overridesoption.Small fixes:
UnknownSubdivisionErrorcarries the original input instead of nil;UnknownCurrencyErrorfor 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!/1raisesArgumentErroron trailing data instead of a bareMatchError.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-klingoncanonicalizes totlh(displaying "Klingon"),zh-min-nantonan,en-GB-oedtoen-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
:localeoption 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-secondrenders "2 feet per second" instead of the raw identifier.MF2
:percentselection multiplies the operand by 100 before plural-category selection, matching the formatted value per the specification.An MF2
.matchon 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
Bformat symbol selects CLDR day periods ("in the morning", "mittags", "noon", "midnight") from the day-period rules now in the supplemental data pipeline, andbrenders noon/midnight at the exact points. Languages without rules fall back to AM/PM.Localize.LanguageTag.U.parse/1andparse!/1parse a standalone BCP 47-u-extension string (with or without theu-singleton) into a validatedLocalize.LanguageTag.Ustruct, using the same canonicalization asLocalize.validate_locale/1.Localize.LanguageTag.U.encode/1is 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 bymix localize.generate_locale_hashesat release time), before any decode or cache write. A mismatch or missing entry fails the download withLocalize.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,esandfrin addition toen, and the likely-subtags suite asserts the CLDR FAIL rows.
Fixed
Localize.Validity.U.encode/2emits the preferred BCP 47 spelling instead of a deprecated alias (:islamic_civilencodes as "islamic-civil", not "islamicc"), anddecode/2accepts struct-field atom keys and the canonical hyphenated calendar spellings ("islamic-civil").Week fields honour the locale's week configuration:
wandYcompute 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), andWcomputes week-of-month accordingly. Previously all three were ISO-only.Multi-replacement territory aliases follow TR35 Annex C:
hy-SUcanonicalizes tohy-AMvia the likely territory,uk-SUtouk-UA,sr-YUtosr-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 theundmapping.Locales whose language has no CLDR match resolve to root (
und) data instead of the alphabetically nearest wrong language —tlhno longer formats with Afar data.Compact formats no longer raise on a negative
:fractional_digitsoption.
Changed
mix test --covernow 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:
The
:styleoption ofLocalize.Duration.to_string/2and ofLocalize.quote/2is deprecated in favour of:format.The
:formatoption ofLocalize.Unit.display_name/2is deprecated in favour of:style.
Changed
Language.display_name/2andScript.display_name/2return{:error, %Localize.InvalidValueError{}}for an invalid:styleor:fallbackoption instead of raising; the!variants raise as before.Localize.quote/2returns an error for an unknown format instead of silently returning the input string unquoted.The
backend: :nifpaths ofNumber.to_string/2,Number.PluralRule.plural_type/2,Unit.to_string/2andMessage.format/3now validate the locale throughLocalize.validate_locale/1and 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
Localize.Territory.known_territories/0returns all CLDR territory codes.Localize.validate_currency/1delegates toLocalize.Currency.validate_currency/1so all validators are available on the top module.Bang variants:
Number.to_at_least_string!/2,Number.to_at_most_string!/2,Number.to_approximately_string!/2,Calendar.display_name!/3andUnit.display_name!/2.
[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:1234renders "1.2K" (previously "1K"). Pass:fractional_digitsor:max_fractional_digitsto override.The
json_polyfilldependency 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:jsonmodule 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.ISOdates: 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
:usageoption toLocalize.Unit.Preference.preferred_units/2no longer creates atoms from user input; it falls back to:defaultpreferences per TR35.
Added
- The hex package now includes the NIF sources (
c_src/), soLOCALIZE_NIF=truebuilds from the published package.
[0.41.3] — July 1st, 2026
Changed
Localize.LanguageTag'scanonical_locale_idfield — and thereforeLocalize.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 offcldr_locale_id.Localize.Language.display_name/2now 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 equivalentLocalize.LanguageTagreturn the same result.Localize.Language.display_name/2andLocalize.Locale.LocaleDisplay.display_name/2both now validate their input throughLocalize.validate_locale/1and drive the display offcanonical_locale_id, so a string and an equivalent (maximized)Localize.LanguageTagrender identically and no likely subtags leak into the output (a validated"en"tag is"English", not"English (United States)").
Fixed
Localize.Language.display_name/2andLocalize.Locale.LocaleDisplay.display_name/2now break the two-subtag candidate tie toward the earlier subtag per TR35, tryinglang-scriptbeforelang-region.Localize.validate_locale/1now accepts a-u-rg-region override that uses a subdivision id (e.g."en-u-rg-gbeng"). The subdivision value was being suffixed with the"zzzz"region filler and rejected as an unknown locale.
[0.41.2] — July 1st, 2026
Fixed
Plural rules now resolve likely subtags before selecting a rule set.
Localize.Number.PluralRule.CardinalandOrdinal(pluralize/3,plural_rules_for/1,plural_rule/3) andLocalize.Number.PluralRule.plural_type/2validate the locale throughLocalize.validate_locale/1instead of a bare parse, so a string and an equivalentLocalize.LanguageTagselect the same category and region-specific rules apply — e.g.plural_rule(0, "pt-PT")is now:other(Portugal) rather than:one(the basept/Brazil rule). This also corrects plural selection in message formatting.Localize.Territory.contains?/2now validates both arguments, so string and atom territories are equivalent (contains?("EU", "DK")matchescontains?(:EU, :DK)); it returnsfalsefor an invalid territory instead of silently treating a string as unmatched. It now also accepts territory strings, matching its sibling relationship functions.Localize.Collation.Options.from_locale/1now canonicalises its input throughLocalize.validate_locale/1, so a string, atom, and equivalentLocalize.LanguageTagyield identical collation options and legacy aliases resolve correctly ("iw"behaves as"he","pt_BR"as"pt-BR").
[0.41.1] — June 30th, 2026
Fixed
Localize.Language.display_name/2now normalises its input throughLocalize.validate_locale/1, so a string and an equivalentLocalize.LanguageTagreturn the same name ("en-GB"andLanguageTag.new!("en-GB")both give"British English") and region-specific codes resolve with fallback ("pt-BR"→"Brazilian Portuguese","ar-SA"→"Arabic"instead of raising). A malformed locale now returnsLocalize.InvalidLocaleErrorrather thanLocalize.UnknownLanguageError. Thanks to @DVSLabs for the report. Closes #36.
[0.41.0] — June 3rd, 2026
Changed
- Breaking:
Localize.Number.Format.Compilernow 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 exposemin_integer_digitsinteger digits. The shift is exact forDecimal(via the%Decimal{exp: …}field) and lossless forDecimal-promoted floats/integers.New
:engineeringformat 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, andoptions.symbols.plus_signfrom the locale's CLDR data instead of hardcoding ASCII"E","+","-".ar/arabrenders"أس"with Arabic Letter Mark signs,fa/arabextrenders the"×۱۰^"superscripting form,ar/latnwraps signs with U+200E LRM.Localize.Number.Format.Compilernow normalises@-significant scientific patterns per TR35:"@@###E0"≡"0.0###E0","@@@##E0"≡"0.00##E0", etc. The compiler rewrites these to the canonical0-prefixed form so the runtime formatter does not need a separate@-aware path.New
:exponent_styleoption onLocalize.Number.to_string/2.:e(the default) emits the standard1.234E3form;:superscriptemits1.234 × 10³using CLDR'ssuperscriptingExponentsymbol and Unicode superscript digits (⁰¹²³⁴⁵⁶⁷⁸⁹⁻⁺). Ignored for non-scientific patterns.
Fixed
Localize.Utils.Math.round_significant/2no longer crashes when called with aDecimalandn <= 0or with a Decimal zero of any sign. Previously these reached:math.log10/1deep in the Decimal square-root path and raised:invalid_operation; both cases now return the input unchanged.Localize.Number.Format.Compiler.compile/1propagates compile-time errors fromformat_to_metadata/1instead of crashing withMatchError.
[0.40.0] — June 1st, 2026
Changed
- Breaking: A bare relative
:locale_cache_dir(no:otp_appset) is now refused at app start withLocalize.LocaleCacheDirError— it resolved against the BEAM's CWD, which differs between mix tasks,mix test, and a release. Fix: add:otp_appto anchor the relative path, or use an absolute path.
Fixed
Localize.Locale.Provider.locale_cache_dir/0validates its configuration at app start (viaLocalize.Supervisor) and raisesLocalize.LocaleCacheDirErrorinstead of silently reading from the wrong directory at runtime.Localize.Locale.LocaleDisplay.display_name/2no longer raisesFunctionClauseErroron a BCP 47 generic extension carrying an odd number of subtags (e.g.cr-s-7b); the trailing singleton chunk now renders as a bare subtag.
Added
- New
:otp_appconfig key. Three supported forms for the locale cache directory: (1):otp_apponly →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_appignored.
[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_localesconfiguration) now accepts a Gettext backend module likeMyApp.Gettextas an entry; it expands toGettext.known_locales/1with each returned string re-resolved through the existing POSIX-normalisation and likely-subtag canonicalisation path. The check usesCode.ensure_compiled/1so the entry is safe to place in eitherconfig.exsorruntime.exs.
[0.37.0] — May 17th, 2026
Fixed
Localize.DateTime.Formatteraudit: removed remaining:gregorian/Calendar.ISOhardcoding. 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 owniso_week_of_year/3callback before falling back to converting toCalendar.ISO, andiso_day/1's dead silent-fallback clause for unknown calendars was removed.Localize.Date.to_string/2now honours CLDRnumber_systemoverrides carried alongside date patterns — Hebrew dates render in Hebrew numerals (י״ב באייר ה׳תשפ״ד), Japanese imperial year-1 renders as元年, and numeric overrides like:arabtransliterate per-field via the system's digit set. Algorithmic systems resolve through CLDR'snumberingSystems.jsonto the right RBNF rule (:hebr→:hebrew,:jpanyear→:spellout-numbering-year-latnunder:ja).Localize.Date.to_string/2with a skeleton format (e.g.:yMMMM) against a non-Gregorian calendar no longer infinite-loops.Match.best_match/3now receives the calendar explicitly andresolve_skeletoncarries aseenset, terminating any residual match cycle.Localize.Date.to_string/2now honours non-Gregorian calendars carried in the date's:calendarfield — previously every call hard-coded:gregorian. ADatebuilt withCalendrical.Japanesenow formats as平成12年1月1日under:"ja-JP"instead of2000/01/01.Localize.Calendar.localize/3for:eranow derives the correct era by delegating to the date'syear_of_era/3callback. Previously it always returned era 1, producing白雉(Hakuchi, AD 650) for everyCalendrical.Japanesedate.Localize.DateTime.Formatterytoken now uses the calendar'scalendar_year/3(oryear_of_era/3as fallback) for non-Calendar.ISOdates, so era-relative year numbering — Heisei 12, Reiwa 6, AH 1420, BE 2543 — renders correctly.Localize.DateTime.Format.resolve_variant/2now handles the CLDR%{format: pattern, number_system: ns}variant shape. Without this, locales whose date patterns carry anumber_systemoverride (Japanesejpanyear, Hebrewhebr) silently fell back to Gregorian.
[0.36.0] — May 15th, 2026
Fixed
- Fix
currency_symbol: :nonewhen processing options forLocalize.Number.to_string/2.
[0.35.0] — May 14th, 2026
Fixed
Localize.Unit.localize/2and a list-of-units overload ofLocalize.Unit.to_string/2close the parity gap withCldr.Unit.localize/3: passing:usagetoto_string/2now resolves the locale-preferred unit set (territory derived from:localeviaLocalize.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/3andLocalize.Unit.decompose/2now preserveDecimalprecision end-to-end:Decimalinputs flow through Decimal arithmetic instead of round-tripping through float.Localize.Unitgains a:format_optionsstruct field thatlocalize/2populates from theskeletonattribute on CLDR'sunitPreferenceData(e.g.[round_nearest: 50]forusage: :roaddistances of 300 m+ in region 001).to_string/2merges 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/3now accepts a rounding mode (:half_updefault, plus:half_even,:half_down,:up,:down,:ceiling,:floor); float values are routed throughDecimalso every mode produces consistent results. NewLocalize.Unit.Math.trunc/1truncates toward zero with the same Decimal/float/integer dispatch asfloor/1andceil/1.Localize.Unit.measurement_system_for_territory/2accepts a category second argument::default(existing behaviour),:temperature(e.g.:US→:usfor Fahrenheit), or:paper_size(:US→:us_letter,:FR→:a4). The category-specific maps in CLDR'smeasurementData.jsononly 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/3accepts an optional thirdformat_optionsargument 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 whatLocalize.Unit.localize/2uses internally to thread CLDR preference skeletons through toto_string/2.Localize.Unit.to_string/2documents:grammatical_genderand:grammatical_caseas accepted options, matching the naming used byCldr.Unit.to_string/3.:grammatical_caseselects a case-keyed pattern variant (existing functionality, now documented).:grammatical_genderis 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/2documents:scopeand:altas forward-compatible placeholder options accepted but unused, matchingcldr_unitsAPI surface. CLDR 48 ships no<unitPreference>carrying these attributes.
[0.34.0] — May 14th, 2026
Fixed
Localize.Utils.Httpnow resolves the HTTPS trust store via:public_key.cacerts_get/0before falling back to the existingcacertfilechain (configured path →CAStore→:certifi→ well-known Unix paths).mix localize.download_localespreviously 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.Systemno 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 viaApplication.app_dir/2from a function body. Thanks to @neilberkman for the PR. Closes #28.
Added
Localize.Supervisoris 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_localesresolution). Consumers can keep the default OTP auto-start, or mark the dependencyruntime: falseand mountLocalize.Supervisordirectly under their own application supervisor. See the newguides/supervision.mdfor the full pattern.
[0.33.0] — May 13th, 2026
Changed
- Breaking:
Localize.Message.Sigilis renamed toLocalize.Message.Sigilsto make room for additional MF2 sigils. Update anyimport Localize.Message.Sigiltoimport Localize.Message.Sigils.
Added
Localize.Message.Sigilsadds a new~tsigil 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 viause Localize.Message.Sigils, backend: MyApp.Gettext. Requires the backend to useLocalize.Gettext.Interpolation.Localize.Gettext.Interpolation.skip_interpolation_sentinel/0— public sentinel used byLocalize.HTML.t/1(and other markup-aware renderers) to retrieve a translated MF2 source without running the markup-stripping interpolation. Bothruntime_interpolate/2andcompile_interpolate/3short-circuit when this sentinel is passed as bindings.
Fixed
Localize.Application.start/2now 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 tobinary_to_existing_atom, which assumes the legitimate atoms already exist. Without the eager-load, valid input likenumberingSystem=arabcould surface as a bogus "unknown numbering system" error on a fresh BEAM where no prior code path had triggered the relevantbinary_to_term/1read. Manifested as a transient CI failure inLocalize.Message.NumberOptionsTest; cause was identical in shape to issue #26.Localize.default_locale/0no longer hangs whenLANG/LC_*hold values that fail validation (e.g.POSIXon minimal CI runners): the resolver pre-seeds the:persistent_termcache with:enbefore 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 theException.message/1→ Gettext-backend →get_locale/0recursion that produced the original 60-second hang inlocalize_translateCI.
[0.32.0] — May 12th, 2026
Fixed
mix localize.download_localesno longer evaluatesconfig/runtime.exsof the consumer application. Previously the task ranMix.Task.run("app.config"), which transitively evaluatedruntime.exs, which was not necessary. The task now loads only compile-time config (config/config.exsand 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 sameMatchErrorclass as issue #26: the per-unit format loop inLocalize.Duration.to_string/2now short-circuits on the first formatter error, andLocalize.Number.Formatter.Decimal's digit-transliteration step now useswithto fall through to untransliterated digits if either the requested or:latnnumber-system data is unavailable.New
Localize.LintTestsource-level lint that scanslib/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.FallbackResilienceTestexercises 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 thatprovider.get/3succeeds.New
Mix.Tasks.Localize.DownloadLocalesTestcallsDownloadLocales.banner/2directly withdefault_locale: :"en-ZA"set, reproducing the exact scenario from issue #26 without invoking the network.banner/2is now@doc falseso the test can reach it.New
Localize.AtomInterningTestexercises the public lookup paths that previously surfaced the supplemental-atom bug —Localize.Number.System.system_name_from/2and friends, plusLocalize.Currency.validate_currency/1,Localize.validate_calendar/1,Localize.validate_territory/1,Localize.validate_script/1— each driven with binary input. A regression inintern_supplemental_atoms/0would surface here as a structured error from one of these accessors.
[0.31.0] — May 12th, 2026
Fixed
Localize.Locale.Loadernow 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 causedprovider.get(requested_locale, _)to miss in-memory fallback data and surface as a spuriousItemNotFoundError— most visibly crashingmix localize.download_localeswith aMatchErrorwhendefault_localenamed an unavailable locale. Locked down withtest/localize/locale/loader_fallback_test.exs. Closes #26.mix localize.download_localesno longer pattern-matches on the result ofLocalize.Message.format/2when 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
Localize.InvalidValueErrorgained an:allowed_valuesfield and a newLocalize.NoCertificateStoreErrorcarries the searched paths; previously prose-stuffed:expected/:currencyfields and bare-string:reasoncodes are now structural.Eight option-validation sites across
Localize.Language,Localize.Script,Localize.Collation,Localize.Utils.Map, andLocalize.Utils.Httpnow raise structured Localize exceptions rather thanArgumentError/RuntimeError.
[0.30.1] — May 12th, 2026
Fixed
- Revert the
[:safe]option on:binary_to_term/2since 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/1no longer callsString.to_atom/1on 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 returnLocalize.InvalidSubtagError.Localize.Locale.to_locale_id/1renamed toLocalize.Locale.cldr_locale_id_from/1and now returns{:ok, atom()} | {:error, Exception.t()}, gating atom creation behindLocalize.validate_locale/1and 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 ofcurrencies_for_locale/3's filter no longer atomise input before checking validity. Unknown currency or territory binaries are rejected viaHelpers.existing_atom/1and never grow the atom table.Localize.Script.display_name/2andLocalize.Unit.Formatterno longer atomise binary input before checking validity. Unknown script codes returnLocalize.UnknownScriptErrorwithout growing the atom table; the unit formatter's currency atomisation is gated as defence-in-depth behind the upstreamLocalize.Unit.validate_currency_codes/1check.MF2
:listfunction and unit parser no longer atomise user-controlled binaries. The:listfunction's binarystyle=fallthrough now sets a sentinel atom that surfaces as anInvalidValueErrorinLocalize.List, and SI prefix names are resolved through a compile-time lookup map (Localize.Unit.Data.si_prefix_atom/1) rather thanString.to_atom/1at 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'stime_preferences_for/1no longer atomise user-supplied binary number-system or locale names before validation. Lookups go throughHelpers.existing_atom/1against pre-atomised CLDR data sets.Closed additional Atom DOS vectors in
Localize.Locale.LocaleDisplay.display_name/2(now routes throughcldr_locale_id_from/1),Localize.Territory.Subdivision.display_name/2, the-u-co-and-u-kr-extension parsers inLocalize.Collation.Options, and the redundantString.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), andGettext.Interpolation.safe_to_atom/1(missing-binding name reporting). All three previously fell through toString.to_atom/1on 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_dirset to a writable directory: a malicious or corrupted cache file can no longer resurrect arbitrary atoms, funs, or refs. Failed safe decodes surface asLocaleNotFoundInCacheError(orLocaleDownloadErrorfor 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/1andLocalize.Unit.Parser.parse/1, 64 KB forLocalize.Message.Parser.parse/1, and 1 KB forLocalize.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/2additionally rejectsDecimalresults whose exponent magnitude exceeds:max_decimal_exponent(default ±100) so downstream multiplication or formatting cannot materialise huge mantissas.Localize.FormatCacheETS table switched from:publicto: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. NewLocalize.FormatCache.clear/0andsize/0helpers added for tests and maintenance.NIF (ICU bindings) hardened. All NIF entries except
nif_plural_rulenow run on the dirty CPU scheduler pool (ERL_NIF_DIRTY_JOB_CPU_BOUND); the collator pool is sized forschedulers + dirty_cpu_schedulersandreserve_collrefuses overflow rather than reading past the array end. The reorder-codes branch capsnumCodesat 256 and checksenif_allocbefore use; everystd::stoll/std::stod/std::stoiis wrapped intry/catchso out-of-range C++ exceptions cannot unwind through the NIF boundary; the hand-rolled JSON arg parser guards each access afterskip_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/1now refuses to evaluate the file in:prod(or any environment without a loadedMixmodule — typical for releases) unlessconfig :localize, :allow_runtime_unit_files, trueis explicitly set. Outside:prodthe function works as before. The flag exists so an unintended feature switch in production cannot accidentally surface arbitrary code execution viaCode.eval_file/1.:localize_locale_cacheETS table switched from:publicto:protected, owned byLocalize.Locale.Loader. Writes are routed through the owner viacast(so the hot validate path doesn't block and writes triggered from inside the owner's ownhandle_callcannot deadlock). Reads remain direct ETS lookups. Combined with the format cache fix above, both ETS caches are now:protectedagainst multi-tenant or other-library interference.Localize.Utils.Http.get/2andget_with_headers/2now reject responses larger than 50 MB by default (configurable via:max_http_body_bytesapp env or per-call:max_body_bytesoption). 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 (viaLOCALIZE_UNSAFE_HTTPS), a one-timeLogger.warningis emitted so a misconfigured production deployment cannot silently downgrade TLS without leaving an audit trail.
[0.29.0] — May 11th, 2026
Changed
Localize.Currency.currency_for_code/2now returns the newLocalize.CurrencyNotLocalizedError(instead ofUnknownCurrencyError) when the currency code is valid but the locale has no display data for it.UnknownCurrencyErroris now reserved for codes that aren't recognised ISO 4217 or registered custom currencies.
Added
Localize.Currency.currency_for_code/2accepts a new:fallbackoption which walks the CLDR parent locale chain and the application default locale before failing. Thanks to @neilberkman for the PR.Localize.Locale.get/3accepts a new:fallback_to_defaultoption for a final-step fallback after any:fallbackparent walk. Acceptstrue(useLocalize.default_locale/0), or an atom/string/Localize.LanguageTagfor a specific locale.Add
:iso_3166option toLocalize.Territory.territory_codes/1to return only ISO 3166 codes (not aggregate territories).
[0.28.0] — May 9th, 2026
Changed
Localize.Interval.to_string/3Date intervals (style:date, the default) now resolve their skeleton per-locale instead of using a hard-coded locale-independent mapping. The interval looks upLocalize.DateTime.Format.date_formats(locale_id)[format]— the same mappingLocalize.Date.to_string/2uses — 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
:mediumis 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
:mediumis now numeric ("15.01.2022 – 20.03.2022"); was previously the abbreviated month form. To get"15. Jan. – 20. März 2022"request the:yMMMdskeleton explicitly. - en
:longuses full month name with no weekday ("January 5, 2012 – January 6, 2012"); was previously the:yMMMEdform"Thu, Jan 5 – Fri, Jan 6, 2012". The weekday now appears at:full. Per-locale skeletons that aren't shipped in CLDR'sinterval_formatsdata fall back to formatting each endpoint withLocalize.Date.to_string/2and joining via the locale'sinterval_format_fallbacktemplate. Non-:datestyles (:month,:month_and_day,:year_and_month) remain locale-independent — they describe a deliberate field selection unrelated to standard date styles. The staticLocalize.Interval.date_styles/0no longer includes a:dateentry.
- ja
Fixed
Strip zone token for NaiveDateTime too in
Localize.Time.to_string/2since they also do not have a time zone field. Relates to #22.Fix
Localize.Interval.to_string/3silently ignoring the:date_formatoption on Date-only intervals. The option now overrides:formaton the date axis, mirroring the precedence used for:time_formaton time intervals. Relates to #22.Fix
Localize.Interval.to_string/3raisingLocalize.DateTimeIntervalFormatErrorwhen called withformat: :fullon a Date interval. Relates to #22.Localize.Utils.Math.sqrt/2now respects the currentDecimal.Context.get/0precision when called with aDecimal— the result is rounded to the configured precision and Newton's-method convergence scales accordingly.Added the test suite for
Localize.Utilsthat are derived from theCldr.Utilequivalents.
[0.27.0] — May 8th, 2026
Fixed
Fix
Localize.Interval.to_string/3:shorttime-interval format using the wrong hour cycle on 24-hour locales.:shortnow picks:hmor:Hmper the locale's preferred hour cycle (honouring any-u-hc-Unicode-extension override), matching the cycle used byLocalize.Time.to_string/2:short. Thanks to @woylie for the follow-up report. Fixes #22.Fix
Localize.Time.to_string/2silently 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. ATimecarries no zone information, soLocalize.Time.to_string/2now 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:fullreturns"21:00:00"instead of"21:00:00 ()"(parens around the empty zone field).Adds support for
Decimalversion 3.0 to address a CVE. Thanks to @mitchellhenke for the PR.
Added
- Add
Localize.Time.hour_format_from_locale/1(and!/1) returning the locale's preferred hour cycle (:h11/:h12/:h23/:h24), honouring any-u-hc-Unicode-extension override.
[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/3format 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分to12:00:00~14:00:00.:en's:mediumshifts from12:00 – 2:00 PMto12: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/3collapsing the:short,:medium,:long, and:fulltime styles to the same:hmskeleton on Time inputs.:shortkeeps CLDR's interval-format dispatch (collapsed AM/PM);:mediumand above route through the locale's per-style time-format pattern, restoring per-style differentiation and including seconds in the default:mediumoutput. Thanks to @woylie for the report. Fixes #22.Fix
:time_formatbeing silently ignored onLocalize.Interval.to_string/3for 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/3crashing withFunctionClauseErrorwhen 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 enis"zero point zero five",3.04 zhis三点〇四, and0.000001 enis"zero point zero zero zero zero zero one".RBNF
0.xspecial-base rule now matches when the integer part is zero and the value is non-zero, routing ko0.5 spellout-numberingthrough its locale-correct sino-Korean영점오instead of the previousx.x-fallback공점공오.RBNF negative floats no longer double their output or silently drop the sign — ko
-0.5 spellout-numberingis-영점오instead of공점공점오, and locales that lack a-xrule 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 — frLocalize.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-cardinalisбир бүтүн ондон бешinstead ofбир бүтүн беш.
Added
Add
:minimum_significant_digitsand:maximum_significant_digitsoptions toLocalize.Number.to_string/2.Localize.Number.Rbnf.to_string/3now acceptsDecimalinputs 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.Formatterstand-alone pattern helpers now passcontext: :stand_alonetoLocalize.Calendar.localize/3instead of an invalidtype:option. Thanks to @timpritlove for the PR. Closes #20.Rename calendar
:format/:stand_alonetypespec and docs from:typeto:context.Clarify
Unit.display_name/2vsto_string/2in docs.
[0.23.0] — April 25th, 2026
Fixed
Fix
Cldr.Number.to_string/2forDecimalnumbers to produce the correct decimal digits.Fix
LOCALIZE_UNSAFE_HTTPSenv-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/2andLocalize.Locale.get/3to honor the:provideroption —load/2no longer callsprovider.store/2with the locale id, andget/3now loads through the same provider it reads from. Thanks to @rubas for the PR. Closes #16.Localize.Locale.get/3now honors the:fallbackoption by walking the CLDR parent locale chain when a key is missing in the requested locale. Fallback is handled inLocalize.Localeso 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 parsedLocalize.LanguageTagstructs whose:cldr_locale_idis not yet populated. The seven per-module locale resolvers collapse to one sharedLocalize.Locale.cldr_locale_id_from/1. Thanks to @rubas for the PR. Closes #18.Fix
Localize.available_locale_id?/1,Localize.validate_calendar/1, andLocalize.validate_number_system/1to 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/0now lazily resolvesconfig :localize, supported_locales: [...]from the application environment when the:persistent_termcache 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 likelocalize_web's~qsigil — previously saw the full CLDR list during partial recompiles. This causedLocalize.validate_locale/1to best-match against all CLDR locales rather than the configured subset, producing incorrectcldr_locale_idresolutions.
[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/1no longer crashes withMatchErroron non-ISO calendars. The generic branch destructuredcalendar.day_of_week/4as a 2-tuple but theCalendarbehaviour returns{day, first, last}.Localize.Time.to_string/2with 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 returningDateTimeUnresolvedFormatError.Localize.DateTime.to_string/2no 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/2with hour + minute (no second) under:mediumno longer emits a stray trailing:before the AM/PM marker — the partial path derives the:hmskeleton instead of using the fullh:mm:ss apattern 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/2now produces identical output for equivalentDecimaland float values. PreviouslyDecimal.new("1234.56")rendered as"1,234.560"under the standard pattern becauseDecimal.round/3returns 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 viaadjust_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 inLocalize.Number.Rbnf.to_string/1but the delegation was lost on theex_cldrtransition. 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/2now 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/2default palette keys renamed to the new atoms.
Removed
- The
mf2_theme_css/directory andscripts/generate_mf2_themes.exsgenerator. Themes now live canonically inmf2_wasm_editor.
Fixed
Localize.Gettext.Interpolation.runtime_interpolate/2no longer raisesLocalize.ParseErrorwhen 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
mix formatpluginLocalize.Message.Formatter.Plugin. Canonicalises MF2 messages in~Msigils and standalone.mf2files. Enable by adding the plugin to.formatter.exs. See the "mix formatplugin" section in the MessageFormat 2 guide.
[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
- MF2 syntax highlighter. See
Localize.Message.to_tokens/2,Localize.Message.to_html/2andLocalize.Message.to_ansi/2.
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
@deriveforJasonsinceJasonis no longer configured or used anywhere in the application.
Fixed
- Fix locale downloader to ensure it only uses the
:cldr_locale_idfield to construct the download URL.
[0.13.0] — April 15th, 2026
Fixed
Localize.Interval.to_string/3now correctly formats datetime intervals — matchingex_cldr_dates_timesbehaviour. 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 (Timevalues 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-unihannow applies correctly. TheLocalize.Collation.Hanmodule 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 inpriv/localize/collation_table.etf; the sort path invokesHan.collation_elements/1for CJK codepoints when the:han_orderingoption is:radical_stroke(set automatically for the-u-co-unihancollation type).
Added
Localize.Collation.Options.han_orderingoption —:implicit(default, UCA codepoint-based) or:radical_stroke(UAX #38). Automatically set to:radical_strokefor-u-co-unihanlocales.Persistent-term cache for parsed tailorings. First call to
Tailoring.get_tailoring/2parses 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, andja-u-co-unihanthat assert output differs from root codepoint order for specific character pairs — guards against silent regressions.
Changed
Localize.Collation.Hanis no longer a GenServer. Radical data is loaded alongside the main collation table byLocalize.Collation.Table(one ETF, one load step).
[0.11.0] — April 14th, 2026
Added
Localize.Interval.to_string/3now acceptsnilfor either thefromortoendpoint to format an open interval (e.g."Jan 1, 2020 –"or"– Jan 1, 2020").New guide: Interval and Duration Formatting — covers
Localize.Interval(including open intervals) andLocalize.Duration(calendar-unit strings and numeric time strings) in one place.
[0.10.0] — April 14th, 2026
Changed
- Mix tasks now only do
Mix.Task.run("app.config")followed byApplication.ensure_all_started(:localize), avoiding starting any consumer application. Thanks to @lostkobrakai for the report. Closes #7,
[0.9.0] — April 14th, 2026
Changed
Localize.Unit.Math.mult/2andLocalize.Unit.Math.div/2now factor operands that share the same base dimension.
[0.8.0] — April 14th, 2026
Added
Localize.Message.format_to_safe_list/3andformat_to_safe_list!/3— new MF2 formatting entry points that preserve markup structure instead of stripping it.
[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.Mathfor 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 viaapply_dimensionless/2.
Fixed
apply_dimensionless/2now validates that the unit is actually dimensionless before computing. Previouslysin(1 meter)would silently return a result; it now returns{:error, "sin requires a dimensionless value, got unit with base: meter"}. Units must reduce torevolution(angles) orpart(ratios) to be accepted.
[0.5.0] — April 13th, 2026
Added
:specialconversion support inCustomRegistry. Custom units can now be registered withfactor: :specialplus:forwardand: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 asvalue * factor + offset.Conversion.do_convert/3now uses a generalisedspecial_unit/1lookup that checks bothCustomRegistryand a compiled@built_in_specialmap, replacing the previous hardcoded:beaufortpattern 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:fullstyles, 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.Operatorsfor 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.Interpolationprovides MF2-based interpolation for Gettext backends.Localize.Charsprotocol — polymorphic locale-aware formatting with built-in implementations for 14 types andAnyfallback toKernel.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_localesfor 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 byException.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.