Multilingual Rails schema: jsonb wins over 19 tables/columns, tiered constraints, ->> queries, indexing timing.
how2claude ships every article in 19 languages (zh / en / ja / ko / ar / ...), and how to store that at the data layer has to be decided up front. Ask Claude to design the table and it'll usually give you 19 string columns, or worse, 19 tables — "one articles table per language." Both are traps.
The right answer is 1 table + 3 jsonb columns, each jsonb keyed by locale. This article covers why this approach wins, how to write the schema, how to query, when to index, and the 4 directions Claude will default toward getting wrong.
Lay the candidate schemas out first:
Option A: 19 tables (articles_zh, articles_en, ...)
- Fetching one article needs 19 joins / 19 queries
- Adding a 20th language = CREATE TABLE
- Deleting an article cascades across 19 tables
- Plus: single-table queries are fast
- Reality: unless the display logic is genuinely different per language (almost never), this is over-partitioning
Option B: 19 string columns (title_zh, title_en, ..., summary_zh, summary_en, ...)
- Table becomes enormous (3 content types × 19 languages = 57 columns)
- Most cells are NULL (Thai translation likely lands last or never)
- Adding a 20th language = ALTER TABLE ADD COLUMN × 3
- Sparse row layout, bad IO efficiency
Option C: 3 jsonb columns (title jsonb, summary jsonb, content jsonb)
- One row per article, locale is a key inside the jsonb
- Adding a language doesn't touch the schema (just insert a key)
- First-class in Postgres — atomic queries, indexes, partial fetches
- Query syntax is slightly noisier (title->>'zh') but you can wrap it
Go with C. The rest of this article is about doing C correctly.
how2claude's actual articles table (excerpt):
create_table :articles do |t|
t.jsonb :title, default: {}, null: false
t.jsonb :summary, default: {}
t.jsonb :content, default: {}
t.string :slug, null: false
t.boolean :free, default: false, null: false
# ... other bigint, timestamp, etc.
end
Why the constraints differ:
title: null: false + default: {}. Title is mandatory — an article must have at least one language's title. Even if only en, at least that. The whole jsonb cannot be NULL.summary / content: just default: {}, NULL allowed. These can be empty (e.g., right after creation, before content is written), so loosen up.default: {} is mandatory. Leave it out and a freshly Article.new'd record has title = nil, so article.title["en"] blows up: NoMethodError: undefined method '[]' for nil:NilClass. Defaulting to an empty hash makes every access safe.
When Claude writes this migration, it tends to:
- Skip default: {} → the nil crash above
- Add null: false to every jsonb → summary can't be NULL on new records still being filled in, breaks a "title first, content later" flow
- Write default: "{}" (string literal) → Postgres stores the literal string "{}" instead of an empty json, type mismatch at query time
Rule: a jsonb column's default must be {} the Ruby hash literal, not a string. null: false only on the mandatory jsonb.
Schema alone isn't enough — the model needs fallback-aware accessors:
class Article < ApplicationRecord
validates :title, presence: true
def title_for(locale = I18n.locale)
title[locale.to_s] || title["en"] || title.values.first
end
def summary_for(locale = I18n.locale)
summary[locale.to_s] || summary["en"]
end
def content_for(locale = I18n.locale)
content[locale.to_s] || content["en"]
end
end
A three-step fallback: current locale → en → whatever exists. A Chinese reader hitting a Japanese article falls back to English; English missing falls back to "whatever's there," never nil.
validates :title, presence: true handles {} automatically — ActiveRecord treats an empty hash as blank, so an empty title fails validation. That's the null: false + presence combo paying off.
->> vs ->Two Postgres jsonb operators in daily use:
title->>'zh' → returns text (string)title->'zh' → returns jsonb (preserves the json type)99% of the time you want ->> — pull a string and use it. Use -> only when you'll nest further (e.g., content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? is jsonb's "key exists" operator. Returns articles where the title hash has the zh key.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Wrong 1: comparing whole jsonb as a string
Article.where(title: '{"zh": "..."}')
# Wrong 2: filtering in Ruby
Article.all.select { |a| a.title["zh"].present? } # pulls whole table into memory
# Wrong 3: using = on a jsonb field without the operator
Article.where("title->>'zh' = ?", "some title") # this is actually correct, but Claude mixes it with Wrong 1
Rule: any filter / order / existence check happens in SQL via ->> or ?; only reach for article.title["zh"] in Ruby when rendering for a user.
jsonb doesn't auto-index. Three common needs map to three indexes:
add_index :articles, :title, using: :gin
Accelerates title ? 'zh', title @> '{"zh": ...}', etc. GIN is write-heavy and spacious, but mandatory for jsonb key/value existence queries.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Only speeds up title->>'en' = ? or ORDER BY title->>'en'. If you only sort by English title (e.g., English-site alphabetical list), one English expression index is enough.
jsonb indexes are expensive. If article count is small (thousands) and all queries are by id/slug, don't pre-index the jsonb. Wait until it grows to a clear query need.
how2claude currently has no jsonb index on title/summary/content — volume is low, queries are by-slug lookups, an index would pure-waste space. Add at the point bulk queries start showing up frequently (say, 10k articles).
When doing this kind of schema design, Claude's first reach is often off. Intercept each:
"I'm not sure what this field needs to hold, I'll make it jsonb..."
Redirect: jsonb fits situations with a stable structure but an open-ended or sparse-wide key set — multilingual, feature flags, user preferences. It's not an excuse for skipping schema design. If you already have a clear key set (say, 5 fixed fields), use normal columns.
default: "{}" as a stringRedirect: Rails will store that as the literal string "{}" in Postgres, not an empty json. After migration, article.title["zh"] blows up (strings don't subscript like that). Must be default: {} (Ruby hash literal).
null: falseRedirect: Ask first which is actually mandatory. Title is mandatory (no-title article is nonsense) → null: false. summary and content can be empty (draft phase) → only default: {}, no null: false. Tier the constraints; don't carpet-bomb.
"I'll
Article.alland.select { |a| a.title["zh"] }..."
Redirect: The whole point of jsonb is native SQL support. All filter / existence / ordering lives in Postgres via ->> / ? / @>. Drag it back into Ruby and the moment the table hits a thousand rows, you're dead.
Letting Claude design a multilingual Rails schema with jsonb — 6 rules:
null: false, default: {}; summary / content only default: {}.default: {} is a hash literal, not a string. A string "{}" causes a type mismatch.current → en → .values.first. Views never see nil.title ? 'zh' (existence), title->>'zh' (value/ordering). Don't yank back into Ruby to filter.The actual decision isn't "jsonb or not" — with 19 languages the answer is obvious. The actual decisions are which fields are mandatory vs. optional, how the model's fallback chain works, when to switch from "no index yet" to "add an expression index." Those are product- and volume-driven calls. Claude gives you the code but won't make those calls for you.