You have a Django app that works in English. Now you need it to work in other languages too. This guide covers the full process, from marking strings in your code to serving a fully localized site, with practical examples at every step.
Django's internationalisation framework (usually called i18n) is one of the better ones out there. It handles right-to-left languages, pluralization rules, date formats, and all the other things that make localization genuinely hard. The catch is that the setup involves several moving parts, and most tutorials only cover one or two of them.
What localization actually involves
Localization is more than just translating strings. A properly localized Django app handles:
- Text translation (templates, views, models, JavaScript)
- URL routing per language (
/en/about/,/de/about/) - Date, time, and number formatting (14/03/2026 vs 03/14/2026)
- Pluralization rules (English has 2 forms, Arabic has 6, Japanese has 1)
- Right-to-left layout for Arabic, Hebrew, Farsi
- Time zone awareness
Most Django projects only need the first three. But it's worth knowing what the framework can do before you start, so you don't paint yourself into a corner.
Step 1: configure your settings
Start with the internationalization settings in settings.py:
from django.utils.translation import gettext_lazy as _
LANGUAGE_CODE = "en"
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGES = [
("en", _("English")),
("de", _("German")),
("fr", _("French")),
("ja", _("Japanese")),
("es", _("Spanish")),
]
LOCALE_PATHS = [
BASE_DIR / "locale",
]
LANGUAGES defines which languages your site supports. Only include languages you actually plan to translate. Adding 30 languages here without translating them just creates empty .po files and confuses your URL structure.
LOCALE_PATHS tells Django where to look for translation files. The default is a locale/ directory in each app, but a single project-level locale/ directory is easier to manage.
Step 2: add middleware and URL configuration
Add the locale middleware to your MIDDLEWARE setting. Order matters here:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", # after SessionMiddleware
"django.middleware.common.CommonMiddleware",
# ...
]
LocaleMiddleware must come after SessionMiddleware (it needs the session to remember language preferences) and before CommonMiddleware. Getting this order wrong is one of the most common reasons Django translations silently fail.
Then wrap your URL patterns with i18n_patterns:
from django.conf.urls.i18n import i18n_patterns
urlpatterns = [
path("admin/", admin.site.urls),
]
urlpatterns += i18n_patterns(
path("", include("myapp.urls")),
)
This prefixes all your URLs with the langauge code: /en/about/, /de/about/, /fr/about/. The admin stays unprefixed since it doesn't need localization in most projects.
Step 3: mark strings for translation
This is where most of the work happens. Every user-facing string in your code needs to be wrapped in a translation function.
In Python code
from django.utils.translation import gettext as _
def my_view(request):
message = _("Your order has been placed.")
return render(request, "order.html", {"message": message})
Use gettext (aliased as _) in views and functions. For module-level strings like model field labels, use gettext_lazy:
from django.utils.translation import gettext_lazy as _
class Product(models.Model):
name = models.CharField(_("product name"), max_length=200)
description = models.TextField(_("description"))
class Meta:
verbose_name = _("product")
verbose_name_plural = _("products")
The difference matters: gettext_lazy delays the translation lookup until the string is actually rendered, which is necesary for anything evaluated at import time (model definitions, form fields, URL names). Using regular gettext at module level will translate once at startup and serve the wrong language to everyone.
In templates
{% load i18n %}
<h1>{% trans "Welcome to our store" %}</h1>
<p>
{% blocktrans count items=cart.item_count %}
You have {{ items }} item in your cart.
{% plural %}
You have {{ items }} items in your cart.
{% endblocktrans %}
</p>
{% trans %} handles simple strings. {% blocktrans %} handles strings with variables or pluralization. Don't forget {% load i18n %} at the top of every template that uses these tags.
In JavaScript
Django can generate a JavaScript catalog of your translations:
from django.views.i18n import JavaScriptCatalog
urlpatterns += i18n_patterns(
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
)
Then in your JavaScript:
document.getElementById("status").textContent = gettext("Loading...");
Include the catalog script before any JavaScript that uses gettext:
<script src="{% url 'javascript-catalog' %}"></script>
Step 4: generate and translate .po files
Once your strings are marked, generate the translation files:
python manage.py makemessages -l de -l fr -l ja -l es
This creates .po files in your locale/ directory:
locale/
de/LC_MESSAGES/django.po
fr/LC_MESSAGES/django.po
ja/LC_MESSAGES/django.po
es/LC_MESSAGES/django.po
Each .po file contains entries like this:
#: myapp/views.py:12
msgid "Your order has been placed."
msgstr ""
The msgstr is where the translation goes. You can fill these in manually, hire translators, or automate the process with AI translation tools.
After translating, compile the .po files into binary .mo files that Django can read efficently:
python manage.py compilemessages
Run compilemessages every time you update translations. Forgetting this step is another common reason translations don't show up. Django reads the .mo files, not the .po files.
Step 5: handle date, number, and time formatting
Django's L10N setting controls how dates and numbers are formatted. With USE_L10N = True, Django automaticly uses locale-aware formatting:
{% load l10n %}
<p>Order date: {{ order.created_at }}</p>
<p>Total: {{ order.total }}</p>
A date that shows as "July 10, 2026" in English will automatically display as "10. Juli 2026" in German and "2026年7月10日" in Japanese. Numbers follow similar rules: 1,234.56 in English becomes 1.234,56 in German.
If you need to override the format for specific values, use the localize and unlocalize filters:
{% load l10n %}
<!-- Always use locale formatting -->
<p>{{ value|localize }}</p>
<!-- Always use the default (English) format -->
<p>{{ value|unlocalize }}</p>
Step 6: add a language switcher
Users need a way to change languages. Here's a simple language switcher that works with Django's set_language view:
{% load i18n %}
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<select name="language" onchange="this.form.submit()">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% for lang_code, lang_name in LANGUAGES %}
<option value="{{ lang_code }}"
{% if lang_code == LANGUAGE_CODE %}selected{% endif %}>
{{ lang_name }}
</option>
{% endfor %}
</select>
</form>
Make sure the set_language URL is included in your URL configuration:
urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")),
# ...
]
This should be outside i18n_patterns since the language switcher itself doesn't need a language prefix.
Common mistakes and how to avoid them
Using gettext instead of gettext_lazy in models
This is the single most common localization bug in Django projects. Model fields, form labels, and anything else defined at module level needs gettext_lazy, not gettext. The symptom: every user sees translations in whatever language the server process started with.
Forgetting to run compilemessages
You edited the .po file, restarted the server, but translations still don't show up. The .mo file is what Django reads, and it's not updated automaticaly. Add compilemessages to your deployment pipeline.
Wrong middleware order
LocaleMiddleware after CommonMiddleware means Django can't detect the language from the URL prefix. The request falls through to the default language every time. Put it between SessionMiddleware and CommonMiddleware.
Hardcoded strings in templates
It's easy to miss strings that should be translated. Search your templates for bare text outside of {% trans %} or {% blocktrans %} tags. Common offenders: button labels, form field placeholders, error mesages, and footer text.
Not handling pluralization
English has two plural forms (1 item, 2 items). Russian has three. Arabic has six. If you hardcode "item(s)" instead of using ngettext or {% blocktrans count %}, your translations will read awkwardly or be grammatically wrong in other languages.
Automating the translation workflow
Manually translating .po files works for small projects, but it doesn't scale. A typical Django project with 200 translatable strings across 5 languages means 1,000 translations to manage. Every time you add or change a string, you need to update all languages.
The workflow usually looks like this:
- Mark new strings with
_()or{% trans %} - Run
makemessagesto extract them - Translate the new entries in each
.pofile - Run
compilemessages - Commit everything
Steps 3 and 4 are where things slow down. Human translators add latency and cost. Manual copy-pasting into translation services is tedious and error-prone, especially when you need to preserve Django's placeholder formats like %(name)s or {count}.
AI translation tools can automate step 3 while keeping step 4 in your CI/CD pipeline. The tradeoff is that AI translations may need review for nuance and terminology, but for most UI strings the quality is good enought to ship directly.
Testing your localization
Before deploying, verify that your localization actually works:
# Generate messages for all configured languages
python manage.py makemessages -a
# Check for missing translations
python manage.py compilemessages
# Start the dev server and switch languages
python manage.py runserver
Things to check:
- Switch between all supported languages using the language switcher
- Verify that URLs include the correct language prefix
- Check that dates and numbers format correctly per locale
- Look for untranslated strings (they'll appear in English)
- Test right-to-left languages if you support them
- Verify that pluralization works correctly (test with 0, 1, 2, and 5+ items)
Consider adding a CI check that fails if any .po file has untranslated or fuzzy entries. This prevents accidental deployments with missing translations.
Project structure
A well-organized localization setup looks like this:
myproject/
locale/
de/LC_MESSAGES/django.po
fr/LC_MESSAGES/django.po
ja/LC_MESSAGES/django.po
es/LC_MESSAGES/django.po
myapp/
templates/
views.py
models.py
settings.py
urls.py
TRANSLATING.md
The TRANSLATING.md file is optional but useful. It documents your terminology preferences, tone guidelines, and any translation-specific rules for your project. If you use AI translation tools that support context files, they can read this and apply your preferences automaticaly.
What to do next
If you're starting from scratch:
- Add the settings and middleware from steps 1 and 2
- Pick your most important template and mark its strings (step 3)
- Generate
.pofiles for one language first (step 4) - Translate that one language and verify it works end to end
- Then expand to more templates and languages
If you already have a partially localized app:
- Run
makemessages -ato regenerate all.pofiles - Check for fuzzy or untranslated strings
- Fill in missing translations
- Set up a CI check so new untranslated strings are caught before they reach producion
Starting small and expanding is better than trying to localize everything at once. Get one language working completely, then add more.