TL;DR: Multi-language database design involves choosing between translation tables, locale columns, and JSON storage. This guide covers schema patterns, fallback strategies, and query optimization for SaaS i18n. Examples from tanstackship.com.
Introduction
When your SaaS needs to support multiple languages, the database schema is the foundation. A poor design leads to complex queries, performance issues, and maintenance headaches. The right approach depends on your content types and access patterns. For the big-picture i18n architecture, start with our i18n for SaaS: Architecture Patterns Compared.
Schema Design Patterns
| Pattern | Flexibility | Query Complexity | Performance | Best For |
|---|---|---|---|---|
| Translation table | High | Medium | Good | Dynamic content |
| Locale columns | Low | Low | Excellent | Fixed few locales |
| JSON/JSONB column | Medium | Low | Good | Simple translations |
| EAV (Entity-Attribute-Value) | Very high | High | Poor | Rarely recommended |
| Separate DB per locale | Maximum | High | Varies | Enterprise multi-region |
Translation Table Pattern (Recommended)
-- Base content table (language-independent)
CREATE TABLE blog_posts (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
author_id TEXT NOT NULL,
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Translation table (one row per locale)
CREATE TABLE blog_post_translations (
blog_post_id TEXT NOT NULL REFERENCES blog_posts(id),
locale TEXT NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
body TEXT NOT NULL,
meta_description TEXT,
is_auto_translated BOOLEAN DEFAULT false,
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (blog_post_id, locale)
);
-- Query with fallback chain
SELECT
COALESCE(t.title, f.title) as title,
COALESCE(t.body, f.body) as body
FROM blog_posts p
LEFT JOIN blog_post_translations t
ON t.blog_post_id = p.id AND t.locale = 'de'
LEFT JOIN blog_post_translations f
ON f.blog_post_id = p.id AND f.locale = 'en'
WHERE p.slug = 'getting-started';
Locale Columns Pattern (Simple, Few Locales)
-- Locale columns: simple but not scalable
CREATE TABLE products (
id TEXT PRIMARY KEY,
name_en TEXT NOT NULL,
name_zh TEXT,
name_de TEXT,
name_ja TEXT,
description_en TEXT NOT NULL,
description_zh TEXT,
description_de TEXT,
description_ja TEXT,
price DECIMAL(10, 2) NOT NULL
);
Good for 2-3 locales with simple schemas. Becomes unwieldy with more languages.
JSONB Pattern (Flexible)
-- JSONB translations: flexible but harder to query
CREATE TABLE pages (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL,
translations JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- Example JSONB content
{
"en": {
"title": "About Us",
"body": "<h1>Our Story</h1><p>...</p>"
},
"de": {
"title": "Über Uns",
"body": "<h1>Unsere Geschichte</h1><p>...</p>"
},
"zh": {
"title": "关于我们",
"body": "<h1>我们的故事</h1><p>...</p>"
}
}
Performance Optimization
| Strategy | Impact | Implementation |
|---|---|---|
| Composite index on (locale, slug) | High | CREATE INDEX idx_locale_slug ON blog_post_translations(locale, blog_post_id) |
| Materialized view for active locales | Medium | Pre-join common locale pairs |
| Redis cache for translation queries | High | Cache after first DB lookup |
| Limit fallback depth | Medium | Max 2-level fallback (target → en) |
| Batch load all translations | High | Single query for all locales, filter in app |
Conclusion
The translation table pattern is optimal for most SaaS applications — it scales to any number of locales, supports fallback chains, and integrates well with ORMs. Use locale columns only for simple cases with 2-3 languages. Avoid EAV patterns entirely. Always index your locale + lookup key combinations.
For data modeling beyond i18n, see Data Modeling: Users, Organizations, and Permissions. SQL indexing best practices are covered in SQL Indexing Strategies for Web Applications, and Database Migrations: Strategies for Zero-Downtime provides deployment guidance for schema changes.