On a Django storefront, French shoppers still see some German checkout labels although the code uses gettext_lazy() — where is the lazy string being evaluated too early?
answer
- the proxy is not the problem
- what forces str at import?
- +, %, f-strings, .format()
- format_lazy and lazy()
basics
~20 sSomewhere a lazy proxy is forced into a str at import time: concatenation, %, an f-string, .format(), str() or a string method in module or class scope. The frozen text is in the import-time language. Keep composition lazy with format_lazy() or lazy(), or move it into function scope.
solid answer
~40 s`gettext_lazy()` only postpones translation until something turns the proxy into a `str`, and many innocent-looking operations do exactly that. `_("Price") + ":"`, `"%s*" % _("Postcode")`, `f"{_('Shipping')} ({code})"`, `_("street").capitalize()` or `str(_("City"))` written in a class body or at module level all translate once, at import, in whatever language was active then, here German from `LANGUAGE_CODE`. The same happens with lookup dicts built at import from `str()` labels, and with values memoised once and then served to every language. To find it, render the form with French active and look for labels that are plain `str` instead of lazy proxies. The fix is to keep composition lazy with `django.utils.text.format_lazy()`, wrap helpers with `django.utils.functional.lazy()`, or move the composition into `__init__()`, a method or the template.
code
python · 16 linesfrom django import forms
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
COUNTRY = "FR"
class ShippingForm(forms.Form):
# BUG: + and f-strings translate at import, in LANGUAGE_CODE ('de').
postcode = forms.CharField(label=_("Postcode") + " *")
method = forms.CharField(label=f"{_('Shipping')} ({COUNTRY})")
class FixedShippingForm(forms.Form):
postcode = forms.CharField(label=format_lazy("{} *", _("Postcode")))
method = forms.CharField(label=format_lazy("{} ({})", _("Shipping"), COUNTRY))go deeper
Recall that gettext_lazy only helps if nothing turns it into a normal string too early.
Explain which operations force a lazy proxy, and what format_lazy and lazy() do instead.
Diagnose partial translation by its pattern, prove the cause with a Promise check under an overridden language, and fix it without splitting sentences.
Invest in tests and lint rules that make import-time evaluation fail CI rather than surface as customer reports.
## The symptom and what it rules out Most labels on the French checkout are French, but a few, such as "Postleitzahl *" or "Versand (DE)", stay German. That pattern rules out the obvious causes: - the French catalog is compiled and loaded (most strings translate); - the language is being activated per request (most strings follow it); - the individual msgids exist, because each fragment translates correctly on its own. That last point needs checking, not assuming. Django falls back to the **`LANGUAGE_CODE` translation** for any msgid the active language lacks, so a missing French entry on a German-default site also shows German. Confirm that the French `.po` translates those msgids and was compiled before you go further. What remains is **when** those particular strings were translated. `gettext_lazy()` returns a proxy that translates **each time it becomes a `str`**. If something turns it into a `str` during import, the result is an ordinary string in the import-time language. For a site with `LANGUAGE_CODE = "de"`, that means German, forever, for every shopper. ## Operations that force a lazy proxy Django's lazy proxy defines `__str__`, `__format__`, `__add__`, `__radd__`, `__mod__` and friends. Each one runs `gettext()` **now** and returns a plain `str`: | Code at module or class scope | Why it freezes | |---|---| | `LABEL = _("Postcode") + " *"` | `+` calls the proxy's `__add__`, which translates immediately | | `"%s *" % _("Postcode")` | `%` formats the proxy into a new `str` | | `f"{_('Shipping')} ({COUNTRY})"` | f-strings call `__format__` | | `"{}: {}".format(_("Price"), _("incl. VAT"))` | `str.format()` converts every argument | | `_("street").capitalize()` | string methods run on the translated text and return a `str` | | `CHOICES = [(k, str(v)) for k, v in LABELS]` | explicit `str()` | | a memoised helper that returns translated text | the first caller's language is cached for everyone | A related trap is caching rendered fragments without the language in the key. Django's per-view cache adds the active language to its keys when `USE_I18N` is on, but a hand-rolled cache key does not. ## How to find the culprit 1. **Reproduce with a known language.** In a test, activate French (for example with `translation.override("fr")`) and render the form or page. Choosing the language per request is a separate topic. 2. **Inspect the labels.** `isinstance(form.fields["postcode"].label, Promise)` should be `True` for class-level labels. A plain `str` means it was forced somewhere. 3. **Search module and class scopes** for `_(` next to `+`, `%`, `f"`, `.format(`, `str(` or a string method. 4. **Check helper modules**, such as constants files and choice builders, that are imported early. ## How to fix it - **`format_lazy()`**: `django.utils.text.format_lazy("{} *", _("Postcode"))` returns a new lazy object that runs `str.format()` only when rendered. - **`lazy()`**: wrap any other function, for example `capitalize_lazy = lazy(lambda s: s.capitalize(), str)`, so the call runs at conversion time. - **Move composition to runtime**: build the combined label in the form's `__init__()`, in a property, or in the template (`{{ field.label }} *`). At that point the request's language is already active. - **Keep one sentence one msgid** where possible. `_("Postcode (required)")` is easier to translate than a glued fragment, and it avoids the problem entirely. - **Leave proxies alone until the boundary.** Convert with `str()` only where the value leaves Django, such as `json.dumps()` or a third-party client, while the request's language is active. ## Lazy-aware helpers Django provides `django.utils.functional` has the building blocks for helpers that must not force a proxy: - **`lazy(func, str)`** wraps a function so that calling it returns a proxy. `func` runs each time the proxy becomes a string. - **`keep_lazy(str)`** is a decorator for utility functions. If any argument is a lazy proxy, the call returns a proxy. Otherwise it runs immediately, so ordinary callers pay nothing. - **`keep_lazy_text`** is shorthand for `keep_lazy(str)`. Many of Django's own text utilities are decorated this way, which is why helpers like `capfirst()` are safe to apply to lazy labels in a class body, while a plain `str` method is not. ## Preventing a repeat - A test that renders key forms under two non-default languages and asserts on one label from each catches regressions cheaply. - A code-review rule, or a custom lint, that flags `+`, `%`, f-strings and `.format()` applied to `_()` results at module or class scope. - Import-time evaluation is only one cause. Missing msgids, uncompiled catalogs and wrong language selection produce different symptoms, where everything or nothing is translated, so check which pattern you are looking at before you start digging.
- Why does the wrong language show up only in production and not on a developer's machine?Locally, developers usually browse in the default language, so an import-time translation into `LANGUAGE_CODE` looks correct. In production the process imports modules once and then serves French shoppers from those frozen strings. If a module is imported lazily during some request, the frozen language can even differ from one worker process to the next.
- When is calling str() on a lazy translation the right thing to do?At the boundary where the value leaves Django, inside a request, with the right language active. Examples are building a payload for `json.dumps()`, a third-party HTTP client or a log field that must be a real string. Calling it there is correct. Calling it at import time, or storing the result in a module-level variable, is the bug.
saying these in an interview costs you the question
- If a field uses gettext_lazy(), anything built from it stays lazy.
- An f-string around a lazy string is evaluated when the template renders.
- Mixed-language pages always mean a missing or uncompiled .po file.
- Calling str() on a lazy string is always a bug.
- The fix is to call translation.activate() at the top of forms.py.