{"id":8291,"date":"2026-09-17T06:00:00","date_gmt":"2026-09-17T06:00:00","guid":{"rendered":"https:\/\/poeditor.com\/blog\/?p=8291"},"modified":"2026-09-15T10:53:08","modified_gmt":"2026-09-15T10:53:08","slug":"django-i18n","status":"publish","type":"post","link":"https:\/\/poeditor.com\/blog\/django-i18n\/","title":{"rendered":"Django i18n: How to translate and localize your app"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"773\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n-1024x773.png\" alt=\"\" class=\"wp-image-8314\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n-1024x773.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n-300x226.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n-767x579.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n-1536x1159.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n.png 1988w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Django comes with an ORM, an admin site, authentication, and a template engine, so you can get an application up and running quickly. As the project grows, though, supporting users in different languages requires more than translating a few strings.<br><br>Internationalization (i18n) prepares the code for multiple languages and locales. Localization (l10n) provides the translations and formats dates, numbers, and other locale-specific content for each language.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this guide, we\u2019ll start with a Django TO-DO app whose text is hardcoded in English. We\u2019ll mark its strings for translation, translate them into French in POEditor, and configure the app to serve both languages.&nbsp;<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What Django translates out of the box<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Django installs its own message catalogs along with the framework. There are dozens of language directories under <code>django\/conf\/locale\/<\/code>, most of them carrying a compiled catalog, plus a set inside each contrib app. Together, these catalogs cover the password validator messages, authentication views, form and field errors, the admin, and locale-specific date and number formats. <code>pip install django<\/code> already put them on disk.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Django serves those catalogs for a language the project has declared and activated. A fresh project declares nothing, so every response comes back in English whatever the browser asks for. A few settings and one middleware line are enough to enable language selection.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Django translates its own messages, but it does not know anything about the strings in your application. \u201cCreate list\u201d, \u201cSigned in as demo\u201d, \u201cTask added.\u201d and every other string your team wrote stay English after that, because no catalog on disk contains them. Marking those strings, extracting them and getting translations back is the rest of this guide.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The example is a small Django to-do app. You register with a username and a password, you are logged in straight away, and you create lists whose tasks you can add, edit, complete and delete. It includes page titles, form labels, buttons, filter pills, flash messages and counts\u2014enough interface text to make a useful translation catalog.&nbsp;<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"544\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01-english-lists-1024x544.png\" alt=\"\" class=\"wp-image-8302\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01-english-lists-1024x544.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01-english-lists-300x160.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01-english-lists-767x408.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01-english-lists-1536x817.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01-english-lists-2048x1089.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The lists page signed in as demo, English only<\/em><\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Before you start<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Python 3.12 or newer<\/strong>, which is what Django 6.1 requires.<\/li>\n\n\n\n<li><strong>Django 6.1<\/strong>. Everything here works the same way back to Django 5.2 LTS.<\/li>\n\n\n\n<li><strong>The GNU gettext tools<\/strong>, which makemessages and compilemessages call out to. sudo apt install gettext on Debian and Ubuntu, brew install gettext on macOS, and on Windows the precompiled gettext binaries with their bin directory added to PATH.<\/li>\n\n\n\n<li><strong>A POEditor account<\/strong> for the translation round trip.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Set the project up and start it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>python -m venv .venv<br>source .venv\/bin\/activate<br>pip install django<br>python manage.py migrate<br>python manage.py runserver<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open the address the development server prints, register as demo, and create a couple of lists so there is something on the page to translate.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Configuring Django\u2019s i18n settings<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Add an internationalization block to the settings module:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todosite\/settings.py<\/em><br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext_lazy <strong>as<\/strong> _<br><br><em># Turns on Django's translation and formatting machinery.<\/em><br>USE_I18N = True<br><br><em># The fallback language, and the language the source strings are written in.<\/em><br>LANGUAGE_CODE = \"en\"<br><br><em># The languages the app offers. The names are marked for translation too, so<\/em><br><em># the switcher reads \"English \/ French\" in English and \"Anglais \/ Fran\u00e7ais\"<\/em><br><em># in French.<\/em><br>LANGUAGES = &#091;<br>&nbsp; &nbsp; (\"en\", _(\"English\")),<br>&nbsp; &nbsp; (\"fr\", _(\"French\")),<br>]<br><br><em># Where makemessages writes the catalogs and where gettext looks for them.<\/em><br>LOCALE_PATHS = &#091;BASE_DIR \/ \"locale\"]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>USE_I18N<\/code> is already True in a fresh project, so the line above is there for anyone who greps the settings for it. <code>LANGUAGE_CODE<\/code> defaults to en-us; setting it to en lines it up with the code in <code>LANGUAGES<\/code> and with the locale directory you are about to create.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>LANGUAGES<\/code> <strong>is an allowlist.<\/strong> Django activates a language only if it appears there. A browser sending <code>Accept-Language: de<\/code> gets <code>LANGUAGE_CODE<\/code> rather than German, and a language cookie holding an unlisted code is ignored the same way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then add the locale middleware:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todosite\/settings.py<\/em>\nMIDDLEWARE = &#091;\n&nbsp; &nbsp; \"django.middleware.security.SecurityMiddleware\",\n&nbsp; &nbsp; \"django.contrib.sessions.middleware.SessionMiddleware\",\n&nbsp; &nbsp; <em># Works out the active language for every request.<\/em>\n&nbsp; &nbsp; \"django.middleware.locale.LocaleMiddleware\",\n&nbsp; &nbsp; \"django.middleware.common.CommonMiddleware\",\n&nbsp; &nbsp; \"django.middleware.csrf.CsrfViewMiddleware\",\n&nbsp; &nbsp; \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n&nbsp; &nbsp; \"django.contrib.messages.middleware.MessageMiddleware\",\n&nbsp; &nbsp; \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The position matters. <code>LocaleMiddleware<\/code> goes after <code>SessionMiddleware<\/code> and before <code>CommonMiddleware<\/code>, because <code>CommonMiddleware<\/code> needs an active language to resolve the requested URL. Put it at the end of the list and language-dependent URL resolution breaks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>LOCALE_PATHS<\/code> points at one directory at the project root. This is the tree the next few sections fill in:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>locale\/<br>\u251c\u2500 en\/<br>\u2502&nbsp; \u2514\u2500 LC_MESSAGES\/<br>\u2502 &nbsp; &nbsp; \u251c\u2500 django.po<br>\u2502 &nbsp; &nbsp; \u2514\u2500 django.mo<br>\u2514\u2500 fr\/<br>&nbsp; \u2514\u2500 LC_MESSAGES\/<br>&nbsp; &nbsp; &nbsp; \u251c\u2500 django.po<br>&nbsp; &nbsp; &nbsp; \u2514\u2500 django.mo<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Django also reads a <code>locale\/<\/code> directory inside each installed app, which is the right layout for a reusable package. One project-level directory puts the whole application\u2019s text in one file per language, which is what you hand to a translation service.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Those settings are enough to start using Django\u2019s own catalogs. Restart the server, set your browser\u2019s preferred language to French, and register with a four-character password. Django\u2019s validator answers in French:<\/p>\n\n\n\n<p class=\"has-text-align-center wp-block-paragraph\">Ce mot de passe est trop court. Il doit contenir au minimum 6 caract\u00e8res.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The username label, the help text under it and the \u201cCreate account\u201d button are still English, because they are strings from this project and this project has no French catalog yet. The password label reads \u201cMot de passe\u201d: <code>Password<\/code> is also a string in Django\u2019s own catalog, and the lookup falls through to it. A project catalog wins wherever it has an entry, and Django\u2019s own catalogs fill the gaps.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"486\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02-french-validator-english-app-1024x486.png\" alt=\"\" class=\"wp-image-8303\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02-french-validator-english-app-1024x486.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02-french-validator-english-app-300x143.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02-french-validator-english-app-766x364.png 766w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02-french-validator-english-app-1536x730.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02-french-validator-english-app-2048x973.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Django\u2019s password validator answering in French on an otherwise English page<\/em><\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">gettext vs.&nbsp;gettext_lazy<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Django resolves a translatable string at one of three moments, and the moment decides which function the string needs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Import time.<\/strong> A model field\u2019s <code>verbose_name<\/code>, a form field\u2019s <code>label<\/code>, an <code>AppConfig.verbose_name<\/code>, a value in settings.py. Python evaluates these once, when it imports the module, before any request exists.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Request time.<\/strong> A flash message or a page title built inside a view. The code runs with a language already active for that request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Render time.<\/strong> A string in a template. The template engine resolves it while building the response.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A string resolved at import time cannot know which language the visitor wants, because no visitor has arrived.<code> gettext()<\/code> looks up the active language and returns a str on the spot, so calling it at import time bakes in the language from <code>LANGUAGE_CODE<\/code> for the lifetime of the process. <code>gettext_lazy()<\/code> returns a proxy object instead. The lookup happens when something asks the proxy for its text, which is at render time, once per request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rule is simple:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>gettext_lazy<\/code><\/strong> for anything evaluated at import: settings, model fields and <code>Meta<\/code>, form labels, help text, error messages, choices.<\/li>\n\n\n\n<li><strong><code>gettext<\/code><\/strong> inside anything that runs per request: view bodies, model methods, <code>clean()<\/code> hooks, signal handlers.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A lazy string is a proxy rather than a str, which matters in two cases. <code>isinstance(value, str)<\/code> is False, so <code>json.dumps()<\/code> on a dict containing one raises <code>TypeError: Object of type __proxy__ is not JSON serializable<\/code>. And concatenating a lazy string with + resolves it immediately, fixing the language at whatever was active when the concatenation ran. <a href=\"https:\/\/docs.djangoproject.com\/en\/6.1\/ref\/utils\/#django.utils.text.format_lazy\" rel=\"nofollow\"><code>format_lazy()<\/code><\/a> combines a lazy string with other values and stays lazy:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># any module that builds a label at import time<\/em><br><strong>from<\/strong> django.utils.text <strong>import<\/strong> format_lazy<br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext_lazy <strong>as<\/strong> _<br><br>LABEL = format_lazy(\"{} \/ {}\", _(\"English\"), _(\"French\"))<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Translating templates<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most of the app\u2019s text lives in templates, so start there. Each template that needs the translation tags loads them first. In <code>base.html <\/code>the load tag is already there for <code>static<\/code>:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>{# todo\/templates\/todo\/base.html (before) #}<br>{% load static %}&lt;!doctype html&gt;<br>&lt;<strong>html<\/strong> lang=\"en\" data-bs-theme=\"light\"&gt;<br>&lt;<strong>head<\/strong>&gt;<br>&nbsp; &lt;<strong>meta<\/strong> charset=\"utf-8\"&gt;<br>&nbsp; &lt;<strong>title<\/strong>&gt;{{ title }} &amp;middot; TO-DO app&lt;\/<strong>title<\/strong>&gt;<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add <code>i18n<\/code> to it, and wrap the literals in <code>{% translate %}<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/todo\/base.html #}<br>{% load i18n static %}&lt;!doctype html&gt;<br>&lt;<strong>html<\/strong> lang=\"{{ LANGUAGE_CODE }}\" data-bs-theme=\"light\"&gt;<br>&lt;<strong>head<\/strong>&gt;<br>&nbsp; &lt;<strong>meta<\/strong> charset=\"utf-8\"&gt;<br>&nbsp; &lt;<strong>title<\/strong>&gt;{{ title }} &amp;middot; {% translate \"TO-DO app\" %}&lt;\/<strong>title<\/strong>&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>{% load i18n %}<\/code> applies to the one template file it appears in. A child template that pulls in a parent with <code>{% extends %}<\/code> still needs its own load tag before it can use the tags.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A string with a value inside it needs <code>{% blocktranslate %}<\/code>. The navbar line reads:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>{# todo\/templates\/todo\/base.html (before) #}<br>&lt;<strong>span<\/strong> class=\"text-body-secondary small\"&gt;<br>&nbsp; Signed in as {{ user.username }}<br>&lt;\/<strong>span<\/strong>&gt;<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rewrite binds the variable with with and renames it to something short:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>{# todo\/templates\/todo\/base.html #}<br>&lt;<strong>span<\/strong> class=\"text-body-secondary small\"&gt;<br>&nbsp; {% blocktranslate trimmed with username=user.username %}Signed in as {{ username }}{% endblocktranslate %}<br>&lt;\/<strong>span<\/strong>&gt;<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>with<\/code> binding is what makes the string work. Leave <code>{{ user.username }}<\/code> inside the block and Django renders it as an empty string with no error, because <code>{% blocktranslate %}<\/code> resolves plain names from its own bindings and nothing else. Bound this way, the extracted <code>msgid<\/code> becomes <code>Signed in as %(username)s<\/code>, a sentence a translator can rearrange. trimmed strips the newlines and indentation from the block, which keeps the <code>msgid<\/code> on one line instead of carrying the template\u2019s whitespace into the catalog.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Other block tags inside <code>{% blocktranslate %}<\/code> raise <code>TemplateSyntaxError: 'blocktranslate' doesn't allow other block tags<\/code>, so no <code>{% if %}<\/code> and no <code>{% for %}<\/code>. When a sentence needs a URL, resolve it first with <code>{% url ... as var %}<\/code> and bind var, or split the sentence:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/registration\/register.html #}<br>&lt;<strong>p<\/strong> class=\"text-body-secondary\"&gt;<br>&nbsp; {% translate \"Already have an account?\" %}<br>&nbsp; &lt;<strong>a<\/strong> href=\"{% url 'todo:login' %}\"&gt;{% translate \"Log in\" %}&lt;\/<strong>a<\/strong>&gt;<br>&lt;\/<strong>p<\/strong>&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>{% trans %}<\/code> and <code>{% blocktrans %}<\/code> are registered names for these same two tags, so existing code and older tutorials will show them. The longer spellings are the current ones.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Translating views, models and forms<\/h2>\n\n\n\n<h4 class=\"wp-block-heading\">Views run at request time<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the flash message the registration view sends, as the app ships:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/views.py (before)<\/em><br><strong>if<\/strong> form.is_valid():<br>&nbsp; &nbsp; user = form.save()<br>&nbsp; &nbsp; login(request, user)<br>&nbsp; &nbsp; messages.success(<br>&nbsp; &nbsp; &nbsp; &nbsp; request, f\"Welcome, {user.username}! Your account is ready.\"<br>&nbsp; &nbsp; )<br>&nbsp; &nbsp; <strong>return<\/strong> redirect(\"todo:index\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>makemessages<\/code> extracts strings by scanning the source with <code>xgettext<\/code>. There is no literal here for it to find, since the sentence only exists once Python evaluates the f-string. The entry never reaches the catalog and the message stays English in every language.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rewrite keeps the whole sentence in one call and interpolates afterwards:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/views.py<\/em><br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext<br><br><strong>if<\/strong> form.is_valid():<br>&nbsp; &nbsp; user = form.save()<br>&nbsp; &nbsp; login(request, user)<br>&nbsp; &nbsp; messages.success(<br>&nbsp; &nbsp; &nbsp; &nbsp; request,<br>&nbsp; &nbsp; &nbsp; &nbsp; gettext(\"Welcome, %(username)s! Your account is ready.\")<br>&nbsp; &nbsp; &nbsp; &nbsp; % {\"username\": user.username},<br>&nbsp; &nbsp; )<br>&nbsp; &nbsp; <strong>return<\/strong> redirect(\"todo:index\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use named placeholders such as <code>%(username)s<\/code> rather than <code>bare %s<\/code>. A translator can move a named placeholder anywhere in the sentence, French word order included, and a mistyped name raises KeyError at the point of failure instead of quietly formatting the wrong value into the string.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Models and forms run at import time<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Model metadata is built while Django imports the app, so every string in it needs the lazy call from the previous section:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/models.py (before)<\/em><br><strong>class<\/strong> TodoList(models.Model):<br>&nbsp; &nbsp; name = models.CharField(\"name\", max_length=100)<br>&nbsp; &nbsp; created_at = models.DateTimeField(\"created at\", auto_now_add=True)<br><br>&nbsp; &nbsp; <strong>class<\/strong> Meta:<br>&nbsp; &nbsp; &nbsp; &nbsp; verbose_name = \"to-do list\"<br>&nbsp; &nbsp; &nbsp; &nbsp; verbose_name_plural = \"to-do lists\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The same class with the strings marked, using the _ alias Django\u2019s own code uses for <code>gettext_lazy<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/models.py<\/em><br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext_lazy <strong>as<\/strong> _<br><br><br><strong>class<\/strong> TodoList(models.Model):<br>&nbsp; &nbsp; name = models.CharField(_(\"name\"), max_length=100)<br>&nbsp; &nbsp; created_at = models.DateTimeField(_(\"created at\"), auto_now_add=True)<br><br>&nbsp; &nbsp; <strong>class<\/strong> Meta:<br>&nbsp; &nbsp; &nbsp; &nbsp; verbose_name = _(\"to-do list\")<br>&nbsp; &nbsp; &nbsp; &nbsp; verbose_name_plural = _(\"to-do lists\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Forms are the same case. Labels, placeholders and error messages are all built when the class body executes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/forms.py<\/em><br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext_lazy <strong>as<\/strong> _<br><br><br><strong>class<\/strong> RegisterForm(forms.ModelForm):<br>&nbsp; &nbsp; password = forms.CharField(<br>&nbsp; &nbsp; &nbsp; &nbsp; label=_(\"Password\"),<br>&nbsp; &nbsp; &nbsp; &nbsp; strip=False,<br>&nbsp; &nbsp; &nbsp; &nbsp; widget=forms.PasswordInput(<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; attrs={<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; \"class\": \"form-control\",<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; \"placeholder\": _(\"At least 6 characters\"),<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }<br>&nbsp; &nbsp; &nbsp; &nbsp; ),<br>&nbsp; &nbsp; &nbsp; &nbsp; error_messages={\"required\": _(\"Please choose a password.\")},<br>&nbsp; &nbsp; )<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Aliasing <code>gettext_lazy<\/code> to <code>_<\/code> is a Django convention, and makemessages recognizes <code>_<\/code> along with the full function names. It is a convention rather than a requirement, and it hides which of the two functions you called, so this guide reserves <code>_<\/code> for modules that are lazy throughout, like <code>models.py<\/code>, <code>forms.py<\/code>, <code>apps.py<\/code> and <code>settings.py<\/code>, and spells the function out in views.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">What to leave alone<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Not every string in the code is text a user reads:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>URL pattern names.<\/strong> <code>path(\"lists\/&lt;int:pk&gt;\/\"<\/code>, <code>views.list_detail, name=\"list_detail\")<\/code> is looked up by <code>reverse()<\/code> and <code>{% url %}<\/code>. Translating the name breaks both.<\/li>\n\n\n\n<li><strong>Field and parameter identifiers.<\/strong> <code>fields = [\"name\"]<\/code>, <code>request.GET.get(\"show\", \"all\")<\/code>, and the filter keys <code>\"all\"<\/code>, <code>\"open\"<\/code> and <code>\"done\"<\/code> that end up in the query string. Only the labels beside them are user-facing.<\/li>\n\n\n\n<li><strong>Model field names.<\/strong> In done <code>= models.BooleanField(_(\"done\")<\/code>, <code>default=False)<\/code>, <code>done<\/code> is the column name and the marked string is its label.<\/li>\n\n\n\n<li><strong>Anything only the team reads.<\/strong> CSS classes, template paths, <code>related_name<\/code> values, log lines and exception messages aimed at developers.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Which strings count as user-facing is a decision every project makes, and it is cheaper to settle before the first catalog exists. Our article on <a href=\"\/blog\/internationalization-best-practices\/\">internationalization best practices<\/a> covers that decision along with the others worth making early.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Plurals and context<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A <code>msgid<\/code> on its own cannot express two things: how a sentence changes with a count, and which of several meanings a word carries. The app fakes both.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Counts<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">There are three home-made plurals in the code. The lists page branches on the count in the template:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/todo\/index.html (before) #}<br>&lt;<strong>p<\/strong> class=\"text-body-secondary\"&gt;<br>&nbsp; {% if todo_lists|length == 1 %}<br>&nbsp; &nbsp; You have {{ todo_lists|length }} list.<br>&nbsp; {% else %}<br>&nbsp; &nbsp; You have {{ todo_lists|length }} lists.<br>&nbsp; {% endif %}<br>&lt;\/<strong>p<\/strong>&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each list row sidesteps the count with a bracketed hedge, <code>{{ todo_list.open_count }} task(s) left<\/code>. And the same branch appears again in Python, where the list detail view builds its own label:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/views.py (before)<\/em><br><strong>if<\/strong> open_count == 1:<br>&nbsp; &nbsp; open_label = f\"{open_count} task open\"<br><strong>else<\/strong>:<br>&nbsp; &nbsp; open_label = f\"{open_count} tasks open\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">All three hardcode two assumptions: that a language has exactly two plural forms, and that the split falls at one. Japanese has one form. Arabic has six categories. Polish picks its form from the last two digits. And French, in the plural rules Django uses, puts zero in the same form as one, so \u201c0 task left\u201d takes the singular there and no <code>if count == 1<\/code> branch will ever produce it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In a template, <code>{% blocktranslate %}<\/code> with count sends the number to gettext, and<code> {% plural %}<\/code> separates the two English forms:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/todo\/index.html #}<br>{% blocktranslate trimmed count counter=todo_lists|length %}<br>&nbsp; You have {{ counter }} list.<br>{% plural %}<br>&nbsp; You have {{ counter }} lists.<br>{% endblocktranslate %}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In Python, <code>ngettext()<\/code> takes the singular, the plural and the number, in that order, and returns the form the active language needs. The message written when a list is deleted uses the same branch as <code>open_label<\/code> above:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/views.py<\/em><br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext, ngettext<br><br>messages.success(<br>&nbsp; &nbsp; request,<br>&nbsp; &nbsp; gettext('List \"%(name)s\" deleted.') % {\"name\": name}<br>&nbsp; &nbsp; + \" \"<br>&nbsp; &nbsp; + ngettext(<br>&nbsp; &nbsp; &nbsp; &nbsp; \"%(count)d task went with it.\",<br>&nbsp; &nbsp; &nbsp; &nbsp; \"%(count)d tasks went with it.\",<br>&nbsp; &nbsp; &nbsp; &nbsp; deleted,<br>&nbsp; &nbsp; )<br>&nbsp; &nbsp; % {\"count\": deleted},<br>)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You still write two English forms, because that is what English has. How many forms the translation needs, and which number selects which, is recorded in the catalog\u2019s <code>Plural-Forms<\/code> header. Django writes this for English:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And this for French:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 &amp;&amp; n % \"<br>\"1000000 == 0 ? 1 : 2;\\n\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three forms against two, from the same two-form call in the code. The catalog is where that expression belongs.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Meaning<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">The word <strong>Done<\/strong> appears three times on one list page: as a filter pill above the tasks, as a status badge on a finished task, and as the button that finishes an unfinished one.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"814\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03-done-three-times-1024x814.png\" alt=\"\" class=\"wp-image-8304\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03-done-three-times-1024x814.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03-done-three-times-300x239.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03-done-three-times-767x610.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03-done-three-times-1536x1221.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03-done-three-times-2048x1628.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The same word doing three jobs: filter, status badge and action button<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three separate literals in the source, and gettext collapses them into a single <code>msgid<\/code>. A translator sees one entry called \u201cDone\u201d and has to guess. French wants the plural adjective <strong>Termin\u00e9es<\/strong> for the filter over several tasks, the singular <strong>Termin\u00e9e<\/strong> for one task\u2019s badge, and the verb <strong>Terminer<\/strong> for the button.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>pgettext()<\/code> attaches a context to a string in Python:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/views.py<\/em><br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> pgettext<br><br>filters = &#091;<br>&nbsp; &nbsp; (\"all\", pgettext(\"task filter\", \"All\"), total_count),<br>&nbsp; &nbsp; (\"open\", pgettext(\"task filter\", \"Open\"), open_count),<br>&nbsp; &nbsp; (\"done\", pgettext(\"task filter\", \"Done\"), total_count - open_count),<br>]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The template tag takes a context argument for the same purpose:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/todo\/list_detail.html #}<br>&lt;<strong>span<\/strong> class=\"badge rounded-pill {% if task.done %}text-bg-success{% else %}text-bg-secondary{% endif %}\"&gt;<br>&nbsp; {% if task.done %}<br>&nbsp; &nbsp; {% translate \"Done\" context \"task status\" %}<br>&nbsp; {% else %}<br>&nbsp; &nbsp; {% translate \"Open\" context \"task status\" %}<br>&nbsp; {% endif %}<br>&lt;\/<strong>span<\/strong>&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The button keeps a bare <code>{% translate \"Done\" %}<\/code>, which leaves three distinct entries in the catalog:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># locale\/en\/LC_MESSAGES\/django.po<br>#: todo\/templates\/todo\/list_detail.html:94<br>msgctxt \"task status\"<br>msgid \"Done\"<br>msgstr \"\"<br><br>#: todo\/templates\/todo\/list_detail.html:105<br>msgid \"Done\"<br>msgstr \"\"<br><br>#: todo\/views.py:153<br>msgctxt \"task filter\"<br>msgid \"Done\"<br>msgstr \"\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A context is part of the string\u2019s identity. Renaming &#8220;<code>task filter<\/code>&#8221; to &#8220;<code>filter<\/code>&#8221; creates a new entry and orphans the translation attached to the old one, so settle on your context labels before translation starts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When a string needs both, <code>npgettext()<\/code> takes the context first, then the singular, the plural and the number. That is how <code>open_label<\/code> ends up in the finished app:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/views.py<\/em><br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> npgettext<br><br>open_label = npgettext(<br>&nbsp; &nbsp; \"task counter\",<br>&nbsp; &nbsp; \"%(count)d task open\",<br>&nbsp; &nbsp; \"%(count)d tasks open\",<br>&nbsp; &nbsp; open_count,<br>) % {\"count\": open_count}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Extracting strings with makemessages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With the strings marked, generate the catalogs from the project root, the directory holding <code>manage.py<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>django-admin makemessages -l en -l fr --ignore=.venv<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>makemessages scans<\/code> the <code>.py<\/code>, <code>.html <\/code>and <code>.txt<\/code> files under the current directory for marked strings, then writes or updates <code>locale\/&lt;language code&gt;\/LC_MESSAGES\/django.po<\/code> for each language you named. Run it again later and it merges the new state into the existing files, keeping the translations that are still current.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you have used gettext outside Django you will look for the<code> .pot<\/code> template and not find one. Django generates a <code>.pot<\/code> internally and merges it into each language\u2019s <code>.po<\/code> in the same run, leaving the per-language files as the only artifacts. <code>--keep-pot<\/code> writes the intermediate file to <code>locale\/django.pot<\/code> if you want it, which is useful when a tool in your pipeline expects a template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Without <code>--ignore<\/code>, <code>makemessages<\/code> walks into <code>.venv<\/code> and extracts every marked string in Django itself. <code>compilemessages<\/code> has no default ignore list either, so it needs the same flag later. Pass <code>--ignore=.venv<\/code> to both, along with anything else in the tree you do not own.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a cut of the generated English catalog:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># locale\/en\/LC_MESSAGES\/django.po<br>\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"<br><br>#: todo\/forms.py:14 todo\/forms.py:74<br>msgid \"Password\"<br>msgstr \"\"<br><br>#: todo\/templates\/todo\/index.html:35<br>#, python-format<br>msgid \"You have %(counter)s list.\"<br>msgid_plural \"You have %(counter)s lists.\"<br>msgstr&#091;0] \"\"<br>msgstr&#091;1] \"\"<br><br>#: todo\/views.py:153<br>msgctxt \"task filter\"<br>msgid \"Done\"<br>msgstr \"\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>#:<\/code> lines are source references, regenerated on every run, and they are what lets a translator ask where a string appears. <code>#, python-format<\/code> tells gettext tools to check the placeholders. Plural entries get numbered <code>msgstr[n]<\/code> slots, one per form the language declares.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the standard Gettext format. The same <code>.po <\/code>entries, <code>msgctxt<\/code> values and <code>msgstr[n]<\/code> slots carry translations for a <a href=\"\/blog\/python-gettext-localization\/\">plain Python app using gettext<\/a> and for a <a href=\"\/blog\/drupal-translation-po-pot-files\/\">Drupal site<\/a>, and POEditor reads all of them the same way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The whole app comes to <strong>82 source strings<\/strong>. Confirm the count with <code>msgfmt --statistics -o \/dev\/null locale\/en\/LC_MESSAGES\/django.po<\/code>, which reports how many messages are translated and how many are not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The&nbsp; <code>--no-obsolete<\/code> flag deletes entries whose source string is gone instead of keeping them commented out at the end of the file with <code>#~ <\/code>markers. Those entries are gettext\u2019s safety net for a string you might restore, and they are noise once you are sure it is gone for good.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Translating the catalog in POEditor<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A <code>.po<\/code> file in the repository works while there is one developer and one language. It stops working the moment a translator who does not use git is involved. POEditor reads and writes standard Gettext files, so nothing in the Django project changes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Log in and create a project for the app. A project needs its languages before it can hold anything, so add two: English as the source, French as the target.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"632\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04-poeditor-languages-1024x632.png\" alt=\"\" class=\"wp-image-8305\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04-poeditor-languages-1024x632.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04-poeditor-languages-300x185.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04-poeditor-languages-767x473.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04-poeditor-languages-1536x948.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04-poeditor-languages-2048x1263.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The project with English as the source language and French as the target<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Open <a href=\"\/kb\/import-options\"><strong>Import<\/strong><\/a> and choose <code>locale\/en\/LC_MESSAGES\/django.po<\/code>. The page imports terms, so leave <strong>Also import translations to a language<\/strong> off: a freshly extracted catalog has terms and no translations yet. Press <strong>Import to project<\/strong>, and POEditor reports <code>82 terms found<\/code>: <code>82 terms added<\/code>. The metadata entry at the top of the file is not a term, so the number matches the 82 strings <code>makemessages <\/code>extracted.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"926\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05-poeditor-import-1024x926.png\" alt=\"\" class=\"wp-image-8306\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05-poeditor-import-1024x926.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05-poeditor-import-300x271.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05-poeditor-import-767x694.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05-poeditor-import-1536x1389.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05-poeditor-import-2048x1852.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Importing the English catalog on the Import page<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><code>msgctxt <\/code>values arrive as a <strong>CONTEXT<\/strong> label on the term, so the three \u201cDone\u201d entries stay separate, and plural entries keep both of their forms.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next, under <strong>Project settings \u2192 Edit Details<\/strong>, set the <a href=\"\/kb\/setting-a-default-reference-language\"><strong>Default Reference Language<\/strong><\/a> to English, so a translator sees the source string beside the field they are filling in. Press <em>Save project details<\/em>; changing the dropdown does not save on its own.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Importing terms creates the term list without creating any English translations, so English sits at 0% with nothing to show as a reference. Open the English language page and use <a href=\"\/kb\/how-to-copy-strings-to-empty-translation-boxes\"><strong>Copy terms to translations<\/strong><\/a>, which fills every empty box with its own term and leaves existing translations alone. It fills the singular of a plural entry and leaves the other forms blank, so type those in.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"560\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06-poeditor-copy-terms-1024x560.png\" alt=\"\" class=\"wp-image-8308\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06-poeditor-copy-terms-1024x560.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06-poeditor-copy-terms-300x164.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06-poeditor-copy-terms-766x419.png 766w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06-poeditor-copy-terms-1536x840.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06-poeditor-copy-terms-2048x1120.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Copy terms to translations on the English language page<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Before translating by hand, note that POEditor can auto-translate the imported strings first, using Google Translate, DeepL, Azure AI Translator, or an AI provider such as OpenAI or Claude, so a translator starts from a draft rather than a blank page. This is optional, and available on every plan including Free.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now open French and work down the list. Translations save as you go, and two details are worth checking here:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A pluralized term gets a <a href=\"\/kb\/plural-forms\">tab per plural form<\/a>, labelled with CLDR categories. French shows <strong>ONE<\/strong>, <strong>MANY<\/strong> and <strong>OTHER<\/strong>, matching the three forms its <code>Plural-Forms<\/code> header declares.<\/li>\n\n\n\n<li>A term with a context shows that context next to it, so the filter \u201cDone\u201d and the status \u201cDone\u201d are visibly two jobs.<\/li>\n<\/ul>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"510\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-poeditor-translation-plurals-1024x510.png\" alt=\"\" class=\"wp-image-8307\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-poeditor-translation-plurals-1024x510.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-poeditor-translation-plurals-300x149.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-poeditor-translation-plurals-767x382.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-poeditor-translation-plurals-1536x765.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-poeditor-translation-plurals-2048x1020.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Plural form tabs and a context label on the French translation page<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"\/kb\/qa-checks\">POEditor\u2019s QA checks<\/a> cover placeholders, including the <code>%(name)s<\/code> style Django uses, and flag a translation that drops one as soon as it is saved. Without the QA check, the same mistake would surface as a <code>KeyError<\/code> when that page is rendered.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When French is far enough along, go to <a href=\"\/kb\/export-options\"><strong>Export<\/strong><\/a>, choose <strong>Gettext PO (.po)<\/strong>, press <strong>Export File<\/strong>, and save the download over <code>locale\/fr\/LC_MESSAGES\/django.po<\/code>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"765\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-poeditor-export-1024x765.png\" alt=\"\" class=\"wp-image-8309\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-poeditor-export-1024x765.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-poeditor-export-300x224.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-poeditor-export-767x573.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-poeditor-export-1536x1147.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-poeditor-export-2048x1530.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Exporting the French catalog as Gettext PO<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Quick tip:<\/strong> open the exported file and check that its <code>Plural-Forms<\/code> header contains a <code>plural=<\/code> expression alongside <code>nplurals=<\/code>. A header missing the expression fails to compile.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Compiling translations and switching languages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Django reads the binary <code>.mo<\/code> file, never the <code>.po<\/code>. Compile the catalogs:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>python manage.py compilemessages --ignore=.venv<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It names each file it processes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>processing file django.po in \/path\/to\/project\/locale\/en\/LC_MESSAGES<br>processing file django.po in \/path\/to\/project\/locale\/fr\/LC_MESSAGES<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That writes <code>django.mo<\/code> beside each <code>django.po<\/code>. Restart the server, because catalogs are loaded and cached per process. This step repeats after every catalog change, including every export out of POEditor, which makes it a good candidate for a build step.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;d rather skip compilemessages, POEditor&#8217;s Export screen can produce the compiled catalog directly: choose Gettext MO (.mo) as the file format and download it straight into <code>LC_MESSAGES\/<\/code>, naming it django.mo to match the domain. Keep the <code>compilemessages<\/code> step if compiling is already part of a build or CI process.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"544\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09-french-lists-1024x544.png\" alt=\"\" class=\"wp-image-8310\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09-french-lists-1024x544.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09-french-lists-300x159.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09-french-lists-767x407.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09-french-lists-1536x815.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09-french-lists-2048x1087.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The lists page in French, rendered from the catalog exported out of POEditor<\/em><\/figcaption><\/figure>\n\n\n\n<h4 class=\"wp-block-heading\">How Django picks the language<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\"><code>LocaleMiddleware<\/code> decides the active language per request, in this order:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>A <strong>language prefix in the URL path<\/strong>, and only when the URLconf is wrapped in <code>i18n_patterns<\/code> (the next section).<\/li>\n\n\n\n<li>The <strong><code>django_language<\/code><\/strong> <strong>cookie<\/strong>, whose name comes from the<code> LANGUAGE_COOKIE_NAME<\/code> setting.<\/li>\n\n\n\n<li>The <strong><code>Accept-Language<\/code><\/strong> request header, first entry that matches an entry in <code>LANGUAGES<\/code>.<\/li>\n\n\n\n<li><strong><code>LANGUAGE_CODE<\/code><\/strong>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Older tutorials add a session lookup between the URL prefix and the cookie. That step was removed in Django 4.0, which is why it is absent here and why a <code>django_language<\/code> key in the session does nothing.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Let the visitor choose<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Django\u2019s <code>set_language<\/code> view writes the cookie. Route it by including <code>django.conf.urls.i18n<\/code>, which puts the view at <code>i18n\/setlang\/<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todosite\/urls.py<\/em><br><strong>from<\/strong> django.urls <strong>import<\/strong> include, path<br><br>urlpatterns = &#091;<br>&nbsp; &nbsp; <em># Django's own i18n URLs, which is where set_language lives.<\/em><br>&nbsp; &nbsp; path(\"i18n\/\", include(\"django.conf.urls.i18n\")),<br>&nbsp; &nbsp; path(\"\", include(\"todo.urls\")),<br>]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Add <code>django.template.context_processors.i18n<\/code> to the template context processors, which puts <code>LANGUAGES<\/code>, <code>LANGUAGE_CODE<\/code> and <code>LANGUAGE_BIDI<\/code> in every context. Then post to the view from the navbar:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/todo\/base.html #}<br>&lt;<strong>form<\/strong> action=\"{% url 'set_language' %}\" method=\"post\" class=\"m-0\"&gt;<br>&nbsp; {% csrf_token %}<br>&nbsp; &lt;<strong>input<\/strong> type=\"hidden\" name=\"next\" value=\"{{ request.get_full_path }}\"&gt;<br>&nbsp; &lt;<strong>div<\/strong> class=\"btn-group btn-group-sm\" role=\"group\" id=\"lang-switcher\"<br>&nbsp; &nbsp; &nbsp; aria-label=\"{% translate 'Language' %}\"&gt;<br>&nbsp; &nbsp; {% for code, name in LANGUAGES %}<br>&nbsp; &nbsp; &nbsp; &lt;<strong>button<\/strong> type=\"submit\" name=\"language\" value=\"{{ code }}\"<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; class=\"btn {% if code == LANGUAGE_CODE %}btn-secondary active{% else %}btn-outline-secondary{% endif %}\"&gt;<br>&nbsp; &nbsp; &nbsp; &nbsp; {{ name }}<br>&nbsp; &nbsp; &nbsp; &lt;\/<strong>button<\/strong>&gt;<br>&nbsp; &nbsp; {% endfor %}<br>&nbsp; &lt;\/<strong>div<\/strong>&gt;<br>&lt;\/<strong>form<\/strong>&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The view acts only on <code>POST<\/code>. It sets the <code>django_language<\/code> cookie to the submitted code and redirects to next, and <code>LocaleMiddleware<\/code> reads that cookie on the following request. A code outside <code>LANGUAGES<\/code> gets no further than the cookie, since the middleware will not activate it. The cookie is why the choice survives the next request and the next session.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"542\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10-language-switcher-1024x542.png\" alt=\"\" class=\"wp-image-8311\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10-language-switcher-1024x542.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10-language-switcher-300x159.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10-language-switcher-767x406.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10-language-switcher-1536x813.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10-language-switcher-2048x1084.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The navbar language buttons, with French active<\/em><\/figcaption><\/figure>\n\n\n\n<h4 class=\"wp-block-heading\">Code that runs without a request<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Management commands, queued jobs and outgoing email have no request, so no middleware has activated a language for them. Pick one explicitly with <code>django.utils.translation.activate()<\/code>, or scope it to a block with the <code>override()<\/code> context manager, which restores the previous language on exit:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># any command, task or email builder<\/em><br><strong>from<\/strong> django.utils <strong>import<\/strong> translation<br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext<br><br><strong>with<\/strong> translation.override(user.preferred_language):<br>&nbsp; &nbsp; subject = gettext(\"Your weekly summary\")<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">When a translation does not appear<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Work down this list:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The string was never extracted. Check for a <code>#:<\/code> reference to its file in the <code>.po<\/code>.<\/li>\n\n\n\n<li>The catalog was never compiled, or was compiled before the last export. Re-run <code>compilemessages<\/code> and restart.<\/li>\n\n\n\n<li>The language is missing from <code>LANGUAGES<\/code>, so Django will not activate it.<\/li>\n\n\n\n<li><code>LocaleMiddleware<\/code> is missing, or sits after <code>CommonMiddleware<\/code>.<\/li>\n\n\n\n<li>The entry is marked <code>#<\/code>, <code>fuzzy.<\/code> Django ignores fuzzy entries at runtime.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Translating URLs with i18n_patterns<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Wrapping the root URLconf in i18n_patterns puts the language code at the front of every path in it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todosite\/urls.py<\/em><br><strong>from<\/strong> django.conf.urls.i18n <strong>import<\/strong> i18n_patterns<br><strong>from<\/strong> django.urls <strong>import<\/strong> include, path<br><br>urlpatterns = &#091;<br>&nbsp; &nbsp; path(\"i18n\/\", include(\"django.conf.urls.i18n\")),<br>]<br><br>urlpatterns += i18n_patterns(<br>&nbsp; &nbsp; path(\"\", include(\"todo.urls\")),<br>&nbsp; &nbsp; prefix_default_language=False,<br>)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>\/lists\/3\/<\/code> becomes <code>\/en\/lists\/3\/<\/code> and <code>\/fr\/lists\/3\/<\/code>, and the prefix is the first thing <code>LocaleMiddleware<\/code> looks at. <code>prefix_default_language=False<\/code> leaves the default language unprefixed, so en keeps <code>\/lists\/3\/<\/code> and only French gains a prefix. Use it when you are adding languages to a site whose URLs are already published.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The pattern strings themselves can be translated. <code>path()<\/code> accepts a lazy string, and <code>makemessages<\/code> extracts it like any other marked string:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/urls.py<\/em><br><strong>from<\/strong> django.urls <strong>import<\/strong> path<br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext_lazy <strong>as<\/strong> _<br><br><strong>from<\/strong> . <strong>import<\/strong> views<br><br>app_name = \"todo\"<br><br>urlpatterns = &#091;<br>&nbsp; &nbsp; path(_(\"lists\/&lt;int:pk&gt;\/\"), views.list_detail, name=\"list_detail\"),<br>]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Translate that <code>msgid<\/code> to <code>listes\/&lt;int:pk&gt;\/<\/code> and a French visitor browses <code>\/fr\/listes\/3\/<\/code>. Keep the converter syntax intact in the translation, since it is still the pattern Django matches against.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>reverse()<\/code> and <code>{% url %}<\/code> resolve against the active language, so the same view name produces a different path depending on the language in effect. This is where a half-migrated project breaks: a <code>hardcoded \/lists\/3\/<\/code> in a template works in English and 404s in French, while <code>{% url 'todo:list_detail' pk %}<\/code> works in both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Distinct URLs per language are worth having for public content, where each language gets its own indexable address. Behind a login they add a prefix that nobody links to and no search engine sees. The TO-DO app is entirely behind a login, so it keeps unprefixed URLs and lets the cookie carry the choice.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Dates, numbers and time zones<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The app prints dates with an explicit format string, which produces US conventions for every visitor:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/todo\/index.html (before) #}<br>created {{ todo_list.created_at|date:\"N j, Y\" }}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Name a format instead of writing one, and the format comes from the active locale\u2019s format module:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todo\/templates\/todo\/index.html #}<br>{% blocktranslate trimmed with when=todo_list.created_at|date:\"DATE_FORMAT\" %}created {{ when }}{% endblocktranslate %}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The same template and the same datetime now render Sept. 2, 2026 in English and 2 septembre 2026 in French. <code>DATETIME_FORMAT<\/code>, <code>SHORT_DATE_FORMAT<\/code> and <code>TIME_FORMAT<\/code> work the same way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Numbers follow the locale too. Turn on digit grouping in settings:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todosite\/settings.py<\/em><br>USE_THOUSAND_SEPARATOR = True<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>USE_THOUSAND_SEPARATOR<\/code> defaults to <code>False<\/code>, and it controls the grouping only. Decimal separators follow the active locale whether or not it is on. English renders 1,234 and 33.3; French renders 1 234, with a non-breaking space, and 33,3. In a template, <code>{% load l10n %}<\/code> gives you the localize filter for making it explicit on one value. In Python, <code>django.utils.formats.number_format<\/code> does the same job:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># todo\/views.py<\/em><br><strong>from<\/strong> django.utils.formats <strong>import<\/strong> number_format<br><strong>from<\/strong> django.utils.translation <strong>import<\/strong> gettext<br><br>progress_label = gettext(\"%(percent)s%% of %(total)s tasks done\") % {<br>&nbsp; &nbsp; \"percent\": number_format(todo_list.percent_done, decimal_pos=1),<br>&nbsp; &nbsp; \"total\": number_format(total_count),<br>}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The doubled <code>%%<\/code> is how a literal percent sign survives Python\u2019s interpolation, and it reaches the catalog doubled, as <code>%(percent)s%%<\/code> of <code>%(total)s<\/code> tasks done. A French translation of that entry has to keep both signs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you find a tutorial telling you to set <code>USE_L10N<\/code>, it predates Django 5.0, which removed the setting; <code>locale-aware<\/code> formatting is always on now.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>USE_TZ<\/code> is <code>True<\/code> in a new project, so Django stores datetimes in UTC and converts them to the current time zone for display. <code>{% load tz %}<\/code> provides <code>{% localtime %}<\/code>, <code>{% timezone %}<\/code> and the localtime filter for per-template control, and <code>{% load l10n %}<\/code> provides <code>{% localize off %} <\/code>for the places where you need a raw value, such as a number going into a form field or a data attribute.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Updating translations as the app changes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After the initial setup, the cycle repeats whenever you add or change a string:&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>edit code&nbsp; -&gt;&nbsp; makemessages&nbsp; -&gt;&nbsp; import to POEditor&nbsp; -&gt;&nbsp; translate<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |<br>&nbsp; app&nbsp; &lt;-&nbsp; restart&nbsp; &lt;-&nbsp; compilemessages&nbsp; &lt;-&nbsp; export .po&nbsp; &lt;-'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Re-run <code>makemessages<\/code> whenever strings change, and read the diff before committing it. New entries with empty <code>msgstr<\/code> are the ones that need translating, and a big diff of nothing but <code>#:<\/code> line numbers means somebody edited code above a string rather than the string itself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Watch for fuzzy entries. When a <code>msgid<\/code> changes slightly, gettext matches it to the closest old entry, copies that translation across and marks the pair <code>#<\/code>, <code>fuzzy<\/code>. Django ignores fuzzy entries at runtime, so the string falls back to English until a human reviews it. The result is that an existing translation can disappear from the live app without raising an error; the sentence simply falls back to English.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a good candidate for a CI check: run <code>makemessages<\/code> as a build step and fail the build if it produces a diff nobody committed. That turns a forgotten translation call into a red build instead of a French-speaking user quietly seeing English.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both ends of the round trip can be automated. The <a href=\"\/docs\/api\">POEditor API<\/a> covers the same two steps, and <a href=\"\/blog\/automating-localization-workflow-poeditor-api-quick-guide\/\">automating your localization workflow with the API<\/a> walks through a complete script. Uploading a freshly extracted catalog:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST https:\/\/api.poeditor.com\/v2\/projects\/upload \\<br>&nbsp; -F api_token=\"YOUR_API_TOKEN\" \\<br>&nbsp; -F id=\"YOUR_PROJECT_ID\" \\<br>&nbsp; -F updating=\"terms\" \\<br>&nbsp; -F file=@\"locale\/en\/LC_MESSAGES\/django.po\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>updating=\"terms\"<\/code> syncs the term list without touching translations, and the projects\/export method returns a temporary download URL for the finished French <code>.po<\/code>. POEditor also <a href=\"\/kb\/localization-file-management-with-github-bitbucket-and-gitlab-integrations\">integrates with GitHub, GitLab, Bitbucket and Azure DevOps<\/a>, which removes the scripts entirely. In either case, run the export and <code>compilemessages<\/code> as part of your build, so a deploy cannot ship a stale <code>.mo<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The workflow is straightforward: strings marked with the call that suits where they live, plurals and contexts in the catalog instead of in if branches, <code>django.po<\/code> generated by <code>makemessages<\/code>, French translated in POEditor, <code>django.mo<\/code> compiled from the export, and a switcher that writes the cookie <code>LocaleMiddleware<\/code> reads. Adding German from here means adding a language in POEditor, translating, exporting <code>locale\/de\/LC_MESSAGES\/django.po<\/code> and compiling. There is no Python or template work left to do per language.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To try it on your own app: mark a dozen user-facing strings, run <code>makemessages<\/code>, import the <code>.po<\/code> into a POEditor project, translate a few entries, then export, compile and switch. A project this size fits inside <a href=\"\/pricing\/\">POEditor\u2019s free plan<\/a>, which allows up to 1,000 strings.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>What does Django translate out of the box, and what doesn&#8217;t it cover?<\/summary>\n<p class=\"wp-block-paragraph\">Django installs its own message catalogs with the framework, under django\/conf\/locale\/ and inside each contrib app. They cover the admin, authentication views, form and field errors, password validator messages, and locale-specific date and number formats, and Django serves them once a language is declared in LANGUAGES and activated. Strings written in your own project are in none of those catalogs, so they stay English until they are marked, extracted and translated.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>How do I mark strings for translation in Django templates and views?<\/summary>\n<p class=\"wp-block-paragraph\">Templates load the tags with {% load i18n %}, then use {% translate &#8220;Create list&#8221; %} for a plain string and {% blocktranslate %} for a sentence containing a variable, bound with with. In Python, views use gettext(), while code evaluated at import time, such as models, forms and settings, uses gettext_lazy(). An f-string isn&#8217;t extracted, so the whole sentence needs to be inside the gettext call.&nbsp;<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>What is the difference between gettext and gettext_lazy in Django?<\/summary>\n<p class=\"wp-block-paragraph\">gettext() looks up the active language and returns a string immediately, which suits code that runs per request, such as view bodies. gettext_lazy() returns a proxy resolved when the text is used, so it is the one for anything evaluated at import time: model fields and Meta, form labels, help text and settings. A lazy string is not a str, so it is not JSON serializable, and joining it with + resolves it early.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>How do I handle plural translations in Django?<\/summary>\n<p class=\"wp-block-paragraph\">In a template, {% blocktranslate count counter=todo_lists|length %} sends the number to gettext, with {% plural %} separating the two English forms. In Python, ngettext() takes the singular, the plural and the number. You write two English forms because that is what English has; how many forms the translation uses comes from the Plural-Forms header in each catalog, and French declares three.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>How do I add translation context for ambiguous strings in Django?<\/summary>\n<p class=\"wp-block-paragraph\">Use pgettext(&#8220;task filter&#8221;, &#8220;Done&#8221;) in Python, or {% translate &#8220;Done&#8221; context &#8220;task status&#8221; %} in a template. The context is stored as the entry&#8217;s msgctxt. This keeps the filter, status badge and button versions of \u201cDone\u201d separate in the catalog, so they can be translated differently. Renaming a context creates a new entry and orphans the old translation. npgettext() covers context and plural forms together.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>How does Django decide which language to serve a visitor?<\/summary>\n<p class=\"wp-block-paragraph\">LocaleMiddleware determines the active language on every request in this order: a language prefix in the URL path, but only when the URLconf is wrapped in i18n_patterns; then the django_language cookie; then the first Accept-Language entry that matches; then LANGUAGE_CODE. Only languages listed in LANGUAGES can be activated. The session lookup described in older tutorials was removed in Django 4.0.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>How do I translate URLs in Django with i18n_patterns?<\/summary>\n<p class=\"wp-block-paragraph\">Wrapping the root URLconf in i18n_patterns() puts the language code at the front of every path in it, and prefix_default_language=False leaves the default language unprefixed. The pattern strings themselves can be translated as well, since path() accepts a lazy string that makemessages extracts like any other. reverse() and {% url %} resolve against the active language, so hardcoded paths are what break in a translated URLconf.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Can I manage a Django project&#8217;s .po files in POEditor?<\/summary>\n<p class=\"wp-block-paragraph\">Yes. makemessages writes standard Gettext catalogs, so locale\/en\/LC_MESSAGES\/django.po can be imported into POEditor as it is, keeping msgctxt contexts and plural forms intact. Translations can be exported back as a .po file, or as a compiled .mo that goes straight into LC_MESSAGES\/. The POEditor API and the Git integrations can run both ends of that round trip automatically.<\/p>\n<\/details>\n\n\n<div class=\"call-action my-4 d-flex justify-content-between align-items-md-center gap-4 flex-column flex-lg-row\"><div><h3 class=\"fs-4\">Ready to power up localization?<\/h3><span class=\"fs-6\">Subscribe to the POEditor platform today!<\/span><\/div><a class=\"btn btn-b-primary d-flex align-items-center justify-content-center px-4 py-3 flex-shrink-0\" \n\t\t\t\t\thref=\"https:\/\/poeditor.com\/pricing\/?utm_source=blog&#038;utm_medium=btn&#038;utm_campaign=cta_pricing\">See pricing<\/a><\/div>\n","protected":false},"excerpt":{"rendered":"<p>Django comes with an ORM, an admin site, authentication, and a template engine, so you can get an application up and running quickly. As the project grows, though, supporting users in different languages requires more than translating a few strings. Internationalization (i18n) prepares the code for multiple languages and locales. Localization (l10n) provides the translations [&hellip;]<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-8291","post","type-post","status-publish","format-standard","hentry","category-tutorials"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.5 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Django i18n: How to translate and localize your app - POEditor Blog<\/title>\n<meta name=\"description\" content=\"Learn Django i18n with POEditor: set up translations, manage locale files, streamline collaboration, and deploy a multilingual Django app.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/poeditor.com\/blog\/django-i18n\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Django i18n: How to translate and localize your app - POEditor Blog\" \/>\n<meta property=\"og:description\" content=\"Learn Django i18n with POEditor: set up translations, manage locale files, streamline collaboration, and deploy a multilingual Django app.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/poeditor.com\/blog\/django-i18n\/\" \/>\n<meta property=\"og:site_name\" content=\"POEditor Blog\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/POEditor\" \/>\n<meta property=\"article:published_time\" content=\"2026-09-17T06:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1988\" \/>\n\t<meta property=\"og:image:height\" content=\"1500\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"POEditor\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:creator\" content=\"@poeditor\" \/>\n<meta name=\"twitter:site\" content=\"@poeditor\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"POEditor\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"23 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/\"},\"author\":{\"name\":\"POEditor\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\"},\"headline\":\"Django i18n: How to translate and localize your app\",\"datePublished\":\"2026-09-17T06:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/\"},\"wordCount\":4752,\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/django-i18n-1024x773.png\",\"articleSection\":[\"Tutorials\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/\",\"name\":\"Django i18n: How to translate and localize your app - POEditor Blog\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/django-i18n-1024x773.png\",\"datePublished\":\"2026-09-17T06:00:00+00:00\",\"description\":\"Learn Django i18n with POEditor: set up translations, manage locale files, streamline collaboration, and deploy a multilingual Django app.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/#primaryimage\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/django-i18n.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/django-i18n.png\",\"width\":1988,\"height\":1500,\"caption\":\"django i18n tutorial poeditor\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/django-i18n\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Django i18n: How to translate and localize your app\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"name\":\"POEditor Blog\",\"description\":\"All about translation and localization management\",\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\",\"name\":\"POEditor\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"width\":512,\"height\":512,\"caption\":\"POEditor\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/POEditor\",\"https:\\\/\\\/x.com\\\/poeditor\",\"https:\\\/\\\/www.linkedin.com\\\/company\\\/poeditor\\\/\",\"https:\\\/\\\/www.youtube.com\\\/channel\\\/UCXAk1u8N49VRMAqNneENCFA\"]},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\",\"name\":\"POEditor\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"caption\":\"POEditor\"},\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/author\\\/poeditor\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Django i18n: How to translate and localize your app - POEditor Blog","description":"Learn Django i18n with POEditor: set up translations, manage locale files, streamline collaboration, and deploy a multilingual Django app.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/poeditor.com\/blog\/django-i18n\/","og_locale":"en_US","og_type":"article","og_title":"Django i18n: How to translate and localize your app - POEditor Blog","og_description":"Learn Django i18n with POEditor: set up translations, manage locale files, streamline collaboration, and deploy a multilingual Django app.","og_url":"https:\/\/poeditor.com\/blog\/django-i18n\/","og_site_name":"POEditor Blog","article_publisher":"https:\/\/www.facebook.com\/POEditor","article_published_time":"2026-09-17T06:00:00+00:00","og_image":[{"width":1988,"height":1500,"url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n.png","type":"image\/png"}],"author":"POEditor","twitter_card":"summary_large_image","twitter_creator":"@poeditor","twitter_site":"@poeditor","twitter_misc":{"Written by":"POEditor","Est. reading time":"23 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/poeditor.com\/blog\/django-i18n\/#article","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/django-i18n\/"},"author":{"name":"POEditor","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17"},"headline":"Django i18n: How to translate and localize your app","datePublished":"2026-09-17T06:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/poeditor.com\/blog\/django-i18n\/"},"wordCount":4752,"publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"image":{"@id":"https:\/\/poeditor.com\/blog\/django-i18n\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n-1024x773.png","articleSection":["Tutorials"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/poeditor.com\/blog\/django-i18n\/","url":"https:\/\/poeditor.com\/blog\/django-i18n\/","name":"Django i18n: How to translate and localize your app - POEditor Blog","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/poeditor.com\/blog\/django-i18n\/#primaryimage"},"image":{"@id":"https:\/\/poeditor.com\/blog\/django-i18n\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n-1024x773.png","datePublished":"2026-09-17T06:00:00+00:00","description":"Learn Django i18n with POEditor: set up translations, manage locale files, streamline collaboration, and deploy a multilingual Django app.","breadcrumb":{"@id":"https:\/\/poeditor.com\/blog\/django-i18n\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/poeditor.com\/blog\/django-i18n\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/django-i18n\/#primaryimage","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/django-i18n.png","width":1988,"height":1500,"caption":"django i18n tutorial poeditor"},{"@type":"BreadcrumbList","@id":"https:\/\/poeditor.com\/blog\/django-i18n\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/poeditor.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Django i18n: How to translate and localize your app"}]},{"@type":"WebSite","@id":"https:\/\/poeditor.com\/blog\/#website","url":"https:\/\/poeditor.com\/blog\/","name":"POEditor Blog","description":"All about translation and localization management","publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/poeditor.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/poeditor.com\/blog\/#organization","name":"POEditor","url":"https:\/\/poeditor.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","width":512,"height":512,"caption":"POEditor"},"image":{"@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/POEditor","https:\/\/x.com\/poeditor","https:\/\/www.linkedin.com\/company\/poeditor\/","https:\/\/www.youtube.com\/channel\/UCXAk1u8N49VRMAqNneENCFA"]},{"@type":"Person","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17","name":"POEditor","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","caption":"POEditor"},"url":"https:\/\/poeditor.com\/blog\/author\/poeditor\/"}]}},"_links":{"self":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8291","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/comments?post=8291"}],"version-history":[{"count":4,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8291\/revisions"}],"predecessor-version":[{"id":8316,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8291\/revisions\/8316"}],"wp:attachment":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/media?parent=8291"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/categories?post=8291"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/tags?post=8291"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}