How to Localize a Django App: A Practical Guide

12 min read
by Arvid Andersson
Django Python i18n gettext Localization
How to Localize a Django App: A Practical Guide

Introduction

Django has built-in internationalization powered by GNU gettext. Your source strings live directly in Python code and templates; then you run makemessages to extract them into .po files, translate those files, and compile them.

Django's approach to i18n is different from most modern web frameworks. Where Rails uses YAML and React uses JSON, Django builds on GNU gettext, a translation system that's been around since the 1990s and powers the majority of open-source software localization worldwide.

This means .po and .mo files instead of key-value pairs. It means makemessages and compilemessages management commands. And it means a workflow that might feel unfamiliar if you're coming from JavaScript-land, but is actually very well thought out once you understand the pieces. I've worked with all three ecosystems, and Django's approach has some real advantages once you get past the initial learning curve.

This guide walks through the full setup in Django 5.x, from settings and template tags to the gotchas that can waste hours if you don't know about them.

How Django i18n Works

Django's translation system has three main parts:

  1. Marking strings – you wrap translatable strings in special functions (gettext(), gettext_lazy()) in Python code, and use template tags ({% translate %}) in templates
  2. Extracting strings – the makemessages command scans your code and templates, extracting every marked string into .po (Portable Object) files
  3. Compiling translations – after translating the .po files, compilemessages compiles them into binary .mo (Machine Object) files that Django reads at runtime

The flow looks like this:

Code with gettext() → makemessages → .po files → translate → compilemessages → .mo files → Django reads at runtime

To get everything working, you need gettext tools installed on your system. On macOS: brew install gettext. On Ubuntu/Debian: apt install gettext. On Windows, the gettext binaries need to be on your PATH.

Setting It Up From Scratch

Configure settings.py

# settings.py
from django.utils.translation import gettext_lazy as _

# Enable internationalization
USE_I18N = True

# Default language
LANGUAGE_CODE = "en"

# Languages your app supports
LANGUAGES = [
    ("en", _("English")),
    ("sv", _("Swedish")),
    ("de", _("German")),
    ("fr", _("French")),
]

# Where Django looks for translation files
LOCALE_PATHS = [
    BASE_DIR / "locale",
]

# Add locale middleware
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",  # Must be after SessionMiddleware and before CommonMiddleware
    "django.middleware.common.CommonMiddleware",
    # ... rest of middleware
]

Middleware ordering matters. LocaleMiddleware should come after SessionMiddleware and before CommonMiddleware. Getting this wrong is a common source of why isn't my locale switching bugs.

Create the Locale Directory

mkdir -p locale

Then generate message files for each language:

python manage.py makemessages -l sv
python manage.py makemessages -l de
python manage.py makemessages -l fr

This creates the directory structure:

locale/
├── sv/
│   └── LC_MESSAGES/
│       └── django.po
├── de/
│   └── LC_MESSAGES/
│       └── django.po
└── fr/
    └── LC_MESSAGES/
        └── django.po

Marking Strings for Translation

In Python Code

The standard way to mark a string for translation is with gettext(), usually aliased as _():

from django.utils.translation import gettext as _

def my_view(request):
    output = _("Welcome to my site.")
    return HttpResponse(output)

Lazy vs Eager Translation

This is where Django differs from most frameworks, and in my experience it's the thing that trips people up the most.

gettext() (eager) translates the string immediately, using whatever locale is active at the time the code runs.

gettext_lazy() (lazy) returns a proxy object that translates the string when it's actually rendered, not when the code is executed.

Use gettext_lazy() for anything defined at module load time:

from django.utils.translation import gettext_lazy as _

class Article(models.Model):
    title = models.CharField(
        max_length=200,
        verbose_name=_("title"),  # Must be lazy, evaluated at import time
    )

    class Meta:
        verbose_name = _("article")
        verbose_name_plural = _("articles")
from django.utils.translation import gettext_lazy as _

# Form field labels, evaluated at class definition time
class ContactForm(forms.Form):
    name = forms.CharField(label=_("Your name"))
    message = forms.CharField(label=_("Message"), widget=forms.Textarea)

Use regular gettext() in views, where the request locale is already set:

from django.utils.translation import gettext as _

def contact_view(request):
    messages.success(request, _("Your message has been sent."))

The rule of thumb: if the code runs at import time (model definitions, form classes, URL patterns), use gettext_lazy(). If it runs during a request (views, signals), use gettext().

Context-Dependent Translations

Sometimes the same English word needs different translations depending on context. For example, May could be a month or a verb:

from django.utils.translation import pgettext

# Disambiguation with context
month = pgettext("month name", "May")
permission = pgettext("permission", "May")

In the .po file, these appear as separate entries with their context, allowing translators to provide different translations for each.

Template Translations

Load the i18n template tags at the top of your template:

{% load i18n %}

Simple Strings

{% translate "Welcome to our site" %}

{# With variable assignment #}
{% translate "Welcome" as welcome_text %}
<h1>{{ welcome_text }}</h1>

Strings with Variables

Use {% blocktranslate %} for strings that contain variables:

{% blocktranslate with name=user.name notifications=notifications_count %}
  Hello {{ name }}, you have {{ notifications }} new notifications.
{% endblocktranslate %}

Pluralization in Templates

{% blocktranslate count counter=items_count %}
  You have {{ counter }} item in your cart.
{% plural %}
  You have {{ counter }} items in your cart.
{% endblocktranslate %}

The count keyword triggers pluralization. Django uses the gettext plural system, which handles the different plural rules automatically based on the language.

URL Internationalization

Django can add language prefixes to your URLs automatically:

# urls.py
from django.conf.urls.i18n import i18n_patterns

urlpatterns = [
    # These URLs won't have a language prefix
    path("api/", include("api.urls")),
]

urlpatterns += i18n_patterns(
    # These URLs get a language prefix: /en/about/, /sv/about/
    path("about/", views.about, name="about"),
    path("contact/", views.contact, name="contact"),
    path("", views.home, name="home"),
)

This gives you URLs like /en/about/, /sv/about/, /de/about/. The locale is detected from the URL prefix and set automatically.

Language Switching

Add the built-in set_language view to let users switch languages:

# urls.py
urlpatterns = [
    path("i18n/", include("django.conf.urls.i18n")),
    # ... rest of your patterns
]

Then add a language switcher in your template:

{% load i18n %}

<form action="{% url 'set_language' %}" method="post">
    {% csrf_token %}
    <input name="next" type="hidden" value="{{ request.get_full_path }}">
    <select name="language" onchange="this.form.submit()">
        {% get_current_language as CURRENT_LANGUAGE %}
        {% get_available_languages as AVAILABLE_LANGUAGES %}
        {% for lang_code, lang_name in AVAILABLE_LANGUAGES %}
            <option value="{{ lang_code }}"
                {% if lang_code == CURRENT_LANGUAGE %}selected{% endif %}>
                {{ lang_name }}
            </option>
        {% endfor %}
    </select>
</form>

The .po File Workflow

Understanding .po File Structure

After running makemessages, your .po file looks like this:

# locale/sv/LC_MESSAGES/django.po

#: templates/home.html:5
msgid "Welcome to our site"
msgstr ""

#: myapp/views.py:12
msgid "Your message has been sent."
msgstr ""

#: myapp/models.py:8
msgctxt "month name"
msgid "May"
msgstr ""

Each entry has:

  • Comment lines (#:) showing where the string appears in your code
  • msgid – the original string (what you wrote in your code)
  • msgstr – the translation (initially empty, this is what you fill in)
  • msgctxt (optional) – context for disambiguation

A translated entry:

#: templates/home.html:5
msgid "Welcome to our site"
msgstr "Välkommen till vår sajt"

The Extract → Translate → Compile Cycle

# 1. Extract new/changed strings from code
python manage.py makemessages -l sv -l de -l fr

# 2. Translate the .po files (manually, or with a tool)

# 3. Compile to binary .mo files
python manage.py compilemessages

You must run compilemessages after every change to a .po file. Django reads the compiled .mo files at runtime, not the .po source files. Forgetting this step is the #1 cause of I translated it but nothing changed bugs.

How Django Detects the Language

Django checks these sources in order:

  1. URL prefix – if you use i18n_patterns()
  2. Cookie – the django_language cookie (set by the set_language view)
  3. Accept-Language header – sent by the browser
  4. Defaultsettings.LANGUAGE_CODE

The first match wins. This is why URL-based locale detection is the most reliable, since it takes the highest priority.

Pluralization

Gettext handles pluralization through the Plural-Forms header in each .po file. This header tells Django how many plural forms the language has and which form to use for a given count.

English has two forms:

"Plural-Forms: nplurals=2; plural=(n != 1);\n"

msgid "%(count)d item"
msgid_plural "%(count)d items"
msgstr[0] "%(count)d item"
msgstr[1] "%(count)d items"

Polish has three forms:

"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 item"
msgid_plural "%(count)d items"
msgstr[0] "%(count)d element"
msgstr[1] "%(count)d elementy"
msgstr[2] "%(count)d elementów"

The plural formula might look intimidating, but you don't need to write it yourself. It's automatically set by makemessages based on the language code. The important thing is that translators provide all the required plural forms.

Date, Time, and Number Formatting

Django 5.x automatically formats dates, times, and numbers according to the active locale (in earlier versions this required USE_L10N = True, but localized formatting is now always enabled):

# In a template
{{ event.date }}
# English: March 5, 2026
# German: 5. März 2026
# Swedish: 5 mars 2026

You can use specific format strings too:

{{ event.date|date:"SHORT_DATE_FORMAT" }}
{# Renders according to locale-specific short format #}

{{ price|floatformat:2 }}
{# Respects locale decimal separators: 1,499.90 vs 1.499,90 #}

For more control, Django provides format localization files you can override per locale in formats/ directories within your app.

Common Gotchas

1. Forgetting to Compile Messages

A common mishap in Django i18n:

# You edit locale/sv/LC_MESSAGES/django.po
# You restart the server
# Translations don't appear
# You spend 30 minutes debugging
# Then you remember:
python manage.py compilemessages

Add compilemessages to your deployment script. Or better yet, run it in CI so it never gets forgotten.

2. Fuzzy Translations Are Silently Skipped

When you run makemessages and Django detects that a source string has changed slightly, it marks the old translation as fuzzy:

#, fuzzy
msgid "Welcome to our updated site"
msgstr "Välkommen till vår sajt"

Fuzzy translations are ignored at runtime. Django treats them as untranslated. This is a safety feature (the translation might not match the updated source string), but it catches people off guard because the translation is right there in the file, it's just not being used.

Fix: review fuzzy entries, update the translation if needed, and remove the #, fuzzy flag.

3. String Extraction Misses f-strings

Django's makemessages command uses static analysis to find translatable strings. It cannot extract strings built with f-strings or concatenation:

# BAD - makemessages can't see this
message = _(f"Hello {name}, welcome back!")

# GOOD - use interpolation
message = _("Hello %(name)s, welcome back!") % {"name": name}

This is a gettext limitation, and it catches everyone eventually. Always use % formatting or .format() with gettext, never f-strings.

4. Middleware Ordering

MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",  # After Session, before Common
    "django.middleware.common.CommonMiddleware",
]

If LocaleMiddleware comes before SessionMiddleware, language selection can behave unexpectedly. If it comes after CommonMiddleware, URL-based locale detection may not trigger correctly. The ordering is documented but easy to get wrong.

5. JavaScript Translations

Django has a built-in JavaScriptCatalog view for translating strings in JavaScript:

# urls.py
from django.views.i18n import JavaScriptCatalog

urlpatterns = [
    path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
]
<script src="{% url 'javascript-catalog' %}"></script>
<script>
    const translated = gettext("Hello");
    const plural = ngettext("%(count)s item", "%(count)s items", count);
</script>

Automating Your Translation Workflow

Setting up the gettext workflow is one thing. Keeping translations in sync across 5, 10, or 30 languages as your app evolves is another challenge entirely.

The typical pain point: a developer adds new source strings in Python code or templates, but the Swedish, German, and French .po files don't get updated until someone manually notices. By then, you've shipped with missing translations. This is the exact problem I'm building Localhero.ai to solve.

The second pain point is consistency. Even when translations exist, they can drift from your product voice, naming conventions, and tone across features. For product teams, that matters as much as coverage.

A few ways to handle this:

  • CI checks around makemessages catch new or missing translation work, but don't generate the translations
  • Human translators deliver quality but introduce delays, and new features can wait days for translations
  • Build your own LLM pipeline gives you full control, but you have to maintain prompts, quality checks, and infrastructure, and still provide a workflow for non-technical teammates to review and adjust translations
  • AI-powered tools like Localhero.ai integrate directly into your GitHub workflow, translating new strings automatically on every pull request while following your glossary, brand voice, and style-guide rules

The goal is to make translations part of the development flow, not a separate step. If you're interested in CI/CD integration, I wrote a detailed guide on automating i18n with GitHub Actions.

FAQ

Do I need GNU gettext installed to run a Django app with translations? You need gettext tools installed to run makemessages and compilemessages. In production, you only need the compiled .mo files since the gettext runtime is included in Python's standard library.

Can I use JSON or YAML instead of .po files? Not with Django's built-in system. Django is built on gettext, which uses .po/.mo files. There are third-party packages that support other formats, but .po is the standard and has the best tooling support.

How do I translate content stored in the database (like blog posts)? Django's i18n handles static strings in code and templates. For database content, look at packages like django-modeltranslation or django-parler, which add translated fields to your models.

What about Django REST Framework APIs? DRF works with Django's i18n. By default, locale comes from URL prefix (if you use i18n_patterns), language cookie, or the Accept-Language header. Query-parameter locale switching requires custom middleware.


Cover photo by Ashkan Forouzani on Unsplash.

Ready to Ship Global?

Try Localhero.ai's AI-powered translation management for your project. Start shipping features without translation delays.

No credit card required • Setup in a few minutes • Cancel anytime