The Hard Reality of Going Global
Hardcoding your UI strings in a single language works fine for a weekend project. But the second your app gains traction in markets like Japan or Germany, that rigid name column becomes a bottleneck. Internationalization (i18n) isn’t just about translating text; it is about architecting your data so it doesn’t break when you hit 100,000 users across five time zones.
I have managed migrations for platforms switching from MySQL to PostgreSQL specifically to handle localized content more efficiently. In one instance, a poorly designed schema tripled the query latency as soon as we added a fourth language. Choosing the right structure early prevents the nightmare of refactoring a production table with 5 million rows while trying to maintain 99.9% uptime.
This guide cuts through the noise to show you three battle-tested strategies for handling multi-language data at scale.
Setting Up Your Sandbox
You will need a working instance of PostgreSQL or MySQL to test these patterns. While the core logic applies to both, PostgreSQL offers specialized tools like JSONB that give it a slight edge for unstructured localizations.
Let’s initialize a demo environment. Run these commands in your terminal to get started:
-- For PostgreSQL
CREATE DATABASE i18n_lab;
\c i18n_lab;
-- For MySQL
CREATE DATABASE i18n_lab;
USE i18n_lab;
We will model an e-commerce “Products” table. Every item needs a title and description available in English, Vietnamese, and French.
Choosing Your Architecture
There is no universal blueprint. The “best” approach depends on whether you are supporting two languages or twenty.
1. The Static Column Approach
This is the “Quick and Dirty” method. You simply append a new column for every language directly into the primary table.
CREATE TABLE products_simple (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) UNIQUE,
name_en TEXT,
name_vi TEXT,
name_fr TEXT
);
When it works: If your requirements are set in stone and you only support 2 or 3 languages, this is incredibly fast. Reads require zero joins. It’s a straight shot to the data.
The Risk: Adding a new language requires an ALTER TABLE. On a table with 10 million rows, this can lock your database for minutes. It also forces your backend developers to write messy, conditional logic just to select the right column name.
2. The Translation Table (The Industry Standard)
This is the classic relational way to solve the problem. You separate the static data (like price or SKU) from the translated content.
-- Core product data
CREATE TABLE products (
id SERIAL PRIMARY KEY,
price DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT NOW()
);
-- Translation storage
CREATE TABLE product_translations (
id SERIAL PRIMARY KEY,
product_id INT REFERENCES products(id) ON DELETE CASCADE,
lang_code VARCHAR(5), -- 'en', 'vi', 'ja'
name TEXT,
description TEXT,
UNIQUE(product_id, lang_code)
);
When it works: This is the most flexible option for MySQL users. You can add 50 languages tomorrow without touching your schema. It keeps your main table lean and organized.
The Trade-off: You pay a “JOIN tax.” Fetching a list of 100 products requires joining 100 translation rows. Without a composite index on (product_id, lang_code), your query performance will tank as the table grows.
3. The JSONB Powerhouse (PostgreSQL Only)
If you are on PostgreSQL, this is often the superior choice. You store all translations in a single binary JSON column.
CREATE TABLE products_json (
id SERIAL PRIMARY KEY,
sku VARCHAR(50),
translations JSONB
);
Inserting data is clean and readable:
INSERT INTO products_json (sku, translations) VALUES
('MACBOOK-PRO', '{"en": "MacBook Pro", "vi": "Máy tính MacBook Pro"}');
When it works: Use this when you want the speed of a single table but the flexibility of a separate one. It’s perfect for rapid prototyping and high-read environments.
Performance Tuning & Auditing
A strategy is only as good as its execution. Let’s look at how to ensure these queries stay fast under load.
Optimizing the Translation Table
To pull a product in Vietnamese, you’ll use a standard join. To keep this under 10ms, you must index your foreign keys:
SELECT p.id, t.name, p.price
FROM products p
JOIN product_translations t ON p.id = t.product_id
WHERE t.lang_code = 'vi';
Run EXPLAIN ANALYZE on this query. If you see a “Sequential Scan” on a large table, your indexes aren’t hitting. Add a composite index to fix it.
Indexing JSONB for Speed
Don’t let the JSON format fool you; it can be indexed. In PostgreSQL, you can use a GIN index to make lookups nearly as fast as native columns:
CREATE INDEX idx_products_json_translations ON products_json USING GIN (translations);
You can then query specific keys efficiently:
SELECT id, translations->>'en' as title
FROM products_json
WHERE translations ? 'en';
Identifying Gaps in Content
One of the biggest headaches in i18n is missing translations. You can easily audit your database for “French” gaps using a LEFT JOIN:
SELECT p.id, p.sku
FROM products p
LEFT JOIN product_translations t ON p.id = t.product_id AND t.lang_code = 'fr'
WHERE t.name IS NULL;
The Translation Table approach makes these audits simple. While JSONB is faster to develop with, it requires more validation logic in your application code to ensure the keys exist. Pick the path that matches your team’s scale—and your database’s strengths.

