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.
[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.