Python and Django i18n: A practical guide for developers

Most JavaScript frameworks make you install a library before you can translate anything. Django is different. Internationalization has been part of the framework since its early releases, built on top of GNU gettext, the same translation system used by countless Unix tools and open-source projects.
That head start is useful, but it also means Django's i18n has its own vocabulary and its own file format. If you're used to JSON-based i18n libraries from the React or Vue world, PO files and gettext() calls will feel unfamiliar at first. This guide walks through how the pieces fit together: marking strings for translation, generating and compiling message files, handling plurals and locale-aware formatting, and syncing translations with a TMS instead of managing PO files by hand.
If you're new to internationalization concepts in general, our internationalization guide covers the architecture patterns that apply across every framework, including Django.
How Django's i18n system is put together
Three settings control whether Django's translation machinery is active at all:
# settings.py
USE_I18N = True
LANGUAGE_CODE = 'en'
LANGUAGES = [
('en', 'English'),
('es', 'Spanish'),
('pl', 'Polish'),
]
LOCALE_PATHS = [
BASE_DIR / 'locale',
]
LANGUAGE_CODE is the fallback language when nothing else is set. LANGUAGES restricts which locales your app actually offers, translations for anything outside that list are ignored. LOCALE_PATHS tells Django where to look for your PO and MO files, on top of each app's own locale/ directory.
Then there's LocaleMiddleware, which does the actual work of figuring out which language to serve for a given request:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
# ...
]
Order matters here. LocaleMiddleware has to come after SessionMiddleware and CacheMiddleware (if you use one), and before CommonMiddleware. Put it in the wrong spot and language detection either silently fails or reads a session that hasn't loaded yet.
Marking strings for translation in Python code
The standard pattern is to import gettext as _, a convention borrowed straight from the C gettext tools:
from django.utils.translation import gettext as _
def welcome_message(user):
return _("Welcome back, %(name)s") % {"name": user.first_name}
Don't use f-strings here.
_(f"Welcome back, {user.first_name}")looks tidier, but an f-string is evaluated beforegettextever sees it, somakemessagesextracts a differentmsgidfor every user instead of one translatable string. Use%(name)swith%formatting, or{name}with.format(), and pass the substitution as a separate step so the placeholder stays in the extracted string.
This works fine inside a view or a function, where the translation happens at the moment the code runs. It breaks down in places that get evaluated once at import time, like model field labels, form field labels, or class attributes:
# Wrong: evaluated once when the module loads, in whatever language is active then
from django.utils.translation import gettext as _
class Booking(models.Model):
status = models.CharField(max_length=20, verbose_name=_("Status"))
# Right: gettext_lazy defers translation until the value is actually rendered
from django.utils.translation import gettext_lazy as _
class Booking(models.Model):
status = models.CharField(max_length=20, verbose_name=_("Status"))
The two snippets look identical, the difference is entirely in the import. gettext_lazy returns a lazy proxy object instead of a string, and the real translation lookup happens later, when the object is actually used in a request. Model fields, form fields, and anything else defined at module load time should always use the lazy variant.
Using
gettextinstead ofgettext_lazyat module load time often works fine in development, where the server process restarts constantly, and breaks in production, where the module loads once and the "translation" gets frozen in whatever language happened to be active at startup.
Marking strings in templates
Templates use tags instead of function calls. Load the i18n tag library first:
{% load i18n %}
<h1>{% translate "Dashboard" %}</h1>
<p>{% blocktranslate %}You have {{ count }} unread messages.{% endblocktranslate %}</p>
{% translate %} handles single strings, including a % placeholder or an as clause to assign the result to a variable. {% blocktranslate %} is for strings that include template variables, since you can't interpolate a variable directly inside {% translate %}. It also supports pluralization directly:
{% blocktranslate count counter=objects|length %}
There is {{ counter }} object.
{% plural %}
There are {{ counter }} objects.
{% endblocktranslate %}
Older Django code often uses {% trans %} instead of {% translate %}, they're aliases and both still work, but {% translate %} is the current name in the docs.
Disambiguating identical strings with context
Some words translate differently depending on where they appear. "May" can be a month or a verb, "Post" can be a noun or a button label. Gettext handles this with msgctxt, a context string attached to a msgid so the same source text can carry two unrelated translations.
In Python code, use pgettext instead of gettext:
from django.utils.translation import pgettext
# Useful when the same word means different things depending on context
month_label = pgettext("calendar month", "May")
verb_label = pgettext("permission verb", "May")
Templates take a context argument on {% translate %}:
{% translate "May" context "calendar month" %}
Without a context, Django would extract a single msgid "May" and force one translation to cover both meanings, which is wrong in almost every language. Keep this in mind now, since it comes back later when syncing PO files with a TMS: msgctxt is how SimpleLocalize and similar tools know that two identical-looking strings are actually two separate translation keys.
Generating and compiling message files
Once your code is marked up, Django can scan it and produce a template of every string it found. This is the makemessages command:
django-admin makemessages -l en
This creates:
locale/
en/
LC_MESSAGES/
django.po
Open that file and you'll see entries like this:
#: templates/dashboard.html:4
msgid "Dashboard"
msgstr ""
#: booking/models.py:12
msgid "Status"
msgstr ""
msgid is the original string extracted from your code. msgstr is where the translation goes, empty for now since en is your source language. Run makemessages again for each additional locale (-l es, -l pl) to generate a PO file per language, then fill in the msgstr values by hand, with a translator, or through a TMS.
None of this reaches your running application until you compile the PO files into the binary MO format Django actually reads at runtime:
django-admin compilemessages
Always run
django-admin compilemessagesduring your deployment or build process. Django reads compiled.mofiles at runtime, not.pofiles. A translated PO file with an uncompiled MO file next to it does nothing, and it's a common source of "I translated this, why is it still in English" bug reports. Add the command to your deployment script so it never depends on someone remembering to run it manually.
Pluralization: gettext plural rules, not ICU
If you've worked with i18next or FormatJS, you're used to ICU plural categories (one, few, many, other). Gettext handles plurals differently: each language defines a Plural-Forms header with a C-style expression that maps a number to a plural index, and the PO file provides one msgstr[N] per index.
In Python code, use ngettext:
from django.utils.translation import ngettext
message = ngettext(
"%(count)d room booked",
"%(count)d rooms booked",
count,
) % {"count": count}
English only needs two forms, but Polish needs three, and the PO file reflects that:
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && "
"(n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "%(count)d room booked"
msgid_plural "%(count)d rooms booked"
msgstr[0] "%(count)d pokój zarezerwowany"
msgstr[1] "%(count)d pokoje zarezerwowane"
msgstr[2] "%(count)d pokoi zarezerwowanych"
makemessages writes the correct Plural-Forms header automatically based on the locale, you don't need to write that expression yourself. For a deeper look at how plural categories work across languages in general, see our pluralization guide.
Locale-aware URLs and language switching
For a public-facing site, encoding the locale in the URL is usually the right call for SEO, since search engines can index each language separately. Django supports this through i18n_patterns:
# urls.py
from django.conf.urls.i18n import i18n_patterns
urlpatterns = i18n_patterns(
path('', views.home, name='home'),
path('pricing/', views.pricing, name='pricing'),
)
This produces /en/pricing/, /es/pricing/, and so on, and LocaleMiddleware picks the right locale from the URL prefix on every request. For a broader comparison of URL-based detection versus cookies and headers, our locale detection strategies guide covers the trade-offs in more depth.
If you want an explicit language selector instead of relying purely on detection, Django ships a built-in view for that:
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<select name="language" onchange="this.form.submit()">
{% get_current_language as CURRENT_LANGUAGE %}
{% get_available_languages as LANGUAGES %}
{% for code, name in LANGUAGES %}
<option value="{{ code }}" {% if code == CURRENT_LANGUAGE %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</form>
set_language needs django.conf.urls.i18n.set_language wired into your URLconf, and it stores the choice in a cookie (or the session, if django.contrib.sessions is installed), so it persists across visits.
Translating strings in JavaScript
Everything so far runs on the server. If your frontend has JavaScript that needs translated strings too, for a date picker, a validation message, a toast notification, Django can export your PO catalog as a script the browser can use, instead of maintaining a second set of translations by hand.
# urls.py
from django.views.i18n import JavaScriptCatalog
urlpatterns += [
path('jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
]
Load that URL as a script tag, and the same gettext, ngettext, and pgettext functions become available in JavaScript, reading from the same PO files you already generate with makemessages:
<script src="{% url 'javascript-catalog' %}"></script>
<script>
document.getElementById("save-btn").textContent = gettext("Save changes");
</script>
Dates, numbers, and locale-aware formatting
Since Django 5.0, locale-aware formatting of numbers and dates is always on. Earlier versions gated this behind a USE_L10N setting, but that setting was removed, Django now formats a date or a decimal using the current locale's conventions by default, with no configuration needed.
from django.utils import formats
formats.date_format(some_date, format='DATE_FORMAT')
formats.number_format(1234.5)
If you need to opt out for a specific value, for instance an API response that should always return a machine-readable format regardless of the request's locale, use the {% localize off %} template tag or the localize template filter rather than reaching for a global setting.
Testing translations before real content exists
You don't need finished translations to catch layout and hardcoding bugs. Django's makemessages also works well with pseudo-localization, where you generate a fake locale with accented, expanded characters and check that your templates hold up:
django-admin makemessages -l xx_PSEUDO
Fill that PO file with expanded, accented versions of your source strings (a script or a TMS's pseudo-localization feature can do this automatically), and you'll catch text overflow and hardcoded strings before a single professional translator is involved. Our pseudo-localization guide covers the technique in more detail.
Syncing Django's PO files with a translation platform
Editing PO files by hand works for a small project with one or two languages. It stops working once you have several languages, several contributors, and a release cadence that doesn't wait for someone to manually merge translation files.
Since Django's translations are plain PO files, you can manage them the same way you'd manage any gettext-based project: extract with makemessages, upload the source file to a translation platform, let translators or auto-translation fill it in, then download and compile. SimpleLocalize supports the PO/POT format directly, so no conversion step is needed.
A simplelocalize.yml configuration file for a Django project looks like this:
apiKey: YOUR_PROJECT_API_KEY
uploadFormat: po-pot
uploadLanguageKey: en
uploadPath: ./locale/en/LC_MESSAGES/django.po
uploadOptions:
- REPLACE_TRANSLATION_IF_FOUND
- MSGCTXT_AS_NAMESPACE
downloadFormat: po-pot
downloadLanguageKey: ['pl', 'fr', 'es']
downloadPath: ./locale/{lang}/LC_MESSAGES/django.po
downloadOptions:
- MSGCTXT_AS_NAMESPACE
REPLACE_TRANSLATION_IF_FOUND keeps your English source strings up to date in the editor whenever you re-upload. MSGCTXT_AS_NAMESPACE matters if you use msgctxt to disambiguate two identical msgid values that need different translations in different places, a common gettext pattern that JSON-based i18n libraries usually solve with separate keys instead.
With that file in place, the workflow is two commands:
simplelocalize upload
simplelocalize download
django-admin compilemessages
Wire this into your CI pipeline (a scheduled job or a step after makemessages runs) and new strings get uploaded, translated, and pulled back automatically. See our continuous localization guide for how to structure that pipeline around a release cycle, and our PO and POT files guide for more on the format itself, since the same approach applies to gettext-based Flask and Pyramid apps.
Where Django i18n commonly goes wrong
Beyond the compilemessages and gettext_lazy gotchas above, a couple of other mistakes show up repeatedly in Django codebases:
-
Mismatched
LocaleMiddlewareorder.
If session-based language switching stops working after a refactor, check the middleware order first. It has to sit afterSessionMiddlewareand beforeCommonMiddleware, and it's the most common cause of "language switching just stopped working." -
Assuming two plural forms are enough.
Testing only in English hides plural bugs completely, since English only hasoneandother. Add a locale with three or more plural forms, like Polish or Russian, to your test matrix early, not after a translator reports broken counts in production.
Wrapping up
Django's i18n system predates most of the JavaScript ecosystem's approach to translation, and it shows: gettext's PO files, the _() convention, and C-style plural expressions all come from a different era of tooling than JSON-based libraries. The concepts map cleanly onto general i18n principles though, translation keys are msgid values, translation resources are PO files, and a locale detection strategy is still a locale detection strategy.
Once your strings are extracted into PO files, treat them the same way you'd treat any other translation resource: sync them with your team or a TMS, automate the extract-translate-compile cycle in CI, and test with more than one plural form before you ship to a market that needs it.




