Lightweight, performant internationalization for Rails with JSON column storage.
Internationalize is a focused, lightweight gem that does one thing well: JSON column translations. No backend abstraction layers, no plugin systems, no extra memory overhead.
Unlike Globalize (separate translation tables) or Mobility (JSON backend is PostgreSQL-only), Internationalize stores translations inline using JSON columns with full SQLite, PostgreSQL, and MySQL support:
- No JOINs - translations live in the same table
- No N+1 queries - data is always loaded with the record
- No backend overhead - direct JSON column access, no abstraction layers
- ~50% less memory - no per-instance backend objects or plugin chains
- Direct method dispatch - no
method_missingoverhead - True multi-database JSON support - SQLite 3.38+, PostgreSQL 9.4+, MySQL 8.0+
- ActionText support - internationalized rich text with attachments
- Visible in schema.rb - translated fields appear directly in your model's schema
Note: Mobility's JSON/JSONB backends only work with PostgreSQL. For SQLite or MySQL, Mobility requires separate translation tables with JOINs. Internationalize provides JSON column querying across all three databases.
| Database | JSON Column | Query Syntax |
|---|---|---|
| SQLite 3.38+ | json |
json_extract() |
| PostgreSQL 9.4+ | json / jsonb |
->> operator |
| MySQL 8.0+ | json |
->> operator |
Add to your Gemfile:
gem "internationalize"rails generate internationalize:translation Article title descriptionThis creates a migration adding title_translations and description_translations JSON columns with default: {}.
Important: JSON columns must have default: {} set. The generator handles this automatically, but if writing migrations manually:
add_column :articles, :title_translations, :json, default: {}class Article < ApplicationRecord
include Internationalize::Model
international :title, :description
end# Set via current locale (I18n.locale)
article.title = "Hello World"
# Set for specific locale
article.title_en = "Hello World"
article.title_de = "Hallo Welt"
# Read via current locale
article.title # => "Hello World" (when I18n.locale == :en)
# Read specific locale
article.title_de # => "Hallo Welt"
# Access raw translations
article.title_translations # => {"en" => "Hello World", "de" => "Hallo Welt"}Use the helper methods for a cleaner syntax when creating records with translations:
# Create with multiple locales using a hash
Article.international_create!(
title: { en: "Hello World", de: "Hallo Welt" },
description: { en: "A greeting" },
status: "published" # non-translated attributes work normally
)
# Or use direct assignment for current locale (I18n.locale)
I18n.locale = :de
Article.international_create!(title: "Achtung!") # Sets title_de
# Mix both styles
Article.international_create!(
title: "Hello", # Current locale only
description: { en: "English", de: "German" } # Multiple locales
)
# Build without saving
article = Article.international_new(title: "Hello")
article.save!
# Non-bang version returns unsaved record on validation failure
article = Article.international_create(title: { en: "Hello" })All query methods default to the current I18n.locale and return ActiveRecord relations that can be chained with standard AR methods.
# Exact match on translation (uses current locale by default)
Article.i18n_where(title: "Hello World")
Article.i18n_where(title: "Hallo Welt", locale: :de)
# Partial match / search (case-insensitive LIKE)
Article.i18n_where(title: "hello", match: :partial)
Article.i18n_where(title: "Hello", match: :partial, case_sensitive: true)
# Exclude matches
Article.international_not(title: "Draft")
Article.international_not(title: "Entwurf", locale: :de)
# Order by translation
Article.international_order(:title)
Article.international_order(:title, :desc)
Article.international_order(:title, :asc, locale: :de)
# Find translated/untranslated records
Article.translated(:title)
Article.translated(:title, locale: :de)
Article.untranslated(:title, locale: :de)
# Chain with ActiveRecord methods
Article.i18n_where(title: "Hello World")
.where(published: true)
.includes(:author)
.limit(10)
# Combine queries
Article.i18n_where(title: "hello", match: :partial)
.where(status: "published")
.merge(Article.international_order(:title, :desc))# Check if translation exists
article.translated?(:title, :de) # => true/false
# Get all translated locales for an attribute
article.translated_locales(:title) # => [:en, :de]By default, Internationalize falls back to the default locale when a translation is missing:
article.title_en = "Hello"
article.title_de # => nil
I18n.locale = :de
article.title # => "Hello" (falls back to :en)For most validations, use standard Rails validators—they work with the virtual accessor for the current locale:
class Article < ApplicationRecord
include Internationalize::Model
international :title
# Standard Rails validations (recommended)
validates :title, presence: true
validates :title, length: { minimum: 3, maximum: 100 }
validates :title, format: { with: /\A[a-z0-9-]+\z/ }
endUse validates_international only when you need:
class Article < ApplicationRecord
include Internationalize::Model
international :title
# Uniqueness per-locale (requires JSON column querying)
validates_international :title, uniqueness: true
# Multi-locale presence (for admin interfaces editing all translations at once)
validates_international :title, presence: { locales: [:en, :de] }
endErrors from validates_international are added to the base attribute name for clean user-facing messages:
article.errors[:title] # => ["has already been taken"]
# Displays as: "Title has already been taken" (not "Title en has already been taken")For admin interfaces where you need to indicate which specific locale failed validation, use custom validations that add errors to locale-suffixed attributes:
class Article < ApplicationRecord
include Internationalize::Model
international :title
validate :validate_required_translations
private
def validate_required_translations
[:en, :de].each do |locale|
if send("title_#{locale}").blank?
errors.add("title_#{locale}", :blank)
end
end
end
endWhen using locale-suffixed error keys, configure Rails I18n to provide user-friendly attribute names:
# config/locales/en.yml
en:
activerecord:
attributes:
article:
title_en: "Title (English)"
title_de: "Title (German)"This displays as "Title (German) can't be blank" instead of "Title de can't be blank".
For rich text with attachments (requires ActionText):
class Article < ApplicationRecord
include Internationalize::Model
include Internationalize::RichText
international_rich_text :content
endThis generates has_rich_text :content_en, has_rich_text :content_de, etc. for each locale, with a unified accessor:
article.content = "<p>Hello</p>" # Sets for current locale
article.content # Gets for current locale (with fallback)
article.content_en # Direct access to English
article.content.body # ActionText::Content object
article.content.embeds # Attachments work per-localeUse the *_translations column name with nested locale keys:
# test/fixtures/articles.yml
hello_world:
title_translations:
en: "Hello World"
de: "Hallo Welt"
status: publishedNo configuration required. Internationalize uses your existing Rails I18n settings:
- Locales:
I18n.available_locales - Fallback:
I18n.default_locale
To override locales (rarely needed):
# config/initializers/internationalize.rb
Internationalize.configure do |config|
config.available_locales = [:en, :de, :fr]
endBenchmark with 1000 records, 2 translated attributes (title + body), 3 locales:
| Metric | Internationalize | Mobility (Table) | Improvement |
|---|---|---|---|
| Storage | 172 KB | 332 KB | 48% smaller |
| Create | 0.27s | 2.1s | 7.8x faster |
| Read all | 0.005s | 0.37s | 74x faster |
| Query (match) | 0.001s | 0.01s | 10x faster |
- Faster reads - No JOINs needed, translations are inline
- Less storage - No separate translation tables with foreign keys and indices
- Simpler schema - Everything in one table
- Schema changes required - Each translated attribute needs a JSON column added to the table
- Migration complexity - Adding translations to existing tables requires data migration
- JSON column support - Requires SQLite 3.38+, PostgreSQL 9.4+, or MySQL 8.0+
- New projects where you can design the schema upfront
- Applications with heavy read workloads
- When you need maximum query performance
Consider Mobility if:
- You need to add translations without modifying existing table schemas
- You're on PostgreSQL and want their JSON backend (note: PostgreSQL-only)
- You need the flexibility of multiple backend strategies
Mobility's table backend stores translations in separate tables with JOINs, which trades query performance for schema flexibility. Their JSON backend is PostgreSQL-only.
MIT License. See LICENSE.txt.