Django translation guide

Django internationalization end to end: gettext markers, lazy translation, plurals, template tags, locale middleware and the makemessages workflow.

Django’s internationalization is built on GNU gettext, which makes it mature, well understood, and a little more ceremonious than frameworks that read plain data files.

Turning it on

python
# settings.py
USE_I18N = True
USE_TZ = True

LANGUAGE_CODE = "en-us"
LANGUAGES = [
    ("en", "English"),
    ("fr", "Français"),
    ("de", "Deutsch"),
]

LOCALE_PATHS = [BASE_DIR / "locale"]

MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",   # after session, before common
    "django.middleware.common.CommonMiddleware",
]

LocaleMiddleware position is load-bearing. It must come after SessionMiddleware, because it reads the language from the session, and before CommonMiddleware, because URL resolution depends on the active language. Placed wrong, it appears to work and then fails on specific paths.

Note also the split that catches people: LANGUAGE_CODE uses a hyphenated lowercase tag (en-us, fr-ca), while the locale directories use the POSIX underscore form (locale/fr_CA/LC_MESSAGES/). Both are correct, in the same project, by design — see en-GB vs en_GB.

Marking strings

In Python:

python
from django.utils.translation import gettext as _

def dashboard(request):
    message = _("Welcome back")

In modules evaluated at import time — models, forms, admin — use the lazy variant:

python
from django.utils.translation import gettext_lazy as _

class Project(models.Model):
    name = models.CharField(_("name"), max_length=100)

    class Meta:
        verbose_name = _("project")
        verbose_name_plural = _("projects")

This distinction is the single most important thing to get right. At import time there is no request and no active locale, so gettext resolves against whatever the default happens to be and freezes that result forever. gettext_lazy returns a promise that resolves when rendered, which is what you want.

The rule: module level or class body → lazy. Inside a function called per request → eager.

Lazy strings are not real strings, which occasionally surprises. Concatenating one with + fails; use format_lazy or interpolate at render time.

Interpolation

Always use named placeholders:

python
_("Welcome back, %(name)s") % {"name": user.first_name}

Never positional %s in a translatable string. Translators cannot reorder positional arguments, and word order differs between languages — see the placeholder formats cheat sheet.

Plurals

python
from django.utils.translation import ngettext

ngettext(
    "%(count)d message",
    "%(count)d messages",
    count,
) % {"count": count}

You supply the English singular and plural; gettext selects the right form per language using the Plural-Forms header in the PO file:

"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"

Two consequences worth internalising. First, gettext’s plural expression returns an index, not a category name, so msgstr[0] is whatever the expression maps to zero — conventionally but not necessarily the singular. Second, the number of forms is a property of the target language: Polish needs four, Arabic six. makemessages writes the correct header for known languages, but a hand-edited PO with the wrong nplurals fails silently for the numbers it does not cover. See plural rules by language.

Templates

django
{% load i18n %}

<h1>{% translate "Dashboard" %}</h1>

{% blocktranslate with name=user.first_name %}
  Welcome back, {{ name }}
{% endblocktranslate %}

{% blocktranslate count counter=messages|length %}
  There is {{ counter }} message.
{% plural %}
  There are {{ counter }} messages.
{% endblocktranslate %}

translate handles simple strings; blocktranslate handles anything with variables or plurals. Inside blocktranslate you can only use simple variables — no filters, no method calls — so compute values in the view or bind them with with.

{% translate %} and {% blocktranslate %} replaced {% trans %} and {% blocktrans %} in Django 3.1. Both spellings still work; use the new ones.

The makemessages workflow

bash
# Scan the codebase and write/update locale/fr/LC_MESSAGES/django.po
django-admin makemessages -l fr

# Also scan JavaScript
django-admin makemessages -d djangojs -l fr

# Compile .po to the .mo Django actually reads
django-admin compilemessages

compilemessages is the step that gets forgotten. Django reads the compiled binary, so an updated .po with a stale .mo produces the exact symptom of “I translated it and nothing changed”. Run it in your build, not by hand.

Do not commit .mo files. They are build artefacts, they conflict constantly in Git, and they carry nothing the .po does not.

Two makemessages flags worth knowing: --no-obsolete drops strings no longer present in the source rather than leaving them commented out, and --no-location strips the file-and-line comments that otherwise produce enormous diffs every time code moves.

Fuzzy entries

When a source string changes slightly, gettext marks the existing translation #, fuzzy and keeps it as a starting point. Django ignores fuzzy entries at runtime, falling back to the source language, so a fuzzy translation is functionally a missing one until a translator confirms it.

This is reasonable behaviour and it surprises people who see a translation sitting in the PO file and cannot understand why the page shows English.

URLs and language switching

python
from django.conf.urls.i18n import i18n_patterns

urlpatterns = i18n_patterns(
    path("dashboard/", views.dashboard, name="dashboard"),
    prefix_default_language=False,
)

This produces /dashboard/ for the default language and /fr/dashboard/ for French. prefix_default_language=False keeps your canonical URLs unprefixed, which is usually what you want for SEO — and pair it with hreflang alternates so search engines understand the relationship between the two.

Syncing translations

PO files are the interchange format, so they go straight to a translation platform:

bash
wti push      # locale/en/LC_MESSAGES/django.po up
wti pull      # translated PO files back
wti diff      # what a push would change

Then compilemessages in your build. Push on merge to your designated branch, pull nightly into a pull request — see Git-based localization workflows.

Contextual markers and translator notes

Two gettext features Django exposes that solve real ambiguity, and that most projects never use.

pgettext disambiguates identical source strings. The word Open as a button and Open as a status are the same string, so gettext merges them into one entry and one translation — which is wrong in most languages:

python
from django.utils.translation import pgettext

pgettext("verb, button label", "Open")
pgettext("adjective, ticket status", "Open")

These become separate PO entries with a msgctxt, so a translator can render them differently. In templates:

django
{% translate "Open" context "verb, button label" %}

Translator comments attach a note to the extracted string:

python
# Translators: shown when a payment fails. Keep it non-blaming.
message = _("We could not process your card")

makemessages copies any comment beginning Translators: into the PO file, where the translator actually sees it. The prefix is required — an ordinary comment is not extracted.

Between them these cover most of what a translator needs and cost seconds at the moment the string is written, when the context is already in your head. See visual context for translators for why reconstructing it later never happens.

Testing

Assert on behaviour rather than on translated text, and use override to pin the locale:

python
from django.test import TestCase
from django.utils.translation import override

class DashboardTests(TestCase):
    def test_renders_in_french(self):
        with override("fr"):
            response = self.client.get("/dashboard/")
        self.assertEqual(response.status_code, 200)

The check worth adding to CI is completeness: every .po has a translation for every msgid, no entries are left fuzzy, and nplurals matches what the language actually requires. Django ignoring fuzzy entries at runtime makes that last point matter more than it looks — a PO file can be 100% “translated” and still serve English.

Common mistakes

  • gettext where gettext_lazy is needed, freezing the default language into model and form definitions.
  • Forgetting compilemessages, so translations exist and do nothing.
  • LocaleMiddleware in the wrong position.
  • Committing .mo files.
  • Positional %s in translatable strings.
  • Assuming a fuzzy entry is live. It is not.
  • Hand-editing Plural-Forms and getting nplurals wrong.

Frequently asked questions

What is the difference between gettext and gettext_lazy in Django?
gettext translates immediately using the active locale. gettext_lazy defers translation until the string is rendered. Use lazy for anything evaluated at import time — model fields, form labels, choices — because at import time there is no request and therefore no active locale.
How do I create translation files in Django?
Run django-admin makemessages -l fr to scan your code for translatable strings and generate locale/fr/LC_MESSAGES/django.po. After translating, run compilemessages to produce the .mo binary Django actually reads at runtime.
Why are my Django translations not showing?
The usual causes are forgetting compilemessages, a LOCALE_PATHS that does not include your locale directory, LocaleMiddleware missing or in the wrong position, or USE_I18N set to False. Check compilemessages first — an updated .po with a stale .mo is the most common case.
Does Django support ICU MessageFormat?
Not natively. Django uses gettext, whose plural support is a numeric index expression rather than named CLDR categories, and which has no equivalent of ICU select or nested formatting. Teams that need ICU generally add a separate library for those messages.

Keep reading

Translate your app without the spreadsheet round-trip

WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.