# Django i18n with gettext: A Practical Guide for Django 6

> Settings, gettext vs gettext_lazy, plurals with ngettext, templates, makemessages and compilemessages, i18n_patterns, and the mistakes that make Django translations vanish in production.
- **Author**: Mohit Sapat
- **Published**: 2026-10-13
- **URL**: https://www.babelize.co/blogs/django-i18n-gettext-guide

---

<p class="lead">
    Django ships a complete translation system built on GNU gettext, but the pieces are spread across settings, middleware, URL config, two management commands, and a file format most web developers have never opened. This guide walks through the whole pipeline on Django 6, with every command and code sample tested on a real project.
</p>

<h2>How Django Translation Works</h2>

<p>
    Django does not translate anything at runtime by itself. It looks up strings you have marked in a compiled catalog for the active language. The pipeline has five steps:
</p>

<ol>
    <li><strong>Mark</strong> translatable strings in Python with <code>gettext</code> and in templates with <code>{% translate %}</code>.</li>
    <li><strong>Extract</strong> them with <code>makemessages</code>, which writes one <code>.po</code> file per language.</li>
    <li><strong>Translate</strong> the <code>msgstr</code> entries in each <code>.po</code> file.</li>
    <li><strong>Compile</strong> them with <code>compilemessages</code> into binary <code>.mo</code> files, which are what Django actually reads.</li>
    <li><strong>Activate</strong> a language per request with <code>LocaleMiddleware</code>, usually from a URL prefix like <code>/de/</code>.</li>
</ol>

<p>
    Most "my translations don't show up" bugs come from skipping step 4 or misconfiguring step 5, so both get their own sections below.
</p>

<h2>Settings</h2>

<p>
    Start with four settings and one middleware:
</p>

<pre><code># settings.py
from django.utils.translation import gettext_lazy as _

USE_I18N = True
LANGUAGE_CODE = "en"

LANGUAGES = [
    ("en", _("English")),
    ("de", _("German")),
    ("pl", _("Polish")),
]

LOCALE_PATHS = [BASE_DIR / "locale"]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]</code></pre>

<ul>
    <li><strong><code>LANGUAGES</code></strong> restricts Django to the languages you actually ship. Without it, Django falls back to its default list of 100 languages and will activate any of them from a browser header, so your pages can come out in a language you never translated.</li>
    <li><strong><code>LOCALE_PATHS</code></strong> is where project-level catalogs live. App-level catalogs go in each app's own <code>locale/</code> folder and are found automatically.</li>
    <li><strong><code>LocaleMiddleware</code></strong> must come after <code>SessionMiddleware</code> and before <code>CommonMiddleware</code>. It reads the language from the URL prefix, then the language cookie, then the <code>Accept-Language</code> header.</li>
    <li>Use <code>gettext_lazy</code> in settings. The translation system is not ready when settings load, and the lazy version only translates when the value is rendered.</li>
</ul>

<h2>Marking Strings in Python</h2>

<p>
    Four functions cover almost everything. Here they are in one view:
</p>

<pre><code># shop/views.py
from django.shortcuts import render
from django.utils.translation import gettext as _, ngettext, pgettext

def cart(request):
    count = int(request.GET.get("n", 3))
    context = {
        "title": _("Your cart"),
        "summary": ngettext(
            "%(count)d item in your cart",
            "%(count)d items in your cart",
            count,
        ) % {"count": count},
        "order_button": pgettext("verb", "Order"),
        "count": count,
    }
    return render(request, "shop/cart.html", context)</code></pre>

<ul>
    <li><strong><code>gettext</code></strong> (aliased to <code>_</code>) translates a single string at the moment it runs. Use it in views and functions.</li>
    <li><strong><code>ngettext</code></strong> handles plurals. You pass the English singular and plural plus the count, and each language's catalog supplies as many forms as it needs. Polish needs four, Arabic six.</li>
    <li><strong><code>pgettext</code></strong> adds context. "Order" as a verb on a button and "Order" as a noun in a table header are different words in German (<em>Bestellen</em> and <em>Bestellung</em>), and the context keeps them separate in the catalog.</li>
    <li>Use named placeholders like <code>%(count)d</code>, never f-strings or concatenation. An f-string is formatted before <code>gettext</code> sees it, so the lookup key changes with every value and nothing ever matches the catalog.</li>
</ul>

<h3>Models and forms: always lazy</h3>

<p>
    Model fields, <code>Meta</code> options, and form labels are evaluated once, when the module is imported. If you use plain <code>gettext</code> there, the string is frozen in whatever language was active at import time, usually the default one. Use <code>gettext_lazy</code>:
</p>

<pre><code># shop/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

class Product(models.Model):
    name = models.CharField(_("name"), max_length=200)

    class Meta:
        verbose_name = _("product")
        verbose_name_plural = _("products")</code></pre>

<h2>Marking Strings in Templates</h2>

<pre><code>{% load i18n %}
&lt;h1&gt;{{ title }}&lt;/h1&gt;
&lt;p&gt;{{ summary }}&lt;/p&gt;
&lt;button&gt;{{ order_button }}&lt;/button&gt;
&lt;p&gt;{% translate "Free shipping on orders over EUR 50" %}&lt;/p&gt;
{% blocktranslate count counter=count trimmed %}
  You saved money on {{ counter }} product.
{% plural %}
  You saved money on {{ counter }} products.
{% endblocktranslate %}</code></pre>

<ul>
    <li><strong><code>{% translate %}</code></strong> handles a plain string. It also accepts <code>context "verb"</code>, the template equivalent of <code>pgettext</code>.</li>
    <li><strong><code>{% blocktranslate %}</code></strong> handles strings with variables. Variables must be simple names, so assign anything more complex with <code>with</code> first.</li>
    <li><strong><code>count counter=...</code></strong> plus <code>{% plural %}</code> gives you a plural-aware block.</li>
    <li><strong><code>trimmed</code></strong> collapses the template's whitespace and line breaks, so the <code>msgid</code> in your catalog is one clean sentence instead of a string full of indentation.</li>
</ul>

<h2>Extracting: makemessages</h2>

<pre><code>python manage.py makemessages -l de -l pl</code></pre>

<p>
    This scans your Python files and templates and writes <code>locale/de/LC_MESSAGES/django.po</code> and <code>locale/pl/LC_MESSAGES/django.po</code>. Run it again whenever strings change; it merges new strings in and keeps existing translations. It needs the GNU gettext tools installed (<code>brew install gettext</code> on macOS, <code>apt install gettext</code> on Debian and Ubuntu). For JavaScript files, run it a second time with <code>-d djangojs</code>.
</p>

<h2>Reading a .po File</h2>

<p>
    Here is what the plural string from the view looks like in the Polish catalog after translation:
</p>

<pre><code>"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10&gt;=2 &amp;&amp; n%10&lt;=4) &amp;&amp; (n%100&lt;12 || n%100&gt;14) ? 1 : n!=1 &amp;&amp; (n%10&gt;=0 &amp;&amp; n%10&lt;=1) || (n%10&gt;=5 &amp;&amp; n%10&lt;=9) || (n%100&gt;=12 &amp;&amp; n%100&lt;=14) ? 2 : 3);\n"

#: shop/views.py:10
#, python-format
msgid "%(count)d item in your cart"
msgid_plural "%(count)d items in your cart"
msgstr[0] "%(count)d produkt w koszyku"
msgstr[1] "%(count)d produkty w koszyku"
msgstr[2] "%(count)d produktów w koszyku"
msgstr[3] "%(count)d produktu w koszyku"

#: shop/views.py:14
msgctxt "verb"
msgid "Order"
msgstr "Zamów"</code></pre>

<ul>
    <li>The <strong><code>Plural-Forms</code></strong> header tells gettext how many forms the language has and which index to pick for a given number. Django fills it in from its own knowledge of each language; leave it alone.</li>
    <li><strong><code>msgstr[0]</code> to <code>msgstr[3]</code></strong> are Polish's four forms. With this catalog, Django renders "1 produkt", "3 produkty", "5 produktów", and "22 produkty", following the Polish rule that 22 to 24 behave like 2 to 4.</li>
    <li><strong><code>#, fuzzy</code></strong> marks an entry that <code>makemessages</code> guessed from a similar old string. Fuzzy entries are skipped at compile time, so a "translated" string that never appears is often just fuzzy. Review it and delete the flag.</li>
</ul>

<p>
    For more on how plural categories differ across languages, see our <a href="/blogs/icu-messageformat-plurals-guide/">ICU MessageFormat plurals guide</a>. gettext uses numbered forms instead of named categories, but the language rules underneath are the same CLDR data.
</p>

<h2>Compiling: compilemessages</h2>

<pre><code>python manage.py compilemessages --ignore=.venv</code></pre>

<p>
    This turns every <code>.po</code> file it finds into a <code>.mo</code> file. Django only reads <code>.mo</code> files, so a translation that is in your <code>.po</code> but not compiled does not exist as far as your site is concerned.
</p>

<p>
    The <code>--ignore</code> flag matters more than it looks. <code>compilemessages</code> walks every directory under the project, and if your virtualenv lives inside the project folder, it will recompile the hundreds of catalogs that ship inside Django and every other installed package. That is slow at best and fails outright when those directories are read-only.
</p>

<p>
    Decide where compilation happens in deployment. Either commit the <code>.mo</code> files, or run <code>compilemessages</code> in your build step next to <code>collectstatic</code>. Forgetting both is the single most common reason translations work locally and vanish in production.
</p>

<h2>Language URLs with i18n_patterns</h2>

<pre><code># urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path

from shop import views

urlpatterns = [
    path("i18n/", include("django.conf.urls.i18n")),  # set_language view
]

urlpatterns += i18n_patterns(
    path("cart/", views.cart, name="cart"),
    prefix_default_language=False,
)</code></pre>

<p>
    <code>i18n_patterns</code> serves <code>/de/cart/</code> and <code>/pl/cart/</code> in German and Polish. One setting decides what happens on the unprefixed URL, and it trips people up:
</p>

<table>
    <thead>
        <tr>
            <th><code>prefix_default_language</code></th>
            <th>Request for <code>/cart/</code> with <code>Accept-Language: pl</code></th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>True</code> (default)</td>
            <td>302 redirect to <code>/pl/cart/</code></td>
        </tr>
        <tr>
            <td><code>False</code></td>
            <td>200, rendered in English (<code>LANGUAGE_CODE</code>)</td>
        </tr>
    </tbody>
</table>

<p>
    With <code>False</code>, the unprefixed URL <em>is</em> the default language, so Django does not redirect based on the browser's preferences. That is usually what you want for SEO, because every language has one stable URL that search engines can index and link with <a href="/blogs/hreflang-multilingual-seo-guide/">hreflang</a>. If you want automatic redirects from the browser language, use <code>True</code> and accept that every URL carries a prefix, including the default language.
</p>

<p>
    For a language switcher, post to the <code>set_language</code> view that <code>django.conf.urls.i18n</code> provides. It stores the choice in a cookie and redirects to the <code>next</code> URL.
</p>

<h2>Translations in JavaScript</h2>

<p>
    If your templates load JavaScript that shows user-facing text, Django can serve a catalog for it:
</p>

<pre><code># urls.py
from django.views.i18n import JavaScriptCatalog

urlpatterns += i18n_patterns(
    path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
)</code></pre>

<p>
    Include <code>{% url 'javascript-catalog' %}</code> as a script, and <code>gettext()</code>, <code>ngettext()</code>, and <code>interpolate()</code> become available in the browser. Extract the strings with <code>makemessages -d djangojs -l de</code>. For a heavy single-page frontend, a dedicated JavaScript i18n library is usually a better fit; see <a href="/blogs/react-i18next-tutorial/">react-i18next</a> or <a href="/blogs/next-intl-vs-react-i18next/">next-intl</a>.
</p>

<h2>Testing Translations</h2>

<p>
    Use <code>translation.override</code> in tests to render a view or function in a specific language without touching request state:
</p>

<pre><code>from django.test import TestCase
from django.utils import translation

class CartTranslationTests(TestCase):
    def test_polish_plural(self):
        response = self.client.get("/pl/cart/?n=22")
        self.assertContains(response, "22 produkty w koszyku")

    def test_lazy_model_label(self):
        from shop.models import Product
        with translation.override("de"):
            self.assertEqual(str(Product._meta.verbose_name), "Produkt")</code></pre>

<p>
    Test the plural edge cases for your hardest language, not just 1 and 5. For Polish that means 0, 1, 2, 5, 12, 22, and 25. Our <a href="/blogs/pseudo-localization-testing-guide/">pseudo-localization guide</a> covers how to catch untranslated strings before a translator ever sees them.
</p>

<h2>Common Mistakes</h2>

<ul>
    <li><strong>Translations work locally but not in production.</strong> The <code>.mo</code> files were never compiled or deployed.</li>
    <li><strong>A model label stays in English.</strong> It uses <code>gettext</code> instead of <code>gettext_lazy</code>.</li>
    <li><strong>A string is in the <code>.po</code> file but never appears.</strong> It is marked <code>#, fuzzy</code>, or the <code>msgid</code> no longer matches the source because someone edited the English text.</li>
    <li><strong>Sentences built from fragments.</strong> <code>_("Hello") + ", " + name</code> forces every language into English word order. Translate the whole sentence with a placeholder.</li>
    <li><strong>The browser language is ignored.</strong> <code>LocaleMiddleware</code> is in the wrong position, or <code>prefix_default_language=False</code> is serving the default language on unprefixed URLs as designed.</li>
    <li><strong>Pages come out partly in an unexpected language.</strong> <code>LANGUAGES</code> is not set, so Django activates languages you never translated.</li>
</ul>

<h2>Automating the Busywork</h2>

<p>
    The slow part of Django i18n is rarely the settings. It is wrapping hundreds of existing strings in <code>gettext</code> and <code>{% translate %}</code>, then keeping every <code>.po</code> file in sync as the code changes. Babelize does that step for Django projects: it marks strings in Python files (models, forms, admin, views) and templates, writes gettext <code>.po</code> catalogs while keeping existing translations, recompiles tracked <code>.mo</code> files, and sets <code>LANGUAGES</code> and <code>LOCALE_PATHS</code>, all delivered as a pull request you review like any other change.
</p>

<h2>Frequently Asked Questions</h2>

<h3>What is the difference between gettext and gettext_lazy in Django?</h3>
<p>
    <code>gettext</code> translates immediately, using the language active at that moment. <code>gettext_lazy</code> returns a proxy that translates when it is rendered. Use the lazy version anywhere that runs at import time, such as model fields, form labels, and settings, and plain <code>gettext</code> inside views and functions.
</p>

<h3>Why are my Django translations not showing?</h3>
<p>
    Check four things in order: the <code>.mo</code> files exist and are newer than the <code>.po</code> files, the entry is not marked fuzzy, the <code>msgid</code> exactly matches the source string, and <code>LocaleMiddleware</code> sits between <code>SessionMiddleware</code> and <code>CommonMiddleware</code>.
</p>

<h3>Should I commit .mo files to git?</h3>
<p>
    Either commit them or compile them in your build step. Committing is simpler and avoids needing gettext installed on the server. Compiling at build time keeps generated files out of the repository. What does not work is doing neither.
</p>

<h3>How does Django handle plurals for languages like Polish or Arabic?</h3>
<p>
    Each <code>.po</code> file has a <code>Plural-Forms</code> header with the number of forms and a formula that picks one for any number. <code>ngettext</code> and <code>{% blocktranslate count %}</code> use that formula, so Polish gets four <code>msgstr</code> entries and Arabic six, while your code passes only the English singular and plural.
</p>

<h3>Does Django redirect users to their browser language?</h3>
<p>
    Only when <code>prefix_default_language</code> is <code>True</code>. Then a request for an unprefixed URL is redirected to the prefixed URL for the language in the <code>Accept-Language</code> header. With <code>False</code>, unprefixed URLs are always served in <code>LANGUAGE_CODE</code>.
</p>

<h2>The Short Version</h2>

<p>
    Set <code>LANGUAGES</code>, <code>LOCALE_PATHS</code>, and <code>LocaleMiddleware</code>. Mark strings with <code>gettext</code>, <code>gettext_lazy</code> for anything evaluated at import time, <code>ngettext</code> for plurals, and <code>pgettext</code> for ambiguous words. Run <code>makemessages</code>, translate, run <code>compilemessages --ignore=.venv</code>, and make sure the <code>.mo</code> files reach production. Use <code>i18n_patterns</code> for language URLs, and choose <code>prefix_default_language</code> deliberately.
</p>
---
- [All articles](https://www.babelize.co/blogs)