DEV Community

Cover image for Making Odoo Bilingual for Quebec: Where EN/FR Actually Breaks
Mohit
Mohit

Posted on

Making Odoo Bilingual for Quebec: Where EN/FR Actually Breaks

Activating French in Odoo takes about ninety seconds. Settings → Translations → Languages, tick fr_CA, save. The menus flip, the buttons flip, and everyone agrees the Quebec problem is solved.

Then someone in Montréal opens an invoice PDF. The header is French. The line items say Stainless Steel Bracket, 40mm. The payment-terms footer is English. The email that delivered it was subject-lined "Your invoice is ready."

Quebec's Charter of the French Language is not interested in your menus. Section 57 says invoices, receipts, acquittances and other documents of the same nature must be drawn up in French, and that you may not send one in another language unless the French version is available on terms at least as favourable. Section 52.1 says computer software must be available in French unless no French version exists. The obligations that bite are about the artifacts your ERP emits and the interface your Quebec staff sit in front of — not the language dropdown in the corner of the screen.

Here is what actually has to be fixed, in the order the fixes tend to be needed.

Install fr_CA, not fr_FR

This sounds pedantic and it is not. fr_FR gives you DD/MM/YYYY, a comma decimal separator and a non-breaking-space thousands separator. Canadian French convention differs, and more importantly the vocabulary differs — Odoo's fr_CA translation catalogue uses the terminology Quebec accountants and the OQLF expect, where fr_FR will happily print terms that read as European.

# check what you've actually got active
env['res.lang'].search([('active', '=', True)]).mapped('code')
# ['en_US', 'fr_CA']  ← what you want
# ['en_US', 'fr_FR']  ← what half the instances I've opened actually have
Enter fullscreen mode Exit fullscreen mode

If you inherited an instance with fr_FR installed and data already translated against it, switching is not a checkbox. The translated values are keyed by language code, so you are looking at a migration of every translated field, not a settings change.

The translations are not where the old tutorials say they are

If you learned Odoo before version 16, you know ir_translation — one row per translated term, queryable, easy to audit. It's gone. Since this change, translated fields are stored as JSONB in the column itself. A product name is no longer a string:

{"en_US": "Stainless Steel Bracket, 40mm", "fr_CA": "Support en acier inoxydable, 40 mm"}
Enter fullscreen mode Exit fullscreen mode

That is a much better design and it breaks every audit script written against the old model. The useful consequence is that you can find your gaps in one query:

SELECT id,
       name->>'en_US' AS english,
       name->>'fr_CA' AS french
FROM   product_template
WHERE  name->>'fr_CA' IS NULL
   OR  name->>'fr_CA' = name->>'en_US';
Enter fullscreen mode Exit fullscreen mode

Run that against product_template, product_category, account_tax, account_payment_term and any custom model with a translate=True field. On a catalogue of a few thousand SKUs the result is usually humbling. Product names are the single largest source of English text on a French invoice, and no amount of interface translation touches them.

Most teams find this at UAT, which is late — the ordering problem is that the catalogue has to be translated before the data load, not after, and it's the kind of sequencing that specialists who handle ERP rollouts like this build into the migration plan rather than discovering in week nine.

res.partner.lang is the switch that matters

Odoo decides what language to speak to a customer based on the lang field on the contact — not the language of the user who clicked the button. This is the field that governs invoice PDFs, quotation PDFs, portal pages and automated emails. It is also, in a freshly migrated database, almost always set to whatever the default was at import time.

qc = env['res.partner'].search([
    ('state_id.code', '=', 'QC'),
    ('lang', '!=', 'fr_CA'),
])
qc.write({'lang': 'fr_CA'})
Enter fullscreen mode Exit fullscreen mode

Do not run that blind. Province is a proxy for language preference, not the thing itself — plenty of Quebec-registered businesses correspond in English and will tell you so. What the query is genuinely good for is producing the list of contacts a human then has to make a decision about. In practice the sales team owns that list, and the field gets set as part of customer onboarding from then on.

Reports render in the language you pass them

A QWeb report does not automatically pick up the partner's language. You set it, either in the template:

<t t-call="web.html_container">
  <t t-foreach="docs" t-as="doc">
    <t t-set="lang" t-value="doc.partner_id.lang or 'en_US'"/>
    <t t-call="web.external_layout" t-lang="lang">
      <!-- ... -->
    </t>
  </t>
</t>
Enter fullscreen mode Exit fullscreen mode

or at the call site:

report = env.ref('account.account_invoices')
pdf, _ = report.with_context(
    lang=move.partner_id.lang
)._render_qweb_pdf('account.report_invoice_with_payments', move.ids)
Enter fullscreen mode Exit fullscreen mode

The standard accounting reports handle this correctly. Custom report templates written by someone in a hurry frequently do not, and the failure is silent — the PDF renders, it just renders in English. If you have custom reports, this is the first place to look.

Payment terms and the invoice footer deserve their own mention because they are separate records with their own translations, and a French invoice with English terms and conditions is exactly the artifact section 57 is about. Contracts of adhesion carry a further requirement — the French version must be presented first — which DLA Piper's summary of the June 2025 changes covers in more detail than I will here.

Mail templates need to be told, too

<field name="lang">{{ object.partner_id.lang }}</field>
Enter fullscreen mode Exit fullscreen mode

Without that line the template renders in the language of the user or cron job that triggered it. Sales order confirmations, invoice delivery and payment reminders are the three that customers see most, and all three ship with the expression set correctly in core — so this is a problem you create, not one you inherit. Check every mail.template your custom modules add.

Your custom module has no French until you ship a .po

Strings in your own code are untranslated until extracted. Wrap them, export, translate, commit to my_module/i18n/fr_CA.po:

odoo-bin -d mydb --i18n-export=fr_CA.po \
         --modules=my_module --language=fr_CA
Enter fullscreen mode Exit fullscreen mode

The extraction gotcha that catches everyone at least once:

# NOT extracted — the f-string is evaluated before _() sees it
raise UserError(_(f"Missing tax on line {line.name}"))

# extracted correctly
raise UserError(_("Missing tax on line %s", line.name))
Enter fullscreen mode Exit fullscreen mode

Ship the .po file in the module. Translating through the UI writes to the database, which means it does not survive a rebuild and does not exist in your other environments.

The honest limitation

None of this makes your instance compliant, and it would be dishonest to imply otherwise. It makes it capable of being compliant. Somebody with real French still has to write the product descriptions, because machine-translated SKU names read as machine-translated to a Quebec customer, and a bad French translation is worse for the relationship than an English one. Budget the translation work as translation work — a per-string cost with a human in the loop — not as a configuration task.

Two more things sit outside Odoo entirely: OQLF registration for employers with 25 or more staff, and the question of whether a heavily customised self-hosted instance makes you the software vendor for section 52.1 purposes. Ask a Quebec-qualified lawyer, not your systems integrator, and certainly not me.

Run the test a lawyer would run

Create a user with lang = fr_CA and no English fallback habit. Have them raise a quotation for a Quebec customer, confirm it, invoice it, and email the invoice. Then open every PDF and every email and read them as a customer in Montréal would — line items, tax labels, payment terms, footer, email subject and body.

Anything still in English is a finding. That takes twenty minutes and tells you more than any compliance checklist.

Top comments (0)