I18n questions · Software localization
First decide what is actually multilingual. Interface strings (button labels, validation messages, email templates) belong in resource files that ship with the code and are translated in a TMS such as Localizely; they do not need tables. What belongs in the database is content that users or editors create per language: product names, articles, category descriptions.
For that content there are three schema patterns.
A translation table per entity is the usual recommendation. The parent row holds language-neutral data and a child table holds one row per locale:
CREATE TABLE products (
id bigint PRIMARY KEY,
sku text NOT NULL,
price_cents integer NOT NULL
);
CREATE TABLE product_translations (
product_id bigint NOT NULL REFERENCES products (id) ON DELETE CASCADE,
locale text NOT NULL, -- BCP 47 tag: 'en', 'de', 'pt-BR'
name text NOT NULL,
description text,
PRIMARY KEY (product_id, locale)
);
Adding a language is an insert, not a migration; NOT NULL works per field; and you can index, search and sort per locale with ordinary SQL. The cost is one join per query and some ORM boilerplate.
One column per language (name_en, name_de, name_fr) is the simplest to query and fine for two or three languages that will never change, but every new language is a schema migration across every translated table, and rows get wide quickly.
A JSON column (name jsonb holding {"en": "Chair", "de": "Stuhl"}) is flexible and keeps the row count flat. PostgreSQL's jsonb can be indexed with GIN, but you give up per-locale constraints such as NOT NULL or uniqueness, need an expression index for every key you sort or search on, and queries get harder to express in an ORM. Reasonable for loosely structured content, weak for catalogs.
Whichever pattern you pick:
en, de-AT, zh-Hant) in one consistent casing, the same tags your app uses for its UI locales./de/... or de.example.com) and emit hreflang links between the versions; the hreflang generator produces the tag set.Published Oct 9, 2026 · Markdown version