Back to Blog

July 3, 2026

Odoo Drops Its Translation Table and Stores Every Language Directly Inside Each Database Column

Odoo eliminates the ir_translation model entirely, moving all translated field values into JSONB columns on the model's own table. The result: dramatically faster reads, trigram search across every language, and a simpler ORM that no longer needs cross-table joins to display a product name in French.

Architecture diagram comparing old ir_translation table with JOIN-based lookups versus new inline JSONB column storage showing COALESCE query pattern and performance improvements

For years, every translated field in Odoo — every product name, every menu label, every email template subject line — lived in a single massive table called ir_translation. When you loaded a product page in French, the ORM would read the product record from its own table, then perform a separate lookup againstir_translation to fetch the French version of the name, description, and any other translatable field. For databases with dozens of active languages and hundreds of thousands of products, this table could balloon into millions of rows, and every translated field read meant a cross-table join.

That architecture is now gone. Odoo has fundamentally changed how translations are stored by moving them directly into the model’s own table as JSONB columns. A product’s name is no longer a plain text column with translations living elsewhere — it’s a JSONB column that holds every language inline.

What the New Storage Actually Looks Like

Instead of a text column containing "Chair"with separate translation records mapping that source text to “Chaise” in French and “Stuhl” in German, the column now contains a JSON object:

{"en_US": "Chair", "fr_FR": "Chaise", "de_DE": "Stuhl"}

The English value serves as the fallback for any language that doesn’t have a specific translation. When the ORM needs to display the field in French, it runs a single query with a PostgreSQL COALESCE expression: COALESCE(name->>'fr_FR', name->>'en_US'). No join. No second table lookup. Just one column access with a JSON path operator.

This works the same way for XML-translated fields — the kind used in website pages and email templates where the translatable content includes HTML structure. The JSON value simply contains the full XML string for each language, and the framework ensures that structural changes in one language propagate correctly to all others.

The Performance Difference Is Not Subtle

The performance implications cascade through the entire system. Reading a translated field used to require fetching the source value from the model’s table, then querying ir_translationfor the current language’s override, then falling back to the source if no translation existed. For “model terms” fields — the ones where only parts of the value are translated, like XML templates — the ORM had to inject individual term translations back into the source value, which was even slower.

With JSONB storage, all of that collapses into a single column read. The ORM cache stores the JSON dict directly, and extracting the current language’s value is a dictionary lookup in memory. For model terms fields, the improvement is especially dramatic because there’s no longer any need to reconstruct the translated value from scattered term fragments.

Search also benefits substantially. Previously, only the source language (English) could be indexed with PostgreSQL trigram indexes. Searching for “Chaise” across French product names meant a full table scan on ir_translation. Now, trigram indexes can be applied to the JSONB column and will match against any language. A French user searching with ilikehits the same trigram index that an English user does — just on a different JSON key.

Code Translations Go Static

A less visible but equally significant change affects code translations — the strings defined in Python source code and JavaScript files that power menu labels, button text, error messages, and other interface elements. These no longer live in the database at all.

Instead, code translations are extracted directly from PO files (the standard gettext translation format) and cached in memory by each worker process. The French translations for all installed modules take roughly 2MB of memory, and this cache can be shared across all database registries in a single worker. Changing a code translation now means updating the PO file and reloading the worker — there’s no database write involved.

This eliminates an entire category of database operations that happened during module updates. Previously, installing or upgrading a module meant importing thousands of translation records into ir_translation. That process still exists for model field translations (they still come from PO files during import), but code translations skip the database entirely. The trade-off: importing model translations from PO files is roughly twice as slow as before, since the ORM now needs to construct and write JSON objects instead of simple text rows. But this is a one-time cost during module installation, not something that affects day-to-day operations.

What Developers Need to Know

For most business users, this change is invisible — translations work exactly as before, just faster. But for developers, several things have changed.

Writing Falseto a translated field now sets it to NULL across all languages, same as before. But writing an empty string only clears the value in the current language, preserving translations in other languages. This distinction matters for modules that programmatically clear field values — the behavior is now language-aware in a way it wasn’t before.

The env.lang attribute, which controls which language the ORM operates in, is now guaranteed to correspond to a valid active language or None. This is because the language code is injected directly into SQL queries via the JSONB key accessor, so invalid language codes would produce incorrect results. The framework enforces this validation automatically.

One notable limitation: computed stored translated fields are not supported. The framework would need to invoke the compute method once per active language to populate all translations, and the team chose not to add that complexity. For modules that need computed translations, the recommendation is to use non-stored computed fields or handle the translation logic manually.

A Foundation Change, Not a Feature

This isn’t the kind of change that shows up in a feature announcement or a marketing slide. There’s no new button, no new screen, no new workflow. But it touches every single translated field in every single Odoo module — and for multinational deployments running dozens of languages with large product catalogs, the cumulative performance improvement is substantial.

The removal of ir_translationalso simplifies the data model in a way that makes custom module development cleaner. Developers no longer need to think about a separate translation layer when working with translatable fields — the translation is the field. It’s stored where you’d expect it, queried how you’d expect it, and cached in a way that makes sense. After years of the translation table being one of the most complex and performance-sensitive parts of the ORM, that simplicity is worth more than any single feature.

Ready to experience Odoo AI?

Join hundreds of teams using DearERP to customize Odoo in minutes, not weeks. Plans start at $29/month.