Number Formatting Guide

Copy Markdown View Source

This guide explains how to use Localize.Number for locale-aware number formatting and parsing.

Overview

Localize.Number.to_string/2 is the primary function for formatting numbers. It accepts an integer, float, or Decimal value and returns a locale-formatted string. The formatting engine uses CLDR number format patterns, locale-specific symbols (grouping separator, decimal separator, percent sign, etc.), and number system digit sets.

iex> Localize.Number.to_string(1234567.89)
{:ok, "1,234,567.89"}

iex> Localize.Number.to_string(1234567.89, locale: :de)
{:ok, "1.234.567,89"}

iex> Localize.Number.to_string(1234567.89, locale: :hi)
{:ok, "12,34,567.89"}

Formatting types

Standard decimal

The default format. Uses the locale's standard pattern with grouping separators and decimal point:

iex> Localize.Number.to_string(1234567.89)
{:ok, "1,234,567.89"}

Percentage

Multiplies by 100 and appends the locale's percent sign:

iex> Localize.Number.to_string(0.456, format: :percent)
{:ok, "46%"}

Scientific notation

Formats in exponential notation:

iex> Localize.Number.to_string(1234567.89, format: :scientific)
{:ok, "1.23456789E6"}

Currency

Formats with a currency symbol, using the currency's standard decimal places. Specifying :currency automatically selects the currency format pattern:

iex> Localize.Number.to_string(1234.56, currency: :USD)
{:ok, "$1,234.56"}

iex> Localize.Number.to_string(1234.56, currency: :EUR, locale: :de)
{:ok, "1.234,56 €"}

iex> Localize.Number.to_string(1234.56, currency: :JPY, locale: :ja)
{:ok, "¥1,234.56"}

Accounting

Like currency but wraps negative values in parentheses instead of using a minus sign:

iex> Localize.Number.to_string(1234.56, format: :accounting, currency: :USD)
{:ok, "$1,234.56"}

iex> Localize.Number.to_string(-1234.56, format: :accounting, currency: :USD)
{:ok, "($1,234.56)"}

Short (compact) formats

Abbreviate large numbers with magnitude suffixes:

iex> Localize.Number.to_string(1234567, format: :decimal_short)
{:ok, "1M"}

iex> Localize.Number.to_string(1234567, format: :decimal_short, locale: :de)
{:ok, "1 Mio."}

iex> Localize.Number.to_string(1234567, format: :decimal_short, locale: :ja)
{:ok, "123万"}

iex> Localize.Number.to_string(1234567, format: :currency_short, currency: :USD)
{:ok, "$1M"}

Long (word) formats

Spell out the magnitude in words:

iex> Localize.Number.to_string(1234567, format: :decimal_long)
{:ok, "1 million"}

iex> Localize.Number.to_string(1234567, format: :decimal_long, locale: :de)
{:ok, "1 Millionen"}

iex> Localize.Number.to_string(1234567, format: :currency_long, currency: :USD)
{:ok, "1,234,567 US dollars"}

Rule-based number formatting (RBNF)

Algorithmic formatting using named rule sets. Used for spellout, ordinals, Roman numerals, and other systems that cannot be expressed as simple patterns:

iex> Localize.Number.Rbnf.to_string(123, :spellout_cardinal, locale: :en)
{:ok, "one hundred twenty-three"}

iex> Localize.Number.Rbnf.to_string(42, :spellout_ordinal, locale: :en)
{:ok, "forty-second"}

iex> Localize.Number.Rbnf.to_string(2024, "roman_upper", locale: :und)
{:ok, "MMXXIV"}

Available RBNF rules vary by locale. Query them with:

iex> {:ok, rules} = Localize.Number.Rbnf.rule_names_for_locale(:en)
iex> rules
["digits_ordinal", "spellout_cardinal", "spellout_cardinal_verbose",
 "spellout_numbering", "spellout_numbering_verbose", "spellout_numbering_year",
 "spellout_ordinal", "spellout_ordinal_verbose"]

The root locale (:und) provides universal rules like roman_upper, roman_lower, hebrew, ethiopic, greek_upper, greek_lower, armenian_upper, armenian_lower, cyrillic_lower, georgian, and tamil.

Range formatting

Format numeric ranges with locale-appropriate separators:

iex> Localize.Number.to_range_string(3, 5)
{:ok, "3–5"}

iex> Localize.Number.to_range_string(1000, 5000)
{:ok, "1,000–5,000"}

iex> Localize.Number.to_range_string(3, 5, locale: :ja)
{:ok, "3~5"}

Related functions for approximate and bounded values:

iex> Localize.Number.to_approximately_string(42)
{:ok, "~42"}

iex> Localize.Number.to_at_least_string(100)
{:ok, "100+"}

iex> Localize.Number.to_at_most_string(50)
{:ok, "≤50"}

How format resolution works

When you call to_string/2, the :format option determines which CLDR pattern is used. The resolution follows these rules:

Standard format names

Atom values map to named patterns in the locale's number format data:

Format atomPattern (English)Description
:standard#,##0.###Default decimal format.
:currency¤#,##0.00Currency with symbol prefix.
:accounting¤#,##0.00;(¤#,##0.00)Parentheses for negative.
:percent#,##0%Percentage.
:scientific#E0Scientific notation.
:decimal_short(magnitude table)Compact form: "1M", "2K".
:decimal_long(magnitude table)Word form: "1 million".
:currency_short(magnitude table)Compact currency: "$1M".
:currency_long(pattern)Pluralized: "123 US dollars".

Custom format patterns

Pass a pattern string directly:

iex> Localize.Number.to_string(1234.5, format: "#,##0.00")
{:ok, "1,234.50"}

iex> Localize.Number.to_string(1234.5, format: "0.000")
{:ok, "1234.500"}

iex> Localize.Number.to_string(42, format: "000")
{:ok, "042"}

Currency auto-selection

When the :currency option is set and :format is :standard (or omitted), the format is automatically changed to :currency. You only need to explicitly set format: :accounting if you want accounting-style negatives:

iex> # These are equivalent:
iex> Localize.Number.to_string(42, currency: :USD)
{:ok, "$42.00"}

iex> Localize.Number.to_string(42, format: :currency, currency: :USD)
{:ok, "$42.00"}

RBNF rule names

For rule-based formatting, use Localize.Number.Rbnf.to_string/3 with a rule name atom:

iex> Localize.Number.Rbnf.to_string(42, :spellout_cardinal, locale: :en)
{:ok, "forty-two"}

Format patterns

CLDR format patterns are strings that describe how a number should be rendered. Understanding them is useful when customising output or reading locale data.

Pattern structure

A pattern has the form positive_pattern;negative_pattern. If the negative pattern is omitted, the positive pattern is prefixed with the locale's minus sign for negative values.

Example: ¤#,##0.00;(¤#,##0.00) — positive values use ¤#,##0.00, negative values are wrapped in parentheses.

Symbol reference

SymbolMeaningExample
0Digit, show zero if absent.0.00 → "1.50"
#Digit, omit if zero.#.## → "1.5"
.Decimal separator (locale-specific).#.## → "1,5" (German)
,Grouping separator (locale-specific).#,##0 → "1,234"
EExponent separator.0.###E0 → "1.235E3"
%Multiply by 100 and show percent sign.#% → "46%"
Multiply by 1000 and show per-mille sign.#‰ → "456‰"
¤Currency symbol placeholder.¤#,##0 → "$1,234"
¤¤ISO currency code.¤¤#,##0 → "USD1,234"
;Separates positive and negative subpatterns.
+Plus sign in exponent.0E+0 → "1E+3"
-Minus sign.
@Significant digit.@@## → "12.34"

Grouping

The grouping pattern is read from right to left in the integer part. #,##0 means groups of 3. The Indian pattern #,##,##0 means the first group is 3 digits and subsequent groups are 2 digits, producing "12,34,567" for Hindi.

Currency symbol

The ¤ placeholder is replaced at format time with the currency symbol. The placement varies by locale — English puts it before the number ($1,234), German puts it after with a non-breaking space (1.234 €).

Number systems

CLDR defines two categories of number systems:

  • Numeric — a set of 10 digit characters. The formatter transliterates Latin digits (0–9) to the target script's digits. Examples: :latn (0123456789), :arab (٠١٢٣٤٥٦٧٨٩), :thai (๐๑๒๓๔๕๖๗๘๙).

  • Algorithmic — numbers are formatted by rules, not digit substitution. Examples: :roman (Roman numerals), :hans (Chinese ideographs). Algorithmic systems use RBNF.

Each locale defines up to four number system types: :default, :native, :traditional, and :finance. For most Western locales, all four resolve to :latn.

iex> Localize.Number.System.number_systems_for(:ar)
{:ok, %{default: :arab, native: :arab}}

iex> Localize.Number.to_string(1234, number_system: :arab, locale: :ar)
{:ok, "١٬٢٣٤"}

How locale influences formatting

Locale-specific patterns

Different locales use different format patterns, grouping rules, and symbols:

LocaleStandard patternDecimalGroupingExample
:en#,##0.###.,1,234,567.89
:de#,##0.###,.1.234.567,89
:fr#,##0.###, (narrow no-break space)1 234 567,89
:hi#,##,##0.###.,12,34,567.89
:ja#,##0.###.,1,234,567.89

Unicode extension keys

BCP 47 locale identifiers can encode number formatting preferences:

-u-nu- — number system:

iex> Localize.Number.to_string(1234, locale: "ar-u-nu-arab")
{:ok, "١٬٢٣٤"}

iex> Localize.Number.to_string(1234, locale: "th-u-nu-thai")
{:ok, "๑,๒๓๔"}

-u-cu- — currency:

When a validated locale has a -u-cu- extension and :currency is not explicitly provided in options, the locale's currency is used.

Locale-specific compact forms

Short and long formats vary dramatically by locale. Japanese uses 万 (ten-thousand) and 億 (hundred-million) instead of the Western K/M/B:

iex> Localize.Number.to_string(1234567, format: :decimal_short, locale: :en)
{:ok, "1M"}

iex> Localize.Number.to_string(1234567, format: :decimal_short, locale: :ja)
{:ok, "123万"}

iex> Localize.Number.to_string(1234567, format: :decimal_short, locale: :de)
{:ok, "1 Mio."}

Options reference

All options accepted by Localize.Number.to_string/2:

OptionTypeDefaultDescription
:localeatom, string, or LanguageTagLocalize.get_locale()Locale for patterns, symbols, and plural rules.
:formatatom or pattern string:standardFormat style or custom pattern. See format table above.
:currencyatomnilISO 4217 currency code. Automatically selects currency format.
:number_systematom:defaultNumber system name (:latn, :arab, etc.) or type (:default, :native).
:fractional_digitsintegernilSet both min and max fractional digits.
:min_fractional_digitsintegernilMinimum trailing zeros after decimal. Overrides :fractional_digits for the minimum.
:max_fractional_digitsintegernilMaximum decimal digits (rounds to this). Overrides :fractional_digits for the maximum.
:maximum_integer_digitsintegernilMaximum integer digits to display.
:rounding_modeatom:half_evenOne of :down, :up, :half_up, :half_down, :half_even, :ceiling, :floor.
:round_nearestintegernilRound to nearest increment (e.g., 5 for rounding to nearest 5).
:minimum_grouping_digitsinteger0Minimum integer digits before grouping is applied.
:currency_symbolatom or stringnilOverride currency symbol display: :symbol, :narrow, :iso, or a custom string.
:currency_digitsatom:accountingHow to determine currency decimal places: :accounting, :cash, or :iso.
:wrapperfunctionnilfn string, type -> string end — wrap formatted components for HTML/markup.

Fractional digit examples

iex> Localize.Number.to_string(1234.5, fractional_digits: 4)
{:ok, "1,234.5000"}

iex> Localize.Number.to_string(1234.56789, max_fractional_digits: 2)
{:ok, "1,234.57"}

iex> Localize.Number.to_string(1234.5, min_fractional_digits: 3)
{:ok, "1,234.500"}

iex> Localize.Number.to_string(1234.56789, min_fractional_digits: 1, max_fractional_digits: 3)
{:ok, "1,234.568"}

Rounding mode examples

iex> Localize.Number.to_string(2.5, fractional_digits: 0, rounding_mode: :half_even)
{:ok, "2"}

iex> Localize.Number.to_string(2.5, fractional_digits: 0, rounding_mode: :half_up)
{:ok, "3"}

iex> Localize.Number.to_string(2.5, fractional_digits: 0, rounding_mode: :ceiling)
{:ok, "3"}

Performance and optimization

to_string/2 accepts either a keyword list or a pre-validated Localize.Number.Format.Options struct. The keyword list path resolves the number system, loads format patterns, resolves currency data, and builds metadata on every call. Locale validation itself is cached in ETS and is fast (~1µs), but the remaining options resolution — format pattern lookup, currency data loading, symbol resolution — still adds measurable overhead, especially for currency formatting.

For high-throughput formatting (rendering a table of thousands of numbers, batch processing), call Localize.Number.Format.Options.validate_options/2 once to build an options struct, then pass it to to_string/2 for each number. The first argument is a representative number (use 0 for a positive-number format):

iex> alias Localize.Number.Format.Options
iex> {:ok, options} = Options.validate_options(0, locale: :en, currency: :USD)

iex> # Reuse for many calls — bypasses all options resolution
iex> {:ok, _} = Localize.Number.to_string(1234.56, options)
iex> {:ok, _} = Localize.Number.to_string(9876.54, options)

Performance comparison

Benchmarks on a typical development machine (Apple Silicon):

ApproachSimple decimalCurrency
Keyword options~7 µs/call~300 µs/call
Normalized Options struct~2 µs/call~6 µs/call
Speedup~3x~50x

The difference is largest for currency formatting because options resolution must load currency metadata (symbols, decimal places, spacing rules) in addition to the standard format pattern. With normalized options, currency formatting drops from ~300µs to ~6µs.

For simple decimal formatting the keyword path is already fast (~7µs) thanks to the locale validation cache, and the normalized path is ~2µs. The difference is small enough that keyword options are fine for most use cases.

When to use Options.validate_options/2:

  • Formatting many numbers with the same locale and format (reports, tables, batch processing).

  • Currency formatting in hot loops — the 50x speedup is significant.

  • Server-side rendering where latency matters.

When keyword options are fine:

  • One-off formatting calls.

  • Simple decimal formatting (already ~7µs with keywords).

  • When the locale or format changes between calls.

Optional NIF

When the NIF is enabled (LOCALIZE_NIF=true), Localize.Nif.number_format/3 provides ICU4C-based number formatting:

Localize.Nif.number_format(1234.56, "en-US", currency: "USD")
#=> {:ok, "$1,234.56"}

NIF vs Elixir differences

The NIF uses ICU4C's icu::number::NumberFormatter, which may produce slightly different output in edge cases:

  • Narrow no-break space handling may differ in some locales.

  • Short/long compact format abbreviations may use different rounding.

  • Some locale-specific patterns (like Indian grouping) may handle large numbers differently.

The NIF is primarily useful for cross-validation and for algorithms like collation sort-key generation where C performance is critical. For number formatting, the pre-built Options struct approach already delivers sub-10µs latency in pure Elixir.

NIF availability

iex> Localize.Nif.available?()
false  # unless compiled with LOCALIZE_NIF=true

Number parsing

Localize.Number also provides locale-aware parsing from formatted strings back to numbers:

iex> Localize.Number.parse("1,234.56")
{:ok, 1234.56}

iex> Localize.Number.scan("The price is $1,234.56 per unit")
["The price is ", 1234.56, " per unit"]

Parsing respects locale-specific separators and can resolve embedded currency symbols and percent signs. See Localize.Number.parse/2 and Localize.Number.scan/2 for details.