What Odoo Renders by Default
When you ship Odoo to a US customer, they will see $ 100.00 on invoices — not $100.00. There is a space between the dollar sign and the amount. Most customers will not articulate the problem. They will just notice that something looks slightly off, and that feeling will attach to your product.
The issue is not cosmetic in isolation. It is a symptom of an architectural choice that makes correct formatting for multiple locales simultaneously impossible.
The Spacing Problem
Odoo unconditionally inserts a non-breaking space between the currency symbol and the formatted amount, in both Python and JavaScript. For USD in en_US, standard typography requires no space at all:
| Locale | Currency | Odoo output | Correct | Failure |
|---|---|---|---|---|
| en_US | USD | $ 100.00 |
$100.00 |
Spurious space after symbol |
| en_US | EUR | 100.00 € |
€100.00 |
Symbol on wrong side |
The en_US + EUR row deserves a note. EUR's symbol position in Odoo defaults to after — which happens to produce the correct French format (100,00 €) by coincidence. But an American viewing a Euro price expects the symbol before the amount with no space, per Intl.NumberFormat CLDR data. Odoo has no mechanism to satisfy both.
The Position Problem
Symbol position — before or after — is stored as a single value on the res.currency database record. Every user in the system sees the same symbol position for a given currency, regardless of their language setting. There is no per-locale override.
For EUR, this creates an impossible choice. Dutch convention places the symbol before the amount (€ 100,00). French convention places it after (100,00 €). A system serving both locales must pick one:
| Locale | EUR DB config | Odoo output | Correct | Result |
|---|---|---|---|---|
| nl_NL | position=after (default) | 100,00 € |
€ 100,00 |
Wrong side |
| fr_FR | position=after (default) | 100,00 € |
100,00 € |
Accidentally correct |
| nl_NL | position=before | € 100,00 |
€ 100,00 |
Accidentally correct |
| fr_FR | position=before | € 100,00 |
100,00 € |
Wrong side |
Whatever you set EUR to, you are fixing one locale and breaking another. The database column model cannot represent a locale-dependent property.
Three Architectural Mistakes
1. Symbol Position Is Global Database State
Odoo stores the currency symbol position — before or after — as a field on res.currency. This is a single value in the database, shared across every user in the system regardless of their language setting. There is one row for EUR, and that row says the symbol goes either before or after the amount for everyone.
Symbol position is a locale property, not a currency property. EUR goes before the amount in Dutch (€ 100,00) but after the amount in French (100,00 €). A German user and a French user looking at the same Euro invoice should see different symbol positions. The database column model cannot represent this.
2. Hardcoded Spacing in Python
The formatting functions in odoo/tools/misc.py assemble the final string by concatenating the symbol and the formatted number with a non-breaking space unconditionally:
return '%s\N{NO-BREAK SPACE}%s' % (currency.symbol, formatted_amount)
There is no locale lookup. The space is always there. For US dollar formatting — where standard typography requires no space between the symbol and the amount — this is wrong by definition. The fix is not to remove the space globally but to let the locale determine whether a space is needed and, if so, on which side.
3. Hardcoded Spacing in JavaScript
The frontend has the same problem. @web/core/currency.js and the MonetaryField component in Odoo's OWL framework replicate the Python logic: symbol position is read from the currency record, a non-breaking space is inserted unconditionally, and Intl.NumberFormat — the browser's built-in locale-aware formatting API — is not consulted.
Because the Python and JavaScript layers independently implement the same flawed approach, the fix requires patching both. Patching one without the other produces inconsistent output between UI views and generated documents.
The Correct Sources of Truth
Both Python and the browser ship with libraries that already know the correct formatting rules for every locale — symbol position, spacing, decimal separator, thousands grouping. They use CLDR (Common Locale Data Repository) data, the same standard that operating systems, browsers, and native apps use.
In Python, that is Babel's format_currency(). Odoo already lists Babel as a dependency. It is present. It is just not used for this purpose.
In the browser, that is Intl.NumberFormat, available in every modern browser. Odoo uses it in other places. It is simply bypassed in the currency formatting path.
Both libraries accept a locale identifier (such as en_US or fr_FR) and a currency code (such as USD or EUR), and return a correctly formatted string. No manual symbol placement. No hardcoded spaces. The locale data is authoritative and maintained externally.
Patching the Python Backend
Three functions in odoo.tools.misc handle monetary formatting on the backend. All three need to be replaced:
formatLang()— general-purpose locale-aware formatting, used widely in QWeb templates and server-side renderingformat_amount()— formats a raw float with a currency, the most commonly called function in reports and invoice PDFsformat_decimalized_amount()— a variant that can abbreviate large numbers (e.g.,1.2k)
The replacement uses Babel's format_currency(), adjusting the CLDR pattern to enforce the currency's own decimal precision rather than relying on the locale default. The language code for the current user's session is resolved from the Odoo context and normalized to the ll_CC format Babel expects:
from babel.numbers import format_currency, LC_NUMERIC
def format_amount_babel(env, amount, currency, lang_code=None):
lang_code = _get_lang(env, lang_code) # e.g. 'en_US', 'fr_FR'
pattern = _adjust_currency_pattern(lang_code, currency.decimal_places)
result = format_currency(
amount,
currency.name,
format=pattern,
locale=lang_code,
)
return _nbspify(result)
_adjust_currency_pattern() rewrites Babel's CLDR pattern to enforce the exact decimal digit count the currency requires — USD needs 2, JPY needs 0, some custom currencies need 4. Without this step, Babel may apply locale defaults that disagree with the currency's own rounding configuration.
_nbspify() converts any remaining regular spaces to non-breaking spaces. Babel itself may output a regular space between the symbol and the amount for locales that require one (such as fr_FR). This ensures line-break behavior stays consistent across all output contexts.
The patched functions are registered as monkey-patches over misc.format_amount and tools.format_amount at module load time via Odoo's post_load hook. Every caller — QWeb templates, invoice PDFs, report controllers — inherits the correct behavior without any per-caller changes.
Patching the JavaScript Frontend
The frontend requires three separate patches plus a full module replacement. They target different parts of Odoo's OWL framework.
The Registry Formatter
Odoo's field rendering system uses a formatter registry. The monetary formatter entry is what list views, kanban cards, and read-only form fields call to display monetary values. This formatter is replaced with one that uses Intl.NumberFormat:
const { amount, currencyId } = getIntlData(value, options);
return new Intl.NumberFormat(userLocale, {
style: 'currency',
currency: currencyCode,
}).format(amount);
Intl.NumberFormat with style: 'currency' handles everything: symbol position, spacing, decimal separator, thousands grouping. For en_US with USD it returns $100.00. For fr_FR with EUR it returns 100,00 €. No manual assembly required.
The Float Formatter for price_unit
Odoo renders price_unit fields — unit prices on sale order lines, invoice lines, and similar — using the float formatter rather than the monetary formatter, because the field type is Float not Monetary. This means currency symbols are absent in list views for these fields by default.
A scoped patch on the float registry entry detects when the field being rendered is named price_unit and delegates to the monetary formatter. All other float fields are left untouched — this avoids accidentally reformatting quantities, tax rates, or other numeric fields that should not carry a currency symbol.
The MonetaryField Component
MonetaryField is the OWL component used for editable monetary inputs in form views. It has a built-in nbsp property that unconditionally inserts the hardcoded space. Three getters are patched:
setup()— setsthis.nbsp = "", disabling Odoo's built-in spacingcurrencygetter — callsIntl.NumberFormatto analyze the formatted output and determine the correct symbol position for the current localeformattedValuegetter — in read-only mode, returns the full Intl-formatted string directly instead of assembling it from parts
Replacing @web/core/currency.js
Odoo's asset pipeline loads @web/core/currency.js as a named module. Other parts of the frontend import it by that name to get the formatCurrency() utility function. To replace the module's behavior without breaking those imports, the module file itself needs to be substituted at load time.
This is done by overriding ir.asset's asset resolution logic. When the pipeline requests the original path, it is redirected to the module's own replacement file — but crucially, the module name remains @web/core/currency, so all existing import { formatCurrency } from "@web/core/currency" calls resolve to the new implementation transparently:
class IrAsset(models.Model):
_inherit = 'ir.asset'
def _get_asset_paths(self, bundle, ...):
paths = super()._get_asset_paths(bundle, ...)
return [
(our_currency_js if p == odoo_currency_js else p, ...)
for p, ... in paths
]
The replacement currency.js exports the same formatCurrency function signature but routes through Intl.NumberFormat. No call sites need updating.
All five patches — three Python, two JavaScript — wrap their logic in try/except (Python) or try/catch (JS) blocks and fall back to the original Odoo implementation on any error. This means the module degrades gracefully if a locale code is unrecognized or Babel is missing an entry, rather than crashing the renderer.
Why You Need Both Layers
Python and JavaScript render monetary values in entirely different contexts. Patching one without the other produces inconsistent output — correct on invoices but wrong in list views, or vice versa.
| Layer | What it drives |
|---|---|
| Python (backend) | QWeb invoice PDFs, server-rendered reports, email templates, format_amount calls in computed fields and wizards |
| JavaScript (frontend) | Form views, list views, kanban cards, x2many inline tables, any OWL component that displays a monetary value |
A customer who sees a correct amount in the Odoo UI and then receives a PDF invoice with the wrong formatting will notice the inconsistency even if they cannot name it. Both layers need to produce output from the same source of truth — CLDR locale data — and they need to do so independently, because they run in different runtimes with different library ecosystems.
The module is available at github.com/StrictBase/odoo-addons under LGPL-3.0. It targets Odoo 19 CE and has no dependencies beyond Babel, which Odoo already ships.