Meta description: You ran makemessages, got a giant .po file, and stalled. Here's what i18n means in Django and how to make the workflow sane.
You ran python manage.py makemessages -l fr, opened locale/fr/LC_MESSAGES/django.po, and got a wall of empty msgstr entries. Then release pressure kicked in. Someone translated a few strings by hand, someone else edited templates, and now half the app is in English again.
That's the answer to what is i18n in Django. It's not vocabulary. It's the engineering work that stops translation from turning into a recurring mess every time your UI changes.
You Ran makemessages So What Is i18n For
The pain usually shows up after the extractor runs. Your code was moving fast. You added labels, validation errors, button text, maybe a new onboarding flow. makemessages did its job and found everything you forgot was user-facing. Now the burden shifts to humans editing .po files under deadline.
That frustration is common. A 2025 Stack Overflow survey summarized by Phrase says 42% of Django users cited managing .po file translations during frequent updates as their top i18n blocker, compared with 28% for React, with 30-50% wasted dev time on localization sync.
i18n is the work you do before translation so your app can survive that cycle. In practice, that means your codebase is prepared for multiple languages, multiple text lengths, different plural rules, and locale-specific formatting without rewriting views and templates every release.
Practical rule: If adding a language feels like a content task, your i18n is probably healthy. If it feels like a refactor, it isn't.
For Django teams, good i18n usually comes down to a few habits:
- Mark strings early with
gettext_lazy,gettext,ngettext, andpgettext - Keep text out of business logic so extraction works cleanly
- Avoid string concatenation that breaks grammar in other languages
- Treat
.pofiles like code with diffs, review, and CI checks
If you want a refresher on how Django's gettext files are structured, this guide to .po files in gettext workflows is worth keeping open while you work.
Internationalization (i18n) vs Localization (l10n)
The distinction matters because teams mix these up and then fix the wrong problem.
Internationalization is the engineering setup. Localization is the language-specific adaptation that happens on top of it. If your templates can't handle long strings, if your date formatting is hard-coded, or if your labels live inside Python string concatenations, that's an i18n problem. If the French translation is awkward, that's l10n.

The shorthand itself came from engineering culture. The term i18n originated at DEC, where the 18 stands for the number of letters between the first i and the final n in “internationalization.” The same pattern gave us l10n for localization, as noted in Wikipedia's background on internationalization and localization.
What Django code falls under i18n
In a Django app, i18n usually includes:
- Marking Python strings with
gettext_lazyfor models, forms, and class attributes - Marking template strings with
{% translate %}or{% blocktranslate %} - Adding
LocaleMiddlewarein the right middleware order - Using locale-aware formatting for dates, numbers, and language selection
- Designing layouts that won't break when text expands or changes direction
What counts as l10n
Localization is the recurring work:
- Translating
msgidvalues into each locale - Reviewing wording for product voice
- Fixing ambiguous strings with context
- Adapting content for a specific market
If your team also handles docs, marketing pages, or video captions, a broader guide to scalable content translation can help map those workflows without mixing them into app-level i18n.
For a Django-specific breakdown of where the line sits, see this localization vs internationalization explainer.
Core i18n Concepts Inside a Django Project
Django's translation stack is boring in the best way. It has existed long enough that the sharp edges are known. Most failures come from how teams use it, not from the framework itself.

Marking strings in Python
Use lazy translation for class attributes that load at import time.
from django.db import models
from django.utils.translation import gettext_lazy as _
class Invoice(models.Model):
customer_name = models.CharField(_("Customer name"), max_length=255)
notes = models.TextField(_("Notes"), blank=True)
class Status(models.TextChoices):
DRAFT = "draft", _("Draft")
SENT = "sent", _("Sent")
Use non-lazy translation when the string should resolve immediately at runtime inside a function.
from django.contrib import messages
from django.utils.translation import gettext
def notify_saved(request):
messages.success(request, gettext("Profile saved"))
Marking strings in templates
Django templates should use translation tags, not hard-coded text.
{% load i18n %}
<h1>{% translate "Account settings" %}</h1>
{% blocktranslate with name=user.first_name %}
Welcome back, {{ name }}.
{% endblocktranslate %}
Externalize every user-facing string
This is the core rule. Keep text in locale files, not scattered through conditionals and concatenations. The Crowdin guide to complete string externalization notes that failing to do this leads to 70-80% of localization bugs.
A few common mistakes keep showing up:
- Hard-coded validation errors inside forms or serializers
- String concatenation like
"Hello " + name - Reusing ambiguous labels like
"Open"without context - HTML mixed into translatable strings without care for placeholders
Hard-coded strings don't just hurt translators. They create bugs your team has to debug in production.
What a real .po entry looks like
After extraction, Django writes entries like this:
#: billing/views.py:18
#, python-format
msgid "Invoice for %(name)s is ready"
msgstr ""
#: templates/account/settings.html:4
msgid "Account settings"
msgstr ""
#: notifications/service.py:22
msgid "Profile saved"
msgstr ""
A translated file might look like this:
#: billing/views.py:18
#, python-format
msgid "Invoice for %(name)s is ready"
msgstr "La facture pour %(name)s est prête"
#: templates/account/settings.html:4
msgid "Account settings"
msgstr "Paramètres du compte"
#: notifications/service.py:22
msgid "Profile saved"
msgstr "Profil enregistré"
Notice the placeholder stays intact. If the placeholder changes, you can break runtime formatting.
Why .mo files still matter
Django reads compiled message catalogs at runtime. That's why .po isn't the final step.
python manage.py compilemessages
compilemessages turns human-editable .po files into machine-friendly .mo files. Skip it and your changes often won't show up, even when the translations are correct.
The Standard Django Translation Workflow
A normal Django translation cycle has a predictable shape. The problem is that one step still tends to eat the schedule.

Step 1 Mark strings in code and templates
You wrap strings before extraction.
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext, pgettext
label = _("Email address")
verb = pgettext("button label", "Open")
message = ngettext(
"%(count)s file uploaded",
"%(count)s files uploaded",
count
) % {"count": count}
Context matters more than organizations often realize. pgettext helps when the same English source string means different things in different places.
Here's the workflow in video form if you want a quick visual pass before wiring it into your project:
Step 2 Extract messages
Run extraction for the target locale:
python manage.py makemessages -l fr
That creates or updates:
locale/fr/LC_MESSAGES/django.po
If you support multiple locales, repeat for each one.
Step 3 Translate the .po file
This is the bottleneck. You open the file and fill in msgstr values.
#: templates/dashboard.html:9
msgid "Welcome back"
msgstr "Bon retour"
#: invoices/views.py:41
#, python-format
msgid "%(count)s invoice overdue"
msgid_plural "%(count)s invoices overdue"
msgstr[0] "%(count)s facture en retard"
msgstr[1] "%(count)s factures en retard"
A few things usually go wrong here:
- Fuzzy entries show up after source changes and need review
- Placeholders get altered and formatting crashes later
- Plural forms get ignored in languages that don't map cleanly to English assumptions
- Context is missing, so short labels translate badly
One habit that saves time: review every
#, fuzzyentry before compiling. Fuzzy is a flag for human attention, not a completion state.
Step 4 Compile and test
After translation:
python manage.py compilemessages
Then run the app and test the screens that changed. Check forms, flash messages, empty states, and any email templates that were extracted.
The business case isn't abstract
A lot of teams treat translation like polish. That's usually a mistake. The Locize summary of the CSA Research 2024 Global Buyer Study says 76% of online shoppers prefer buying products with information in their native language, and it points to a $1.3 trillion non-English e-commerce market projection for 2026.
For engineering, the practical takeaway is smaller and more immediate. If your translation workflow is painful, shipping in another language becomes something the team delays. Then product says “we support French,” while support tickets still arrive in English.
Choosing Your Translation Strategy
By the time the third or fourth locale lands, teams often end up choosing between three patterns. None is perfect. The right choice depends on how often strings change, who reviews translations, and whether your team wants the workflow inside Git or inside a web portal.
The Lokalise article summarizing a March 2026 JetBrains Python developer report says 61% of Python developers are experimenting with LLM-based .po translation, but 25% hit breakage from poor placeholder or HTML handling. That tracks with what many Django teams have seen. Generic AI is fast. Format-safe translation is the essential requirement.
Translation Workflow Comparison
| Strategy | Cost | Workflow | Best For |
|---|---|---|---|
| Manual copy-paste | Labor-heavy and hard to predict | Editor, spreadsheets, ad hoc reviews | Very small projects with rare string changes |
| Traditional TMS | Ongoing subscription and portal overhead | Web UI, translators outside Git | Larger teams with dedicated localization roles |
| Developer-first CLI translation | Usage-based model, depends on provider | Terminal, Git diffs, CI-friendly | Django teams that already live in manage.py and pull requests |
The trade-offs are usually obvious after one release cycle:
- Manual works until it doesn't. It's fine for a few static pages. It falls apart when product copy changes every sprint.
- TMS platforms help with review workflows. They also pull you out of the repo and add another system to manage.
- CLI-first tools fit engineering teams better when the translation source of truth is the
.pofile already in Git.
If you also manage translated media outside the app itself, this YouTube subtitle translation guide is a useful contrast. Subtitle workflows care about timing and caption formatting. Django app translation cares about placeholders, plural rules, and reviewable diffs.
How to Wire Translation into CI/CD
If i18n only happens before launch, it decays. New strings slip in, old translations go fuzzy, and someone notices after deploy. Put it in CI and it becomes maintenance instead of chaos.
A good starting point is one pull request check and one deploy step. If your team needs a refresher on the mechanics, this guide to building CI/CD pipelines with GitHub Actions gives a solid foundation.
What to automate
- PR check for extraction drift by running
makemessagesand failing if.pofiles changed unexpectedly - Translation review step so new
msgidentries are visible in diffs - Compile on build or deploy with
python manage.py compilemessages - Locale smoke tests for key pages, forms, and message formatting
You should also test placeholder safety. A translation that changes %(name)s to % (name)s can pass review and still break at runtime. These translation test examples for Django projects are a good pattern to copy into CI.
Keep translation checks close to the code that introduced the string. Waiting until release week guarantees cleanup work.
If your team wants a terminal-first way to translate Django .po files without pushing strings into a portal, TranslateBot is worth a look. It plugs into the makemessages and compilemessages workflow you already use, writes back to locale files in Git, and is built for teams that want reviewable diffs instead of another localization dashboard.