Back to blog

Why Django Translations Break (and How to Fix the 10 Most Common Causes)

2026-02-04 9 min read
Why Django Translations Break (and How to Fix the 10 Most Common Causes)

You marked strings for translation, generated .po files, ran compilemessages, and your app still shows English. You're not alone. Django's i18n framework is powerful, but it has sharp edges that catch even experienced developers.

This guide covers the 10 most common reasons Django translations silently fail, with exact symptoms and fixes for each.

1. Forgetting to Run compilemessages After Editing .po Files

You edited a .po file (manually or with a tool), but the translated text never appears. The app keeps showing the original English strings.

Django doesn't read .po files at runtime. It reads the compiled .mo (machine object) binary files instead. If you edit a .po file without recompiling, Django has no idea anything changed.

Run compilemessages after every .po file change:

python manage.py compilemessages

If you automate your translations with TranslateBot, add compilemessages as the final step in your workflow:

python manage.py makemessages -a --no-obsolete
python manage.py translate
python manage.py compilemessages

2. Missing {% load i18n %} in Templates

You use {% trans "Hello" %} in a template, but Django raises a TemplateSyntaxError. Or worse, the tag silently does nothing if you have a misconfigured template engine.

The {% trans %} and {% blocktrans %} tags live in Django's i18n template tag library. Without loading it, the template engine doesn't recognize them.

Add {% load i18n %} at the top of every template that uses translation tags:

{% load i18n %}

<h1>{% trans "Welcome to our site" %}</h1>
<p>{% blocktrans with name=user.name %}Hello, {{ name }}!{% endblocktrans %}</p>

This is a per-template requirement. Even if a parent template loads i18n, child templates that use translation tags need their own {% load i18n %} declaration.

3. LocaleMiddleware Not in MIDDLEWARE or in the Wrong Position

Django always serves content in the default language regardless of the browser's Accept-Language header, URL prefix, or session settings.

LocaleMiddleware determines the active language for each request. Without it, Django defaults to LANGUAGE_CODE and ignores all language-selection mechanisms. Its position in the middleware stack matters too, because it needs access to session data and URL resolution.

Add LocaleMiddleware to your MIDDLEWARE setting, after SessionMiddleware and CommonMiddleware:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.locale.LocaleMiddleware",  # Must be after SessionMiddleware
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
]

Also make sure django.conf.urls.i18n is included in your URL configuration if you use URL-based language switching:

from django.conf.urls.i18n import i18n_patterns

urlpatterns = i18n_patterns(
    path("", include("myapp.urls")),
)

4. Language Code Mismatches (e.g., pt-br vs pt_BR)

Translations exist in your .po files, compilemessages succeeds, but Django ignores the translation for certain locales.

Django expects locale directories to follow the format <language>_<COUNTRY> with an underscore separator and uppercase country code. For example, pt_BR for Brazilian Portuguese. If your directory is named pt-br, pt-BR, or ptBR, Django won't find it. The same applies to the LANGUAGES setting: the codes there use hyphens (pt-br), but the filesystem uses underscores (pt_BR).

Make sure your directory structure matches Django's expectations:

locale/
    pt_BR/
        LC_MESSAGES/
            django.po
            django.mo

And in your settings, use the hyphenated form:

LANGUAGES = [
    ("en", "English"),
    ("pt-br", "Brazilian Portuguese"),
    ("zh-hans", "Simplified Chinese"),
]

When running makemessages, use the underscore form for the locale flag:

python manage.py makemessages -l pt_BR

5. Fuzzy Entries Silently Skipped During Compilation

A translation exists in the .po file, but Django shows the original English string at runtime for that specific entry. This one is particularly frustrating because the translation is right there in the file.

When Django's makemessages detects a source string that changed slightly, it marks the existing translation as "fuzzy" (meaning it's a guess that needs human review). The compilemessages command skips all fuzzy entries, treating them as untranslated. So the entry looks translated in the .po file, but the .mo file excludes it entirely.

A fuzzy entry looks like this:

#, fuzzy
msgid "Welcome to our website!"
msgstr "Welkom op onze website!"

Review the translation, update msgstr if needed, then remove the #, fuzzy flag:

msgid "Welcome to our website!"
msgstr "Welkom op onze website!"

Then recompile:

python manage.py compilemessages

In a larger project, fuzzy entries pile up and are easy to miss. TranslateBot's check_translations command catches these automatically:

python manage.py check_translations
locale/nl/LC_MESSAGES/django.po: 0 untranslated, 3 fuzzy
CommandError: Translation check failed

Add it to your CI pipeline with check_translations --makemessages and you'll never ship a fuzzy entry again.

6. LOCALE_PATHS Not Configured or Pointing to the Wrong Directory

makemessages creates .po files in one location, but Django looks for them somewhere else. Translations exist on disk but never load.

Django searches for translation files in a specific order: LOCALE_PATHS directories first, then each app's locale/ directory, and finally the project's locale/ directory. If LOCALE_PATHS isn't set or points to the wrong path, Django may never find your .po files.

Set LOCALE_PATHS in your settings to an absolute path:

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

LOCALE_PATHS = [
    BASE_DIR / "locale",
]

Verify the directory exists and contains the expected structure:

locale/
    de/
        LC_MESSAGES/
            django.po
            django.mo
    nl/
        LC_MESSAGES/
            django.po
            django.mo

A common mistake is setting LOCALE_PATHS to locale/ (relative) instead of an absolute path. Django won't resolve relative paths from your project root. It depends on the working directory of the process, which is often not what you expect.

7. Caching Serving Stale Translations

You updated and compiled translations, but the old text keeps appearing. Restarting the server fixes it.

Django's translation catalog is loaded once per process. In production, WSGI/ASGI servers like Gunicorn or Uvicorn keep worker processes alive for extended periods. The .mo file may have changed on disk, but the running process still has the old translations in memory. On top of that, if you use Django's cache framework or a reverse proxy like Nginx or Cloudflare, cached responses will serve old content until they expire.

Restart your application server after deploying new translations:

# Gunicorn
kill -HUP $(cat /tmp/gunicorn.pid)

# Systemd
sudo systemctl restart myapp

# Docker
docker compose restart web

For Django's cache framework, clear the cache after updating translations:

from django.core.cache import cache
cache.clear()

In development, runserver auto-reloads when Python files change but does not watch .mo files. You'll need to restart it manually after running compilemessages.

8. Strings Not Wrapped in gettext (_() or {% trans %})

makemessages doesn't extract certain strings, so they never appear in your .po files and are never translated. This is the most basic issue and also the easiest to overlook in a large codebase.

Django's makemessages command uses xgettext to scan your source code for translation markers. If a string isn't wrapped in gettext() (commonly aliased as _()), gettext_lazy(), {% trans %}, or {% blocktrans %}, it's invisible to the extraction process.

Wrap every user-facing string:

# Python code
from django.utils.translation import gettext_lazy as _

class Article(models.Model):
    class Meta:
        verbose_name = _("article")
        verbose_name_plural = _("articles")

# Views
from django.utils.translation import gettext as _

def my_view(request):
    message = _("Your changes have been saved.")
    return HttpResponse(message)
<!-- Templates -->
{% load i18n %}
<h1>{% trans "Welcome" %}</h1>
<p>{% blocktrans with count=items|length %}You have {{ count }} items.{% endblocktrans %}</p>

Use TranslateBot's --dry-run flag to preview what strings are currently untranslated, so you can catch strings that were missed during extraction:

python manage.py translate --target-lang de --dry-run

This shows all untranslated entries across your .po files without making any API calls or changes.

9. f-strings Cannot Be Translated (Django Limitation)

You wrap an f-string in _() and either get a syntax error, or makemessages extracts a broken/partial string that can't be translated.

Python f-strings are evaluated at runtime. The xgettext extraction tool parses source code statically, so it can't evaluate Python expressions inside {} braces. This means _(f"Hello, {name}") gets extracted as a string containing a literal {name} expression (or fails to extract entirely), and the resulting .po entry will never match the runtime string.

Use Django's % formatting or .format() with named placeholders instead:

# Wrong -- f-string cannot be extracted
message = _(f"Hello, {user.name}! You have {count} new messages.")

# Correct -- named placeholders
message = _("Hello, %(name)s! You have %(count)d new messages.") % {
    "name": user.name,
    "count": count,
}

# Also correct -- .format() with positional args
message = _("Hello, {0}! You have {1} new messages.").format(user.name, count)

This isn't a TranslateBot or tooling limitation. It's fundamental to how gettext works. The source string must be a static literal so it can be extracted and looked up at runtime.

TranslateBot preserves all these placeholder formats (%(name)s, {0}, %s, HTML tags) during translation, so the translated strings remain fully functional.

10. Placeholder Format String Errors Breaking .po Compilation

compilemessages fails with an error, or the .po file has a #, python-format flag mismatch, and the entry is silently dropped.

When a source string contains Python format placeholders like %(name)s, Django marks the .po entry with #, python-format. If the translation has different placeholders (a typo like %(nome)s, a missing placeholder, or an extra one) the gettext tools may reject the entry or compilemessages may fail. This commonly happens with manual translations or with AI translation tools that don't understand placeholder semantics.

A broken entry looks like this:

#, python-format
msgid "Hello, %(name)s! You have %(count)d new messages."
msgstr "Hallo, %(naam)s! Je hebt %(count)d nieuwe berichten."

Here %(naam)s should be %(name)s. Placeholders must match the source exactly.

Ensure translated strings contain the exact same placeholders as the source. Check for typos, missing placeholders, and extra placeholders.

This is one area where TranslateBot provides a real advantage. Its placeholder preservation logic ensures that all format strings (%(name)s, {0}, %s) in the translated output match the source exactly. The placeholder handling is covered by 100% test coverage, so format string errors from translation are eliminated at the tool level rather than caught at compile time.

If you're translating manually or with a tool that doesn't handle placeholders, validate your .po files with:

msgfmt --check-format locale/de/LC_MESSAGES/django.po

This runs gettext's format string validation and reports any mismatches.

Putting It All Together: A Defensive Workflow

Most of these issues share a root cause: manual steps that are easy to forget. Here's a workflow that prevents all 10 problems:

# 1. Extract strings (catches #8 -- any new gettext-wrapped strings)
python manage.py makemessages -a --no-obsolete

# 2. Translate (catches #1, #5, #8, #9, #10 -- handles untranslated,
#    fuzzy, and placeholder issues automatically)
python manage.py translate

# 3. Compile (catches #1 -- generates .mo files)
python manage.py compilemessages

# 4. Verify in CI (catches everything that slipped through)
python manage.py check_translations --makemessages

Add step 4 to your CI pipeline and untranslated strings, fuzzy entries, and format errors will fail the build before they reach production.

Quick Reference Table

Cause Symptom One-Line Fix
No compilemessages Translations exist but don't appear python manage.py compilemessages
Missing {% load i18n %} TemplateSyntaxError on {% trans %} Add {% load i18n %} to the template
LocaleMiddleware missing Language always defaults to English Add django.middleware.locale.LocaleMiddleware to MIDDLEWARE
Language code mismatch Locale directory not found Use pt_BR (underscore) for directories, pt-br (hyphen) for settings
Fuzzy entries skipped Translation in .po but not in app Remove #, fuzzy flag after reviewing
Wrong LOCALE_PATHS .po files exist but Django ignores them Set LOCALE_PATHS to an absolute path
Cached translations Old text appears after update Restart the application server
String not in gettext String missing from .po files Wrap in _() or {% trans %}
f-string in gettext Broken extraction or runtime mismatch Replace with % or .format() placeholders
Placeholder mismatch compilemessages fails or entry dropped Match placeholders exactly between source and translation

Most of these problems disappear when you automate the translate step and enforce checks in CI. TranslateBot's translate command handles placeholder preservation and incremental translation, while check_translations catches anything that falls through the cracks (untranslated entries, fuzzy flags, and format string issues) before they reach production.

Stop editing .po files manually

TranslateBot automates Django translations with AI. One command, all your languages, pennies per translation.