This guide compares the performance of Localize's pure Elixir implementation against the optional NIF (Native Implemented Functions) backend, and provides guidance on when each is appropriate.
Overview
Localize ships with two execution backends:
Pure Elixir (default) — no compilation dependencies, works everywhere Elixir runs. Uses CLDR data loaded from ETF files and cached in
:persistent_term.Optional NIF — binds to ICU4C via Erlang NIFs. Requires ICU4C to be installed at compile time. Enable with
config :localize, :nif, trueorLOCALIZE_NIF=true.
The NIF is not a drop-in replacement for all operations. Some operations are faster with the NIF, some are faster in pure Elixir, and some are comparable. The NIF overhead of crossing the Erlang/C boundary means that lightweight operations with pre-cached data are often faster in pure Elixir.
Benchmark Results
All benchmarks run on Apple M4 Max, Elixir 1.19.5, OTP 28, CLDR 48.2. Times are median per-call over 10,000+ iterations after warmup. The figures have not been re-measured against CLDR 49; the data changed but the code paths they exercise did not, so they are indicative rather than current.
MessageFormat 2
MF2 formatting involves parsing, function dispatch, and string assembly. The NIF delegates to ICU4C's MessageFormatter which handles all three in a single C call.
| Operation | Elixir | NIF | Winner |
|---|---|---|---|
Simple message ("Hello {$name}!") | 23 µs | 11 µs | NIF 2.1× |
Complex message (.match + plural) | 125 µs | 73 µs | NIF 1.7× |
| Parse only (validate) | 10 µs | 7 µs | NIF 1.5× |
Recommendation: Use the NIF for MF2-heavy workloads. The NIF advantage grows with message complexity because ICU4C handles the entire pipeline in C, avoiding repeated Erlang↔Elixir boundary crossings for nested expressions.
Number Formatting
Number formatting in Elixir uses pre-compiled format metadata cached in :persistent_term, making it very fast. The NIF must convert Elixir terms to C types, call ICU, and convert back.
| Operation | Elixir | NIF | Winner |
|---|---|---|---|
Decimal (1234567.89) | 5 µs | 10 µs | Elixir 2× |
Currency ($1,234.56) | 11 µs | 13 µs | Elixir 1.1× |
Percent (56%) | 4 µs | 9 µs | Elixir 2× |
Recommendation: Pure Elixir is faster for number formatting. The pre-compiled format metadata and :persistent_term caching eliminate the overhead that the NIF would need to offset.
Plural Rules
Plural rule evaluation determines the plural category (:one, :other, etc.) for a number in a given locale. The Elixir implementation uses generated function clauses from CLDR plural rule definitions.
| Operation | Elixir | NIF | Winner |
|---|---|---|---|
Cardinal rule (1234, "en") | 1.9 µs | 2.9 µs | Elixir 1.5× |
Recommendation: Pure Elixir is faster. The generated pattern-matching clauses are extremely efficient and avoid the NIF boundary crossing overhead.
Unit Formatting
Unit formatting combines number formatting with unit pattern lookup and substitution.
| Operation | Elixir | NIF | Winner |
|---|---|---|---|
Simple unit (100 meters) | 5 µs | 16 µs | Elixir 3× |
Recommendation: Pure Elixir is significantly faster. The Elixir implementation benefits from pre-built locale data and cached format patterns.
Collation (String Sorting)
Collation implements the Unicode Collation Algorithm for locale-sensitive string comparison. This is the most compute-intensive operation and where NIF acceleration typically provides the largest benefit in other libraries. However, Localize's Elixir implementation uses a pre-computed collation table cached in :persistent_term which is very efficient.
| Operation | Elixir | NIF | Winner |
|---|---|---|---|
| Sort 100 ASCII strings | 145 µs | 502 µs | Elixir 3.5× |
| Sort 20 CJK strings | 14 µs | 58 µs | Elixir 4× |
Recommendation: Pure Elixir is substantially faster. The pre-computed collation element table avoids the per-comparison NIF overhead. Each NIF comparison call crosses the Erlang/C boundary, and for sorting (which requires O(n log n) comparisons), this overhead dominates.
When to Use the NIF
Based on the benchmarks, the NIF provides a measurable advantage only for MessageFormat 2 operations:
| Use NIF | Use Elixir |
|---|---|
| MF2 formatting (simple messages) | Number formatting |
| MF2 formatting (complex messages with match/plural) | Currency formatting |
| MF2 validation/parsing | Plural rule evaluation |
| Unit formatting | |
| Collation / string sorting | |
| Date/time formatting |
Why Pure Elixir is Competitive
Localize's Elixir implementation uses several techniques that minimize the advantage of native code:
Pre-compiled format metadata. Number and datetime format patterns are parsed once and cached as Elixir terms in
:persistent_term. Subsequent calls do pattern substitution without re-parsing.Pre-built locale data. Currency structs, number symbols, and format patterns are built at data-generation time and stored as ETF. There is no runtime struct construction.
Collation table in
:persistent_term. The Unicode collation element table (~2 MB) is loaded once and shared across all processes without copying. Each comparison is a series of map lookups, which the BEAM optimizes well.Generated function clauses. Plural rules compile to pattern-matching clauses that the BEAM JIT optimizes to near-native speed.
NIF boundary cost. Each NIF call has a fixed overhead of approximately 1–3 µs for argument marshalling and result conversion. For operations that complete in 5 µs in pure Elixir, this overhead is significant.
Enabling the NIF
To enable the NIF backend:
# config/config.exs
config :localize, :nif, trueOr via environment variable:
export LOCALIZE_NIF=true
The NIF requires ICU4C development headers at compile time. On macOS: brew install icu4c. On Ubuntu: apt install libicu-dev.
You can check NIF availability at runtime:
iex> Localize.Nif.available?()
trueSelecting a Backend
All NIF-capable functions accept a :backend option. The default is always :elixir. When :nif is specified and the NIF is available, the ICU4C implementation is used. If the NIF is not available, it silently falls back to the pure Elixir implementation.
Localize.Message.format/3 passes its bindings to ICU as strings, integers within 64 bits and floats, so a message with any other binding — a date, a Decimal, a unit, a list — is formatted by the Elixir interpreter even when backend: :nif is given.
| Function | :backend option | NIF implementation |
|---|---|---|
Localize.Number.to_string/2 | backend: :nif | ICU4C NumberFormatter |
Localize.Unit.to_string/2 | backend: :nif | ICU4C NumberFormatter (unit) |
Localize.Number.PluralRule.plural_type/2 | backend: :nif | ICU4C PluralRules |
Localize.Message.format/3 | backend: :nif | ICU4C MessageFormat 2 |
Localize.Collation.compare/3 | backend: :nif | ICU4C Collator |
Example:
iex> Localize.Number.to_string(1234.5, locale: :de, backend: :nif)
{:ok, "1.234,5"}
iex> {:ok, unit} = Localize.Unit.new(100, "meter")
iex> Localize.Unit.to_string(unit, format: :short, backend: :nif)
{:ok, "100 m"}Optimizing Pure Elixir Performance
Pre-validate options
For number formatting, pre-validate options once and reuse the struct:
# Validate once
{:ok, options} = Localize.Number.Format.Options.validate_options(0, locale: :en, currency: :USD)
# Use many times — skips option validation on each call
for price <- prices do
Localize.Number.to_string(price, options)
endThis saves approximately 5–10 µs per call by avoiding repeated locale resolution, number system lookup, and symbol loading.
Locale caching
Localize.validate_locale/1 caches results in an ETS table. The first call for a locale takes ~50 µs; subsequent calls take ~1 µs. Call Localize.validate_locale/1 during application startup for locales you know you will need.
Process locale
Set the process locale once rather than passing :locale on every call:
Localize.put_locale(:de)
# All subsequent calls use :de without option resolution
Localize.Number.to_string(1234.5)
Localize.Date.to_string(~D[2025-07-10])Memory
A loaded locale stays in :persistent_term, where every process reads it without copying it. The VM keeps those terms in its literal area, a region whose size is fixed when the VM starts: a gigabyte by default on a 64-bit VM. What Localize keeps there:
A locale — about 1.2 MB each,
en1.6 MB, so all 657 of CLDR's take about 814 MB.A parser's patterns — compiled once for each locale and calendar that text is read in: up to about 80 KB for dates and up to about 300 KB more for intervals.
The collation table — about 15 MB, once, when strings are first collated.
The supplemental data — about 2 MB, and an inflection lexicon for each language that is inflected.
A term the literal area has no room for stops the VM, so Localize asks the VM for the room before it keeps any of these. A locale with no room is not loaded, and the call that needed it returns {:error, %Localize.LiteralMemoryError{}}, as does a call that needs an inflection lexicon. A parser's patterns and the supplemental data are worked out again on each call rather than kept, and a collation table with no room is logged as an error and not loaded.
An application that loads most of CLDR's locales should start its VM with a larger literal area, set in megabytes:
elixir --erl "+MIscs 2048" -S mix phx.server
In a release the flag goes in rel/vm.args.eex, as the line +MIscs 2048.
The room is the VM's own report of the area, :erlang.system_info({:allocator, :erts_mmap}). A VM that does not give one, as a 32-bit VM does not, refuses nothing, and two processes that ask at the same moment may both be told there is room: the check keeps a VM that is running out from stopping, and is no substitute for a literal area of the size the application needs.