How Odoo Translations Work
Odoo uses GNU gettext-style translations. Translatable strings are marked in Python and XML, extracted to PO files, translated, and loaded into the database. At runtime, Odoo serves the translated version based on the user's language preference.
Marking Strings for Translation
Python: The _() Function
from odoo import _, models, fields
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
if not self.order_line:
raise UserError(_('You cannot confirm an order without lines.'))
self.message_post(body=_('Order confirmed by %s.', self.env.user.name))Rules for _():
- Only use with string literals —
_('Fixed text')works,_(variable)does not - Supports printf-style formatting —
_('Hello %s', name) - Must import
_fromodoomodule - The extraction tool finds
_()calls and adds them to PO files
Field Labels and Help Text
name = fields.Char(string='Order Reference', help='Unique reference for this order')
# 'Order Reference' and the help text are automatically translatableField string, help, and selection labels are extracted automatically — no _() needed.
Selection Fields
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('done', 'Done'),
], string='Status')
# 'Draft', 'Confirmed', 'Done' are all translatableXML Views
Text content in XML views is automatically translatable:
<form>
<header>
<button string="Confirm" name="action_confirm"/>
<!-- "Confirm" is translatable -->
</header>
<sheet>
<group string="Order Details">
<!-- "Order Details" is translatable -->
</group>
</sheet>
</form>QWeb Templates
<template id="my_template">
<p>Welcome to our store</p>
<!-- Text nodes are automatically translatable -->
<!-- Dynamic text is NOT translatable -->
<span t-esc="record.name"/>
<!-- Use t-attf with translation -->
<a t-attf-href="/shop">Visit Shop</a>
</template>PO File Structure
Translation files live in i18n/ directory of your module:
my_module/
i18n/
my_module.pot # Template (source strings)
fr.po # French translations
es.po # Spanish translations
de.po # German translationsPO file format:
# French translation for my_module
msgid "You cannot confirm an order without lines."
msgstr "Vous ne pouvez pas confirmer une commande sans lignes."
msgid "Order confirmed by %s."
msgstr "Commande confirmee par %s."
msgid "Order Reference"
msgstr "Reference de commande"Exporting Translations
Generate the POT file or export existing translations:
- Go to Settings > Translations > Export Translation
- Select your module and language
- Choose PO File format
- Download and save to
i18n/directory
Or via command line:
odoo -d mydb --modules=my_module --i18n-export=my_module/i18n/my_module.potImporting Translations
# Import a PO file
odoo -d mydb --i18n-import=my_module/i18n/fr.po --language=fr_FR
# Or via Settings > Translations > Import Translation in the UITranslatable Fields (translate=True)
Model fields that should store different values per language:
class Product(models.Model):
_name = 'product.template'
name = fields.Char(translate=True) # different name per language
description = fields.Text(translate=True)
website_description = fields.Html(translate=True)When translate=True is set, Odoo stores separate values for each installed language. Users editing the record in French see and modify the French version.
Website Multilingual Setup
- Install desired languages at Settings > Translations > Languages
- Go to Website > Configuration > Settings
- Enable the languages under Website Info
- The language selector appears automatically in the website header
- URLs get language prefixes:
/fr/shop,/es/shop
Programmatic Language Switching
# Read a field in a specific language
product_fr = product.with_context(lang='fr_FR')
french_name = product_fr.name
# Create record with specific language
product = self.env['product.template'].with_context(lang='es_ES').create({
'name': 'Producto de Ejemplo',
})Common i18n Pitfalls
- Concatenation: Never build translatable strings with
+— use_('Hello %s', name)instead of_('Hello ') + name - Variables in _():
_(variable)does not work — the extractor needs literal strings - f-strings:
_(f'Hello {name}')does not work — use_('Hello %s', name) - Plural forms: Odoo does not have built-in plural handling — use conditional logic
- Missing .pot file: If the POT file is missing, translators cannot find strings to translate
- HTML in translations: Avoid HTML tags in
_()strings — they break when translated - Date/number formatting: Use
format_date()andformat_amount()instead of manual formatting
Testing Translations
class TestTranslation(TransactionCase):
def test_french_label(self):
# Install French
self.env['res.lang']._activate_lang('fr_FR')
# Check translated field
product = self.env['product.template'].with_context(lang='fr_FR').browse(product_id)
self.assertEqual(product.name, 'Expected French Name')Best Practices
- Always wrap user-facing strings in
_() - Use printf-style formatting:
_('Order %s confirmed', order.name) - Set
translate=Trueon fields that need per-language values - Generate and commit the POT file with your module
- Export PO files after adding new strings
- Test with at least one non-English language
- Use
format_date()andformat_amount()for locale-aware formatting