Free

Letting Claude Model Multilingual Rails Schemas With jsonb

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.


The three options on the table

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.

Schema: 3 jsonb columns plus constraint trade-offs

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.

Model layer: wrap fallback

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.

Query patterns: ->> 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').

Articles with a specific translation

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.

Order by a specific language title

Article.order("title->>'en' ASC")

Filter + order by locale

Article.where("title ? :loc", loc: "ja")
       .order("title->>'ja' ASC")

Queries Claude defaults to writing wrong

# 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.

Indexes: what to add, when

jsonb doesn't auto-index. Three common needs map to three indexes:

1. Existence / @> containment queries → GIN index

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.

2. Ordering / equality on a specific locale → expression index

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.

3. No high-frequency query → don't add

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).

The 4 directions Claude defaults to getting wrong

When doing this kind of schema design, Claude's first reach is often off. Intercept each:

1. Uses jsonb as an "I don't know the schema" escape hatch

"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.

2. default: "{}" as a string

Redirect: 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).

3. Every jsonb column gets null: false

Redirect: 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.

4. Filter jsonb in the application layer

"I'll Article.all and .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.

Checklist

Letting Claude design a multilingual Rails schema with jsonb — 6 rules:

  1. Rule out 19-tables / 19-columns first. Unless the display logic differs substantively per locale, jsonb is the only answer.
  2. 3 jsonb columns + tiered constraints: title null: false, default: {}; summary / content only default: {}.
  3. default: {} is a hash literal, not a string. A string "{}" causes a type mismatch.
  4. Model provides fallback-aware accessors: current → en → .values.first. Views never see nil.
  5. Queries in SQL: title ? 'zh' (existence), title->>'zh' (value/ordering). Don't yank back into Ruby to filter.
  6. Wait to index until you have real query needs. Adding GIN pre-emptively is over-engineering; small volumes with slug/id lookups don't need any jsonb index.

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.