Modern, GNU gettextβcompatible internationalization (i18n) for Erlang/OTP β in pure Erlang.
Why erli18n?
It's first-class
gettexti18n for Erlang/OTP, natively β no polyglot build, no routing through Elixir, no stalled dependency.
- π¦ Drop-in
.po/.potβ load the exact files your translators already produce in Poedit, Crowdin, Transifex, Weblate, orxgettext.- π Real CLDR pluralization β a true
Plural-Formsevaluator backed by the upstream CLDR plural rules, inlined for offline use.- β‘ Copy-free lookups β reads run straight from
persistent_termin your own process, with no copy onto the caller heap and no lock; only writes go through agen_server. No bottleneck on the hot path.- π Per-request localization for Cowboy & Elli β optional
erli18n_cowboy/erli18n_ellimiddleware negotiate the request locale (query β cookie βAccept-Language) and set it before your handler, so handlers translate with no locale argument. Both are optional liketelemetry: not pulled into the publishedkernel+stdlibbuild.
Quickstart
Add the dependency to rebar.config:
{deps, [{erli18n, "~> 0.7"}]}.Then load a catalog and translate:
application:ensure_all_started(erli18n).
%% Load a `.po` catalog for a (domain, locale). Parse -> compile plural rule ->
%% validate against CLDR -> insert: one atomic step. Returns {ok, NewlyLoaded}
%% (or {ok, already} if it was already loaded).
{ok, _Loaded} = erli18n_server:ensure_loaded(my_domain, <<"pt_BR">>,
<<"priv/locale/pt_BR/LC_MESSAGES/my_domain.po">>).
%% Singular.
<<"OlΓ‘, mundo">> = erli18n:gettext(my_domain, <<"Hello, world">>, <<"pt_BR">>).
%% Plural. ngettext returns the correct plural FORM for N (it selects the
%% form; the `f` family below splices the number in β see "Interpolation").
<<"arquivo">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 1, <<"pt_BR">>).
<<"arquivos">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 42, <<"pt_BR">>).
%% Contextual. The same source word, disambiguated by a msgctxt.
<<"Maio">> = erli18n:pgettext(my_domain, <<"month">>, <<"May">>, <<"pt_BR">>).
<<"pode">> = erli18n:pgettext(my_domain, <<"verb">>, <<"May">>, <<"pt_BR">>).
%% Interpolating. The `f`-suffix family resolves the translation, then
%% splices named `%{var}` placeholders from a Bindings map (see below).
<<"3 arquivos">> = erli18n:ngettextf(my_domain, <<"%{count} file">>,
<<"%{count} files">>, 3, <<"pt_BR">>, #{}). %% count => 3 auto-boundThat is the whole surface: gettext (singular), ngettext (plural), pgettext (contextual), and npgettext (contextual + plural), each with d / dc domain-explicit variants β the full GNU gettext C-macro family, as Erlang functions. Each also has an interpolating f-suffix sibling (gettextf, ngettextf, pgettextf, npgettextf) that splices named %{var} values into the resolved string.
Common patterns
Set the locale once per process (e.g. one web request) β then every lookup in that process uses it, with no locale argument to thread around. App-wide, set_default_locale/1 does the same for processes that never call setlocale/1:
erli18n:setlocale(<<"pt_BR">>), %% this process
%% erli18n:set_default_locale(<<"pt_BR">>), %% (or: app-wide default)
<<"OlΓ‘, mundo">> = erli18n:gettext(my_domain, <<"Hello, world">>),
<<"arquivos">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 42).Set a default domain so the shortest forms work without naming it each time:
erli18n:textdomain(my_domain),
<<"OlΓ‘, mundo">> = erli18n:gettext(<<"Hello, world">>). %% default domain + resolved localeFormat a pluralized count β use the f-suffix ngettextf: it selects the plural form and splices the number in. The count is auto-bound as %{count}, so the translator controls where the number lands in each language:
%% Source: msgid "%{count} file" / msgid_plural "%{count} files"
%% pt_BR: msgstr[0] "%{count} arquivo" / msgstr[1] "%{count} arquivos"
<<"3 arquivos">> = erli18n:ngettextf(my_domain,
<<"%{count} file">>, <<"%{count} files">>, 3, <<"pt_BR">>, #{}).Context + plural together (npgettext β domain, context, singular, plural, N, locale):
<<"comentΓ‘rios">> = erli18n:npgettext(my_domain, <<"ui">>,
<<"comment">>, <<"comments">>, 5, <<"pt_BR">>).Load several catalogs at startup in one batch:
%% Each entry is {Domain, Locale, PoPath, Opts}; the result is one
%% {Domain, Locale, {ok, NewlyLoaded} | {ok, already} | {error, _}} per entry.
Results = erli18n_server:ensure_loaded_many([
{my_domain, <<"pt_BR">>, <<"priv/locale/pt_BR/LC_MESSAGES/my_domain.po">>, #{}},
{my_domain, <<"en_US">>, <<"priv/locale/en_US/LC_MESSAGES/my_domain.po">>, #{}}
]).Observe at runtime with telemetry (optional) β for example, get notified whenever a lookup falls through to the source string:
telemetry:attach(<<"erli18n-misses">>, [erli18n, lookup, miss],
fun(_Event, _Measurements, Metadata, _Config) ->
logger:info("i18n miss: ~p", [Metadata])
end, undefined).Interpolation
Every lookup family has an interpolating f-suffix sibling β gettextf, ngettextf, pgettextf, npgettextf (plus the d / dc aliases) β that takes a trailing Bindings :: map(). Each f function resolves the translation exactly like its non-f sibling, then substitutes named %{var} placeholders in the result:
erli18n:setlocale(<<"pt_BR">>),
%% Source msgid "Hello, %{name}!" with pt_BR msgstr "OlΓ‘, %{name}!"
<<"OlΓ‘, Ada!">> = erli18n:gettextf(my_domain, <<"Hello, %{name}!">>,
#{name => <<"Ada">>}).Named placeholders (rather than positional ~s) decouple the wording from argument order: a translator can move %{name} anywhere in the sentence β or repeat it β and the binding still resolves by name. Binding keys are atoms; values may be a binary, an iolist/string, an integer, a float, or an atom, and are coerced to UTF-8 text. Plural members auto-bind count => N, so %{count} is always available without passing it yourself (a caller-supplied count wins):
%% pt_BR msgstr[1] "%{count} arquivos" β count auto-bound to 42
<<"42 arquivos">> = erli18n:ngettextf(my_domain,
<<"%{count} file">>, <<"%{count} files">>, 42, <<"pt_BR">>, #{}).Escaping. A literal percent is %%; to emit a literal %{name} un-substituted, write %%{name} (the %% collapses to %, leaving {name} untouched):
<<"100% sure">> = erli18n:gettextf(<<"100%% sure">>, #{}).
<<"use %{name}">> = erli18n:gettextf(<<"use %%{name}">>, #{name => <<"X">>}).Missing bindings β lenient vs strict. The f functions on erli18n are lenient: an unbound %{name} is left in place literally and nothing crashes. Interpolation is total and fail-soft β for any input and any bindings it returns a binary and never raises. When you want an unbound placeholder to be a hard error instead, call erli18n_interp:format/3 directly with the strict policy:
%% Lenient (the f-family default): unknown placeholder stays literal.
<<"Hi %{who}">> = erli18n:gettextf(<<"Hi %{who}">>, #{}).
%% Strict: opt in via erli18n_interp:format/3 β raises on a missing binding.
erli18n_interp:format(<<"Hi %{who}">>, #{}, #{on_missing => strict}).
%% ** exception error: {erli18n_interp, {missing_binding, who}}Bidi / RTL caveat
Interpolation does not auto-insert Unicode bidi isolation marks (U+2066βU+2069) around spliced values. Placing an RTL value (Arabic, Hebrew) into an LTR sentence β or the reverse β can reorder neighboring punctuation under the Unicode Bidirectional Algorithm. If you mix directions, isolate the values yourself.
Locale negotiation & fallback (opt-in)
Catalogs are keyed by exact binary, so by default a pt_BR request only matches a pt_BR catalog. Two opt-in pieces close the common gaps β without changing the default exact-match behavior or touching the copy-free hot path.
1. Request-time negotiation (erli18n_negotiate, exposed on the facade). Pick the best locale a client supports from those you have loaded. parse_accept_language/1 turns an HTTP header into a priority-ordered list; negotiate/2 resolves it (with BCP-47 canonicalization and base-language fallback) against your available set, always returning a usable locale:
Available = [<<"en">>, <<"pt">>, <<"de">>],
%% Hyphenated, mixed-case, and legacy tags all canonicalize to match.
{ok, <<"pt">>} = erli18n:negotiate([<<"pt-BR">>], Available),
%% Straight from an Accept-Language header (q-values honored, q=0 dropped).
{ok, <<"de">>} = erli18n:negotiate(
erli18n:parse_accept_language(<<"fr-CH, de;q=0.9, en;q=0.5">>),
Available),
%% One-off tag canonicalization to the catalog-key shape.
<<"pt_BR">> = erli18n:canonicalize_locale(<<"PT-br.UTF-8">>).A typical web handler negotiates once per request and calls setlocale/1:
Prefs = erli18n:parse_accept_language(AcceptLanguageHeader),
{ok, Locale} = erli18n:negotiate(Prefs, my_supported_locales()),
erli18n:setlocale(Locale).2. Lookup-time fallback chain (erli18n.locale_fallback, default off). When enabled, a lookup that misses the exact locale walks a canonicalization-aware BCP-47 chain before falling back to the msgid, so a pt_BR user reads a loaded pt catalog:
%% Only a "pt" catalog is loaded.
<<"Hello">> = erli18n:gettext(my_domain, <<"Hello">>, <<"pt_BR">>), %% off: raw msgid
erli18n:set_locale_fallback(base_language),
<<"OlΓ‘"/utf8>> = erli18n:gettext(my_domain, <<"Hello">>, <<"pt_BR">>). %% pt_BR -> ptlocale_fallback accepts off (default), base_language (pt_BR β pt β default_locale), or {explicit, Map} where Map :: #{locale() => [locale()]} overrides specific locales (unlisted ones fall through to base_language). The chain runs only on a miss and only when enabled, so an exact hit stays a single copy-free persistent_term:get with zero added cost. Canonicalization covers separator (pt-BR/pt_BR) and case normalization plus a closed legacy-alias set (iwβhe, inβid, jiβyi, jwβjv, moβro). Scriptβregion Likely Subtags inference (zh_Hans β zh_CN) is an explicit non-goal β load catalogs under the keys your clients send, or use an {explicit, Map}.
Per-request localization (Cowboy / Elli)
Two optional adapters wire that per-request negotiation into a web framework so you stop hand-rolling it: erli18n_cowboy (a cowboy_middleware) and erli18n_elli (an elli_middleware), both built on the pure, framework-agnostic core erli18n_http (negotiate_locale/3, negotiate_locale_lazy/4, cookie_value/2, and query_value/2), which you can also call directly when wiring a framework the adapters don't cover. Both negotiate the locale from the request and call setlocale/1 before your handler runs, so handlers translate with no locale argument. cowboy/elli are optional like telemetry β neither is a dependency of the published package, which still runs on kernel + stdlib alone; you add whichever framework you already use.
%% Cowboy: install the middleware ahead of the handler.
Dispatch = cowboy_router:compile([{'_', [{"/[...]", my_handler, []}]}]),
cowboy:start_clear(http, [{port, 8080}], #{
env => #{dispatch => Dispatch, erli18n => #{cookie_name => <<"locale">>}},
middlewares => [erli18n_cowboy, cowboy_router, cowboy_handler]
}).The default precedence is query string > cookie > Accept-Language header > default (i18next's order; Django's "explicit beats persisted beats browser-preferred" spirit), configurable per request. The available set defaults to erli18n:loaded_locales/0 β the distinct locales you have actually loaded, the authoritative thing to negotiate against β and the default to default_locale/0.
The query seam is total and fail-soft on both adapters: each adapter feeds the raw query binary (Cowboy's cowboy_req:qs/1, Elli's elli_request:query_str/1 β both total, never raising) to the single core extractor erli18n_http:query_value/2, which percent-decodes the matched value itself. A value-less key (?locale) and a malformed percent-escape (?locale=%ZZ, bare ?%, ?locale=%E0%) are simply skipped, never crashing the request. Per-request option values are equally fail-soft: a malformed default or available falls back to the documented default (default_locale/0 / loaded_locales/0) with a one-time logger:warning, so an operator misconfiguration is observable rather than request-fatal.
Mind the spawn boundary. As above, the locale is per-process and is not inherited across a spawn. Cowboy and Elli run the middleware and handler in one request process, so the handler sees it β but any cross-process handoff (a pooled worker, a shared gen_server, a Task-style spawn, a Cowboy stream handler that offloads) starts at which_locale() = undefined. Capture Locale = erli18n:which_locale() and re-setlocale/1 it in the worker, or pass it explicitly. The adapters also set logger process metadata #{locale => L} by default so request logs carry it. The erli18n_cowboy module docs cover the full hazard, the mitigations, and a Phoenix interop note (no Elixir dependency).
Core concepts
A few things worth knowing before you reach for the API:
- Locale is per-process.
erli18n:setlocale(<<"pt_BR">>)sets the locale for the calling process (stored in its process dictionary);which_locale/0reads it back. It is not inherited by processes youspawn. When a process hasn't set one, lookups fall back to the application-wide default. Passing the locale explicitly always wins. - Catalogs are keyed by domain + locale. A domain is a gettext text domain (e.g.
my_domain) β your way of grouping translations. You load each(domain, locale)catalog once; lookups then target a domain explicitly or use the default. - The
.poheader drives pluralization. Each catalog'sPlural-Formsheader is the runtime source of truth for plural selection. CLDR rules (inlined as a static table that tracks the upstream GNU gettext / CLDR data) are consulted only at load time β to emit a telemetry warning when a header diverges from CLDR, never to override it. - Misses degrade gracefully. A lookup with no catalog, no entry, or an empty translation returns the original
msgid(ormsgid_plural), so your UI never shows a blank. And a crash of the catalog server does not wipe loaded translations: catalogs live inpersistent_term, which is owned by the runtime (not by the server process), so they survive a server crash untouched β no dedicated table owner or heir is needed.
Why erli18n
Most Erlang projects today either reach for the venerable but largely-stalled gettexter, or route strings through Elixir's gettext (which forces a polyglot build). erli18n is for projects that want first-class i18n in pure Erlang/OTP without giving up compatibility with the standard gettext translation tooling.
- Drop-in
.po/.potcompatibility β a hand-written parser that handles real-world catalogs: contexts, plurals, fuzzy entries, charsets, BOMs, and obsolete entries. Works with Poedit, Crowdin, Transifex, Weblate, andmsgfmtout of the box. (The exact.po-semantics decisions are documented inCHANGELOG.md.) Theerli18n_pomodule exposes this read/serialize surface as public API βparse/1,2,parse_file/1,2,dump/1, andescape_string/1(the five GNU gettext PO escapes β backslash, double-quote, newline, tab, carriage return β applied exactly asdump/1does).escape_string/1is published so the separaterebar3_erli18nplugin can serialize the PO metadata it owns byte-identically todump/1across the{deps, [erli18n]}boundary, instead of vendoring a duplicate escaper. - CLDR-backed pluralization β a real evaluator for the
Plural-FormsC-expression, with the upstream CLDR plural rules inlined for offline use. - The full gettext API β
gettext/ngettext/pgettext/npgettext, plus thed/dcdomain-explicit variants, and an interpolatingf-suffix family (gettextf, β¦) for named%{var}substitution. - Optional, first-class observability β 8
telemetryevents (catalog load/reload/unload spans, lookup misses, fuzzy-entry skips, locale fallback, plural divergence, rate-limited memory warnings).telemetryis an optional dependency: events fire only when your app ships it. - A copy-free hot path β
lookup_*reads run directly frompersistent_termin the calling process, with no copy onto the caller heap and no lock; only writes (loading and reloading catalogs) go through the owninggen_server. No process bottleneck on the read side. A reload or unload defers a one-time, node-widepersistent_termliteral-area GC, paid once per write and negligible for the load-once workload. - Heavily tested β Common Test suites, PropEr property-based tests, fuzzing, and a parity suite that checks output byte-for-byte against GNU
msgfmtas a ground-truth oracle. 100% behavioral coverage.
String extraction is handled by the companion rebar3 plugin, rebar3_erli18n β an Erlang-native extractor that walks your source's abstract forms and recognizes the full facade family by name and arity, producing .pot templates (the mix gettext.extract experience for Erlang). It is shipped as a separate Hex package that depends on this library ({deps, [{erli18n, "~> 0.7"}]}); consumers opt in with {plugins, [rebar3_erli18n]} and gain rebar3 erli18n {extract,merge,check,report,compile}. Only compile-time-literal msgids are extracted (runtime-computed keys still translate, they just aren't discovered statically) β the same model and the same caveat as Elixir's Gettext. The plugin also offers an opt-in compile provider that bakes catalogs into BEAM carriers plus an opt-in compile-time key-existence check (rebar3 erli18n compile); runtime lookup plus the check freshness gate remains the default. (The plugin is published as its own Hex package, after this library; see apps/rebar3_erli18n/README.md.)
Compiled catalogs (opt-in). By default catalogs load at runtime from .po files with erli18n:ensure_loaded/3,4. As an opt-in alternative, the rebar3_erli18n plugin can bake each catalog β already parsed, with its Plural-Forms rule already compiled β into a generated BEAM module, and erli18n:register_compiled_catalogs/1 registers them at boot with no .po parse and no plural compile (the install cost remains; it is no parse / no compile at startup, not zero-load). Call it once in your app's start/2, before the supervision tree. See the "Compiled catalogs" section of the erli18n module docs and the plugin README.
Installation
{deps, [
{erli18n, "~> 0.7"}
]}.For telemetry observability (optional β erli18n runs fine without it), add it too:
{deps, [
{erli18n, "~> 0.7"},
{telemetry, "~> 1.3"}
]}.For the optional per-request adapters, add the web framework you already use to your application's {deps} β erli18n does not pull cowboy or elli in (they are optional like telemetry), so the published library keeps building on kernel + stdlib alone:
{deps, [
{erli18n, "~> 0.7"},
{cowboy, "~> 2.13"} %% or: {elli, "~> 3.3"}
]}.Compatibility
| OTP 27 (minimum) | OTP 28 | OTP 29 | |
|---|---|---|---|
| Tier-1 (CI) | β | β | β |
OTP 27 is the floor because the public modules use the native -doc / -moduledoc documentation attributes (EEP-59), which only compile on OTP 27+; on OTP 25.3 / 26 the compiler rejects them with attribute doc after function definitions. CI exercises OTP 27, 28, and 29 β all Tier-1 β on every push to main and every pull request.
Status
Initial development (0.7.0). Per SemVer 2.0.0 Β§4, the public API is functional but may change on a minor bump (0.7.0 β 0.8.0); patch bumps (0.7.0 β 0.7.1) stay backward-compatible. The criteria for a stable 1.0.0 are in CHANGELOG.md.
Documentation
- API reference β published on HexDocs, generated from the native
-doc/-moduledocattributes (OTP 27+ documentation). Every public module and function is documented there. - Changelog & design decisions β
CHANGELOG.mdrecords each release, the versioning policy, and the.po-semantics and pluralization decisions behind the implementation. - Examples β the
.pofixtures underapps/erli18n/test/cover plural forms, contexts, fuzzy entries, encodings, and edge cases β a practical reference for whaterli18naccepts. A runnable downstream consumer lives underexamples/erli18n_demo/, with realgettextcall sites and committed catalogs. Two runnable middleware examples show per-request locale negotiation end to end:examples/erli18n_cowboy_demo/(Cowboy) andexamples/erli18n_elli_demo/(Elli).
Development
git clone git@github.com:eagle-head/erli18n.git
cd erli18n
rebar3 compile
bin/quality-gate.sh --fast # ~30s: compile + xref + erlfmt + elvis + hank + elp lint + actionlint
bin/quality-gate.sh --full # ~5min: + dialyzer + eqwalize-all + Common Test (+ coverage) + gettext parity
See CONTRIBUTING.md for the full setup: toolchain pinning with mise, git hooks, local CI emulation with act, and the contribution workflow.
Security
To report a vulnerability, see SECURITY.md β please do not open a public GitHub issue for security reports.
License
Apache License 2.0 (SPDX: Apache-2.0).
References
- GNU gettext manual β
.poformat and runtime semantics. - Unicode CLDR plural rules β pluralization data source.
telemetryβ the observability framework.gettexterβ historical Erlang gettext library whose API surfaceerli18nmirrors for easy migration.