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
# 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:
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:
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:
_("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
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
{% 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
# 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
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:
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:
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:
{% translate "Open" context "verb, button label" %}
Translator comments attach a note to the extracted string:
# 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:
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
gettextwheregettext_lazyis needed, freezing the default language into model and form definitions.- Forgetting
compilemessages, so translations exist and do nothing. LocaleMiddlewarein the wrong position.- Committing
.mofiles. - Positional
%sin translatable strings. - Assuming a fuzzy entry is live. It is not.
- Hand-editing
Plural-Formsand gettingnpluralswrong.
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
-
Rails i18n: the complete guide
Setting up Rails internationalization properly: locale files, interpolation, pluralization, lazy lookup, fallbacks, and syncing translations from CI.
-
Plural rules by language: the complete CLDR guide
A reference table of CLDR plural categories for 163 languages, plus why one does not mean 1 and how each i18n framework expects you to spell the rules.
-
Placeholder formats cheat sheet: %s, %@, %{name}, {{name}}, {0}
Every string placeholder syntax you will meet, which language or framework uses it, and what breaks when a translator retypes one by hand.
-
Translate Gettext .po files (documentation)
How WebTranslateIt parses PO files, including plural forms, comments and fuzzy flags.
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.