English · Italiano
Translatable text columns for PostgreSQL, in plain SQL and PL/pgSQL.
A column holds either a plain string or a JSON object of translations:
name
-----------------------------------
Chair
{"en": "Chair", "it": "Sedia"}
pg_i18n gives you functions to read and write one language out of such a
column, an updatable view layer so an application that only knows about plain
strings keeps working, a migration that turns the whole thing into proper
jsonb, and an optional automation that fills missing languages through
DeepL, Google Translate or any model on OpenRouter.
Pure SQL and PL/pgSQL, no compiled code, no superuser needed. Installable as an extension or as a plain script. Tested on PostgreSQL 14, 16 and 17; needs 9.5+.
Docs site: https://ingmmo.com/pg_i18n/ · Contents: Install · Quick start · Function reference · String-based apps · Migrating to jsonb · Automation · Searching · Behaviour details · Tests
| File | Purpose |
|---|---|
i18n.sql |
core: read/write functions, view layer, migration |
i18n_auto.sql |
automation: config, queue, triggers, worker-side functions |
pg_i18n.control, Makefile |
extension packaging; make install builds pg_i18n--1.0.sql from the two files above |
worker/pg_i18n_worker.py |
translation worker (DeepL, OpenRouter, echo) with Dockerfile and requirements.txt |
test.sql, test.sh, worker/test_worker.sh |
test suite and runners |
make install # uses pg_config from PATH, or PG_CONFIG=/path/to/pg_config make install
psql -d mydb -c 'CREATE EXTENSION pg_i18n'make install only copies two files (pg_i18n.control and the generated
pg_i18n--1.0.sql, which is i18n.sql plus i18n_auto.sql) into
$(pg_config --sharedir)/extension/, so on a host without make you can
copy them by hand. No superuser is required to run
CREATE EXTENSION, only CREATE privilege on the database.
To put the functions in their own schema:
CREATE EXTENSION pg_i18n SCHEMA i18n;The extension is not relocatable: the functions pin the schema they were
installed in (see search_path), so drop and recreate it rather
than ALTER EXTENSION ... SET SCHEMA.
psql -d mydb -f i18n.sql -f i18n_auto.sql # i18n_auto.sql is optional, see AutomationEverything is created in the first schema of the current search_path.
Re-running the file is safe (CREATE OR REPLACE throughout).
Functions that call other pg_i18n functions are declared
SET search_path FROM CURRENT, so they keep working when PostgreSQL 17+
builds indexes and checks constraints under a restricted search_path, and
when the extension lives in a schema that callers do not have on their path.
This means the schema is fixed at install time: install with the intended
schema first on the search_path, or use CREATE EXTENSION ... SCHEMA.
SET i18n.default_lang = 'en'; -- fallback language (default: en)
SET i18n.lang = 'it'; -- language for this session
SELECT i18n_get(name) FROM products;
-- 'Chair' -> Chair (plain string, returned as-is)
-- {"en":"Chair","it":"Sedia"} -> Sedia
UPDATE products SET name = i18n_set(name, 'Sedia rossa') WHERE id = 1;
-- 'Chair' -> {"en": "Chair", "it": "Sedia rossa"} (plain string promoted to default_lang)All functions come in a text and a jsonb flavour. PostgreSQL picks the one
matching the column type, so the same queries work before and after the
migration.
| Function | Volatility | Description |
|---|---|---|
i18n_get(v, lang, default_lang, mode) |
IMMUTABLE | Core resolver. mode is any (lang, then default_lang, then the first non-empty language by key), default (lang, then default_lang) or none (lang only). NULL when nothing matches. A plain string is the default_lang text. |
i18n_get(v, lang, fallback) |
IMMUTABLE | Same as mode any with fallback as the default language. |
i18n_exact(v, lang, default_lang) |
IMMUTABLE | Same as mode none: exactly lang or NULL. |
i18n_get(v, lang) |
STABLE | Mode from i18n.fallback, default language from i18n.default_lang, missing value from i18n.missing. |
i18n_get(v) |
STABLE | As above with lang = i18n.lang. |
i18n_langs(v) |
IMMUTABLE | text[] of languages present. {} for a plain string. |
i18n_is_json(v) |
IMMUTABLE | True only for a JSON object whose values are all strings, so a text column that happens to contain some other JSON is still treated as a plain string. |
i18n_values(v) |
IMMUTABLE | text[] of every translation. A plain string gives a one-element array. |
i18n_all(v) |
IMMUTABLE | Every translation joined by newline. For LIKE search across languages. |
Use the three- or four-argument forms in expression indexes; the shorter forms depend on session state and cannot be indexed.
An empty-string translation counts as not set, so {"en": "Chair", "it": ""}
falls through to English for Italian and shows up as missing in the
automation views.
By default a language that is not set falls back as far as needed, so the application always gets some text. Two session settings change that for the session-driven forms and for the wrapped views:
SET i18n.fallback = 'none'; -- any (default) | default | none
SET i18n.missing = 'empty'; -- null (default) | empty
SELECT i18n_get('{"en":"Chair","it":"Sedia"}', 'de');
-- fallback any: Chair
-- fallback default: Chair
-- fallback none: NULL, or '' with i18n.missing = 'empty'
SELECT i18n_get('{"fr":"Chaise"}', 'de');
-- fallback any: Chaise
-- fallback default: NULL / ''A NULL column value stays NULL whatever the settings. For a fixed choice in
one query, use the immutable forms: i18n_exact(v, 'de', 'en') or
i18n_get(v, 'de', 'en', 'default'), with COALESCE(..., '') if you want
the empty string.
i18n_set returns the new value to store in the column. It never mutates
anything itself.
| Function | Volatility | Description |
|---|---|---|
i18n_set(v, lang, val, promote_as) |
IMMUTABLE | Sets lang to val. A plain-string v is first promoted to {promote_as: v}. NULL or '' starts from {}. A NULL val removes the language; when nothing is left the result is NULL. |
i18n_set(v, lang, val) |
STABLE | promote_as is i18n.default_lang. |
i18n_set(v, val) |
STABLE | lang is i18n.lang. |
SELECT i18n_set('Chair', 'it', 'Sedia', 'en'); -- {"en": "Chair", "it": "Sedia"}
SELECT i18n_set('{"en":"Chair"}', 'en', 'Armchair', 'en'); -- {"en": "Armchair"}
SELECT i18n_set('{"en":"Chair","it":"Sedia"}', 'it', NULL, 'en'); -- {"en": "Chair"}| Function | Volatility | Description |
|---|---|---|
i18n_lang() |
STABLE | Current language: i18n.lang, else i18n.default_lang. |
i18n_default_lang() |
STABLE | i18n.default_lang, else en. |
| Function | Description |
|---|---|
i18n_wrap_table(table, cols, view) |
Create view exposing cols as plain strings in the session language, with INSTEAD OF triggers writing back. See below. |
i18n_migration_report(table, col) |
Count null, plain, translated and other-JSON rows in a column. |
i18n_migrate_column(table, col [, lang]) |
Promote plain strings to {lang: v}, alter the column to jsonb, add a CHECK constraint. |
i18n_migrate_table(table, cols [, lang]) |
Same for several columns. |
| Function | Description |
|---|---|
i18n_missing(v, langs [, default_lang]) |
text[] of the languages in langs that are absent or empty in v. IMMUTABLE with the third argument. |
i18n_fill(v, translations) |
Return v with the languages from the {"lang": "text"} object added, only where still missing. STABLE. |
i18n_auto_enable(table, col, langs [, source_lang, provider, hint, detect]) |
Configure col to be kept filled for langs and attach the trigger. detect (default false) enables language detection. |
i18n_relocate(v, from_lang, to_lang, txt) |
Move txt from one language key to another, only if from_lang still holds exactly txt and to_lang is empty. STABLE. |
i18n_auto_disable(table, col) |
Drop the trigger and mark the configuration disabled. |
i18n_backfill(table, col) |
Queue every existing row that misses a configured language. Returns the count. |
i18n_queue_claim(n, worker) |
Worker side: take up to n pending jobs (SKIP LOCKED), returns them. |
i18n_queue_complete(id, translations [, detected_lang]) |
Worker side: apply translations through i18n_fill and mark the job done. With a detected_lang different from the job's source language, the text is first moved there with i18n_relocate. |
i18n_queue_fail(id, error [, max_attempts]) |
Worker side: back to pending, or error after max_attempts. |
i18n_queue_requeue_stale([interval]) |
Return jobs stuck in processing for longer than interval to pending. |
i18n_present(v, default_lang) |
text[] of the languages that have non-empty text. A plain string counts as default_lang. IMMUTABLE. |
i18n_missing_rows(table, col, langs) |
Rows of col missing any of langs, as (pk, present, missing). Any table with a primary key. |
i18n_coverage_of(table, col, langs) |
Per language: rows total, rows missing it, percentage done. |
view i18n_missing_translations |
Every row and column configured in i18n_auto that still misses a language, with a queued flag. |
view i18n_coverage |
Per configured column and language: total, missing, done_pct. |
Tables: i18n_auto (configuration, one row per table and column) and
i18n_queue (jobs). Both are dumped by pg_dump when installed as an
extension.
| Setting | Default | Used by |
|---|---|---|
i18n.lang |
value of i18n.default_lang |
one-argument i18n_get, two-argument i18n_set, wrapped views |
i18n.default_lang |
en |
fallback on read, promotion language on write |
i18n.fallback |
any |
how far the session-driven i18n_get falls back: any, default or none |
i18n.missing |
null |
what a missing translation reads as: null or empty |
These are ordinary custom GUCs. Set them per connection (SET), per transaction
(SET LOCAL, the right choice behind a transaction-mode pooler such as
PgBouncer) or permanently per role or database:
ALTER ROLE api_user SET i18n.lang = 'it';
ALTER DATABASE mydb SET i18n.default_lang = 'en';If the application already reads and writes these columns as plain strings and you cannot or do not want to change it, hide the JSON behind a view:
ALTER TABLE products RENAME TO products_i18n;
SELECT i18n_wrap_table('products_i18n', '{name,description}', 'products');i18n_wrap_table(table, cols, view) creates view with the same columns as
table, where every column listed in cols is exposed as
i18n_get(col), plus an INSTEAD OF INSERT / UPDATE / DELETE trigger that
writes back through i18n_set. The table needs a primary key.
The application then keeps using products and only has to have i18n.lang
set (see above). What it sees:
- SELECT returns the translation for
i18n.lang, followingi18n.fallbackandi18n.missing. - INSERT stores
{"<lang>": value}. Columns left out of the insert keep their defaults (serials,now(), ...). - UPDATE changes only the current language inside the JSON, leaving the
others intact. A legacy plain string is promoted to
i18n.default_langon the first write. Columns whose visible value did not change are not touched. - DELETE deletes the row.
RETURNINGworks and returns the translated row.
To remove the layer: DROP VIEW products; and rename the table back.
Once every writer goes through the functions or the view, you can turn the
text columns into real jsonb with a constraint, and get containment queries
and GIN indexes for free.
BEGIN;
SELECT * FROM i18n_migration_report('products_i18n', 'name');
-- col_type | total | nulls | plain | translated | other_json
-- text | 12040 | 15 | 9871 | 2154 | 0
DROP VIEW IF EXISTS products; -- ALTER COLUMN TYPE refuses a dependent view
SELECT i18n_migrate_table('products_i18n', '{name,description}', 'en');
SELECT i18n_wrap_table('products_i18n', '{name,description}', 'products');
COMMIT;i18n_migrate_column(table, col [, lang]) (and i18n_migrate_table for a
list of columns) does, for a text, varchar or char column:
UPDATEevery non-NULL value that is not already a translation object to{"<lang>": value}.langdefaults toi18n.default_lang. Empty strings become{"<lang>": ""}soNOT NULLsemantics are preserved.ALTER COLUMN ... TYPE jsonb. Any column default is dropped first, since a text default is no longer valid; re-add one as'{"en": "..."}'::jsonbif needed.- Add a
CHECK (col IS NULL OR i18n_is_json(col))constraint named<col>_i18n_check.
On a column that is already jsonb only steps 1 and 3 run (bare JSON strings
get promoted to objects).
The rewrite takes an ACCESS EXCLUSIVE lock on the table for its duration.
other_json in the report counts values that start with { but are not a
translation object, for example {"foo": 1}; they are promoted like plain
strings, which is probably not what you want, so inspect them first.
i18n_auto.sql (included in the extension) keeps chosen languages filled in
by an external translation service. PostgreSQL cannot call HTTP APIs portably,
so the work is split:
- database: a configuration table, triggers that detect rows missing a configured language and put them on a queue, and functions that write the translations back without ever overwriting an existing one;
- worker (
worker/pg_i18n_worker.py): claims queue jobs, calls the provider, writes back. Providers:deepl,google,openrouter, andecho(offline, returns[lang] text, for tests).
-- keep en, it and de filled for products.name; hint is passed to LLM providers
SELECT i18n_auto_enable('products_i18n', 'name', '{en,it,de}',
NULL, -- source language (NULL: default lang, else first available)
'openrouter', -- provider (NULL: worker default)
'furniture product names, keep brand names untranslated');
SELECT i18n_backfill('products_i18n', 'name'); -- queue every existing row that misses a language
SELECT i18n_auto_disable('products_i18n', 'name');i18n_auto_enable records the configuration in i18n_auto and adds an
AFTER INSERT OR UPDATE OF col trigger. Each write that leaves a configured
language missing or empty creates one job in i18n_queue (one open job per
row and column; repeated writes update it) and sends a NOTIFY i18n_queue.
Writes through a wrapped view count too.
The source text is the configured source language if it has text, otherwise the first non-empty language. Only missing languages are requested and only missing languages are written: a human translation entered while a job is in flight wins. Changing the source text does not retranslate languages that already exist.
Queue jobs go pending → processing → done or error (after
max_attempts). Watch it with:
SELECT status, count(*) FROM i18n_queue GROUP BY 1;
SELECT id, tbl, col, pk, target_langs, attempts, error FROM i18n_queue WHERE status = 'error';
UPDATE i18n_queue SET status = 'pending', attempts = 0 WHERE status = 'error'; -- retryHelper functions usable on their own: i18n_missing(v, langs) returns which
of langs are absent or empty, i18n_fill(v, '{"it": "..."}') adds only
the languages still missing, i18n_exact(v, lang, default) reads one language
without fallback.
An application that knows nothing about languages inserts plain strings, and
those are taken to be in the default language. Someone using the API in an
Italian session may paste an English text, which then lands under it.
Detection, off by default, fixes both cases at translation time. Enable it per
column with the last argument of i18n_auto_enable:
SELECT i18n_auto_enable('notes', 'body', '{en,it,de}', NULL, 'deepl', NULL, true);For each job of such a column the worker asks the provider (or a local detector) what language the source text is in. If it matches the language the text was stored under, nothing changes. Otherwise:
- the text is moved to the detected language key, and the key it was stored under is cleared, provided it still holds exactly that text and the detected key is empty (a human edit in the meantime wins);
- every other configured language, including the one it was wrongly stored under, is filled by translation from the detected language.
A text in a language outside the configured set stays under its own key and
all configured languages get translations: Bonjour inserted with
{en,it} configured becomes {"en": "Hello", "fr": "Bonjour", "it": "Ciao"}.
The job records what was detected in i18n_queue.detected_lang.
Detection source (PG_I18N_DETECT on the worker):
| Value | How |
|---|---|
provider (default) |
DeepL and Google detect while translating (the source language is omitted from the first request); OpenRouter is asked to return the detected code alongside the translations. |
local |
The langdetect Python package, no extra API call (pip install langdetect). Also usable with echo for dry runs. |
Provider codes are mapped onto your configured ones: EN from DeepL matches
en, zh-CN from Google matches a configured zh, and a regional code such
as en-GB is taken as the same language as en, so it never triggers a
move. Codes outside the configured set are stored lower-cased as returned.
Two views answer "what is still untranslated" for every column configured
with i18n_auto_enable, enabled or not:
SELECT * FROM i18n_coverage;
-- tbl | col | enabled | lang | total | missing | done_pct
-- products_i18n | name | t | de | 5 | 5 | 0.0
-- products_i18n | name | t | en | 5 | 2 | 60.0
-- products_i18n | name | t | it | 5 | 0 | 100.0
SELECT * FROM i18n_missing_translations WHERE NOT queued;
-- tbl | col | enabled | pk | present | missing | queued
-- products_i18n | name | t | {"id": 6} | {it} | {de,en} | fqueued tells whether a translation job is already open for that row.
Rows with no text at all show every language as missing; the automation
skips them since there is nothing to translate from.
For a column that is not configured, or to restrict the scan to one table, call the underlying functions directly:
SELECT * FROM i18n_missing_rows('products_i18n', 'description', '{en,it,de}');
SELECT * FROM i18n_coverage_of('products_i18n', 'description', '{en,it,de}');Both views run a scan per configured column on every query; a WHERE tbl =
filter on the view does not shrink the scan, the function form does.
cd worker && pip install -r requirements.txt
export PG_I18N_DSN=postgresql://user:pw@host/db
export PG_I18N_PROVIDER=deepl DEEPL_API_KEY=... # or
export PG_I18N_PROVIDER=google GOOGLE_TRANSLATE_API_KEY=... # or
export PG_I18N_PROVIDER=openrouter OPENROUTER_API_KEY=... OPENROUTER_MODEL=anthropic/claude-sonnet-4.5
./pg_i18n_worker.py # runs forever: LISTEN/NOTIFY plus a poll every PG_I18N_POLL seconds
./pg_i18n_worker.py --once # drain the queue and exit, for cronOr as a container: docker build -t pg_i18n-worker worker/ and run it with
the same environment variables. All settings:
| Variable | Default | Meaning |
|---|---|---|
PG_I18N_DSN (or DATABASE_URL) |
libpq connection string | |
PG_I18N_SCHEMA |
schema pg_i18n is installed in, if not on the search_path | |
PG_I18N_PROVIDER |
echo |
provider for jobs whose config has none |
PG_I18N_BATCH |
10 |
jobs claimed per round |
PG_I18N_POLL |
30 |
seconds between polls when idle |
PG_I18N_MAX_ATTEMPTS |
3 |
failures before a job is marked error |
PG_I18N_STALE_MINUTES |
10 |
jobs left processing this long are requeued |
PG_I18N_DETECT |
provider |
language detection source for columns with detect: provider or local |
DEEPL_API_KEY |
keys ending in :fx use the free endpoint |
|
DEEPL_TARGET_MAP |
en=EN-US,pt=PT-PT,zh=ZH-HANS |
DeepL regional targets, e.g. en=EN-GB,pt=PT-BR |
DEEPL_FORMALITY |
more, less, prefer_more, prefer_less |
|
GOOGLE_TRANSLATE_API_KEY |
API key with the Cloud Translation API enabled (Basic edition, v2) | |
GOOGLE_TRANSLATE_FORMAT |
text |
text or html; use html for columns holding markup |
OPENROUTER_API_KEY |
||
OPENROUTER_MODEL |
openai/gpt-4o-mini |
any OpenRouter model id |
DeepL and Google make one request per target language; DeepL receives the
hint as context, Google ignores it. Google's language codes are BCP-47
(en, pt-BR, zh-CN), so name your languages that way if you use it.
OpenRouter gets one request per job asking for all target languages as a
JSON object; the hint from the configuration is added to the prompt. Several workers can run at once: claims
use FOR UPDATE SKIP LOCKED.
The worker only needs the ability to call the i18n_queue_* functions and to
update the target tables. To use another service, add a class with a
translate(text, source_lang, target_langs, hint) method returning
{lang: text} to PROVIDERS.
All of this works on the original text columns, before any migration, on
plain and JSON rows alike, and keeps working on jsonb afterwards.
-- one language (with fallback)
SELECT * FROM products_i18n WHERE i18n_get(name, 'it', 'en') ILIKE '%sedia%';
-- any language
SELECT * FROM products_i18n WHERE i18n_all(name) ILIKE '%chair%';
-- through the wrapped view: the session language, not indexable
SET i18n.lang = 'it';
SELECT * FROM products WHERE name ILIKE '%sedia%';Do not LIKE the raw column: on JSON rows it also matches language keys,
quotes and \uXXXX escapes, and a search for Sedia would match a row whose
German translation contains it while missing an Italian one written with an
escape.
i18n_get(v, lang, fallback) and i18n_all(v) are IMMUTABLE, so both can be
indexed. For %term% patterns use pg_trgm:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- one language
CREATE INDEX ON products_i18n USING gin (i18n_get(name, 'it', 'en') gin_trgm_ops);
-- any language
CREATE INDEX ON products_i18n USING gin (i18n_all(name) gin_trgm_ops);Both serve LIKE, ILIKE, ~ and the % similarity operator. For equality
or left-anchored LIKE 'sed%' a plain B-tree on the same expression is
enough (use text_pattern_ops if the database collation is not C).
These expression indexes survive the migration to jsonb: ALTER COLUMN TYPE
rebuilds them against the jsonb overloads.
-- jsonb only: containment and key existence
CREATE INDEX ON products_i18n USING gin (name);
SELECT * FROM products_i18n WHERE name @> '{"it": "Sedia"}';
SELECT * FROM products_i18n WHERE NOT name ? 'de'; -- rows missing GermanFilters written against the wrapped view use the session language and cannot use these indexes. Query the base table with the explicit form when speed matters.
- Fallback order on read is: requested language, default language, first
non-empty language sorted by key.
i18n.fallbackor themodeargument stop it earlier; a missing translation then reads as NULL, or''withi18n.missing = 'empty'. - An empty-string translation is treated as not set everywhere: reads fall through it, automation counts it as missing and will fill it.
i18n_is_jsonrequires an object whose values are all strings. A stored value like{"en": "a", "count": 3}is a plain string as far as pg_i18n is concerned and will be promoted wholesale on write.- Language codes are opaque keys. Nothing stops you from using
en-GBandenside by side, but nothing resolves between them either. i18n_setwith aNULLvalue that empties the object returnsNULL, not{}.- Automation only ever adds languages. It requests only what is missing or
empty,
i18n_fillwrites only what is still missing at write-back time, and a changed source text does not retranslate languages that already exist. Clear a language (i18n_set(v, 'it', NULL)) to have it redone. - With
detectenabled on a column, the one exception to "only adds" is the move of a text to its detected language, which clears the key it was stored under; it happens only while that key still holds the exact inserted text. - Automation triggers fire on the base table, so writes through wrapped views and direct writes are treated the same. The worker's own write-back fires the trigger too, which finds nothing missing and stops there.
make test # same as ./test.sh; also make test-ext, make test-worker
./test.sh # plain script, throwaway postgres:16-alpine container
EXT=1 ./test.sh # build + install the extension with PGXS, then CREATE EXTENSION
EXT=1 ./test.sh postgres:17-alpine # any official image
./worker/test_worker.sh # providers against a mock HTTP server, then end-to-end: postgres + worker, echo providerOr against any empty database: psql -d empty_db -f test.sql (add
-v use_ext=1 to load via CREATE EXTENSION). The script stops at the first
failing statement.
MIT