If you're a Django developer shipping a multi-language app, you know the feeling. The moment you run makemessages and open a fresh .po file, your heart sinks a little. Web page localization isn't just swapping words; it’s adapting your app for a new culture. But the default workflow, manually copy-pasting strings into Google Translate, is a slow process that grinds development to a halt.
It's a huge bottleneck. And it's keeping you from a massive global audience.
Why Manual Web Page Localization Fails

The internet is global, but most websites feel like they were built for one city. As a solo developer or a small team, your time is everything. Wasting it on the grunt work of .po file management is a great way to fall behind.
The numbers are stark. Over 5 billion people are online, but less than 26% of them are native English speakers. Yet a staggering 64% of all websites are English-only. This creates a huge gap between the services we build and the people who want to use them. Research backs this up: one Harvard Business review report found that 90% of users will always choose a site in their native language if it's an option. You can see more surprising data on localization statistics and trends from POEditor.
The Scaling Problem with Manual Workflows
The manual approach to web page localization breaks down with modern, agile development. Every time you run makemessages, you trigger a painful, repetitive dance:
- Open the new
django.pofile. - Copy an untranslated
msgid. - Paste it into a tool like Google Translate or DeepL.
- Copy the translation back into the
msgstrfield. - Repeat this hundreds of times, for every new string, across every single language you support.
I’ve been there. You get into a rhythm, then you accidentally break a template variable like %(name)s or forget a closing </a> tag. You lose all context, which leads to awkward, nonsensical translations. Worst of all, it feels like a chore that lives completely outside your normal dev flow, slowing down every single release.
Here's a quick look at how the old way compares to an automated approach.
Manual Translation vs. Automated CLI Workflow
| Task | Manual Method (e.g., Google Translate) | Automated CLI (e.g., TranslateBot) |
|---|---|---|
| New Strings | Manually copy-paste each one | One command translates all new strings |
| Placeholders | Prone to breaking (%(name)s, HTML) |
Preserved automatically |
| Consistency | Relies on memory or a messy spreadsheet | Managed with a version-controlled TRANSLATING.md |
| Time | Hours per language, per sprint | Seconds or minutes |
| Integration | Outside the dev workflow | Integrated into the terminal and CI/CD |
The difference is clear. One is a bottleneck, the other is a build step.
For small teams, this manual process becomes a significant drag on productivity. It turns localization from a growth strategy into a technical debt that accumulates with every new feature.
A Developer-First Alternative
SaaS platforms like Crowdin or Transifex exist. They’re powerful, but they often feel like bringing a sledgehammer to crack a nut. They pull you out of your editor into a separate web portal, come with monthly subscription fees, and add complexity you might not need. Paying $150/month per seat for a tool you only touch once a sprint is a tough pill to swallow for most of us.
What we really need is a developer-first approach. Something that lives in the terminal, plays nicely with gettext, respects Git, and automates the pain away without sacrificing control.
This guide will show you exactly how to build that: an automated, AI-powered translation pipeline right inside your Django project.
Setting Up Your Django Project For Automation
Alright, let's get this set up. Integrating an automated translation tool should feel like adding any other dev dependency, not like onboarding a whole new enterprise service. This means a quick install and a simple configuration that lives right inside your project.
The package is called translatebot-django. For .po file translation it's only needed when you generate translations, not at runtime, so install it as a dev dependency:
- For uv users:
shell uv add --dev translatebot-django - For Poetry users:
shell poetry add --group dev translatebot-django - For pip users:
shell pip install translatebot-django
If you want to use DeepL instead of an LLM, install the extra: translatebot-django[deepl].
Then add it to INSTALLED_APPS so Django picks up its management commands. Because it's a dev dependency, only add it when it's installed. Otherwise a production install without dev dependencies fails at startup:
# settings.py
import importlib.util
if importlib.util.find_spec("translatebot_django"):
INSTALLED_APPS += ["translatebot_django"]
If you have separate dev settings, adding "translatebot_django" there works just as well.
This gives you two new commands: translate and check_translations. Now we just need to give it an API key.
Initial Configuration and API Keys
TranslateBot needs an API key to talk to your chosen provider. By default it uses LiteLLM, so any supported LLM works: OpenAI, Anthropic Claude, Google Gemini, Azure OpenAI, and more. DeepL is available as a separate provider. Don't hardcode keys in your settings.py; read them from an environment variable.
Keep the key in your environment or a .env file loaded by whatever you already use (django-environ, python-decouple, direnv). If you use a .env file, add it to your .gitignore. You don't want to accidentally commit your secrets.
# .env file
OPENAI_API_KEY="sk-..."
Now wire it up in settings.py. TranslateBot reads plain top-level settings:
# settings.py
import os
TRANSLATEBOT_API_KEY = os.getenv("OPENAI_API_KEY")
TRANSLATEBOT_MODEL = "gpt-4o-mini" # default; any LiteLLM model name works
LANGUAGE_CODE = "en"
LANGUAGES = [
("en", "English"),
("fr", "French"),
("es", "Spanish"),
]
LANGUAGES tells TranslateBot which languages to translate into. The source language (LANGUAGE_CODE) is excluded automatically. If TRANSLATEBOT_API_KEY isn't set in settings, TranslateBot falls back to a TRANSLATEBOT_API_KEY environment variable.
To use DeepL instead, switch the provider. There's no model to pick:
# settings.py
TRANSLATEBOT_PROVIDER = "deepl"
TRANSLATEBOT_API_KEY = os.getenv("DEEPL_API_KEY")
That's it. For every setting and supported model, see the configuration docs and the supported AI models.
Creating Your First Glossary
One of the most common ways machine translation fails is with brand names and technical jargon. How do you stop "TranslateBot" from becoming "Robot de traduction" in French? Or ensure a term like "pull request" is handled correctly across languages?
You solve this with a TRANSLATING.md file. It's a plain Markdown file that acts as a version-controlled set of instructions for the AI. You create it in your project root (next to manage.py), and it becomes the single source of truth for your app's specific vocabulary.
There's no special syntax. It's free-form Markdown that gets passed to the model as-is, so write it the way you'd brief a human translator.
Your
TRANSLATING.mdis more than a word list. It’s a set of instructions that gives the AI the context it needs to translate your app correctly, preventing common and embarrassing mistakes.
Here’s a basic example for this very project:
# Translation Context
## About This Project
TranslateBot is a developer tool that translates Django .po files.
## Do Not Translate
- "TranslateBot" is a brand name. Never translate it.
## Terminology
- "pull request": keep as "pull request". It's a technical term our users understand.
- ".po file": keep as ".po file".
- "user": translate as "utilisateur" in French and "usuario" in Spanish.
## Tone
- Use formal "vous" in French and "usted" in Spanish.
The contents of this file are added to the system prompt of every translation request. This means that even if you switch LLMs or upgrade to a newer model, the core terminology of your app stays consistent. Individual Django apps can also have their own TRANSLATING.md next to their models.py; it's combined with the project-level file for that app's strings.
One caveat: this only works with LLM providers. DeepL's API doesn't accept custom instructions, so with TRANSLATEBOT_PROVIDER = "deepl" TranslateBot prints a warning and ignores TRANSLATING.md. For terminology control with DeepL, use DeepL glossaries directly. The translation context docs have more examples.
With the package installed, keys configured, and a basic glossary in place, your project is ready for automation. You can run makemessages like you always have. Then you're just one command away from getting everything translated. We’ll get to that next.
Running Your First AI Translation
The setup is done. You’ve installed TranslateBot, plugged in your API keys, and sketched out a basic TRANSLATING.md glossary. You've run makemessages, and now your .po files are full of empty msgstr fields. This is usually the part where you'd sigh, grab a coffee, and settle in for hours of copy-pasting.
Not anymore.
With TranslateBot, you replace that entire tedious session with a single command. Want to see what it would do first? A dry run lists the strings that need translating without calling the API or touching any files:
python manage.py translate --dry-run
Then run it for real:
python manage.py translate
That’s it. With LANGUAGES set, this translates into every configured language. To do one language at a time, pass --target-lang fr. The tool scans your LOCALE_PATHS, your apps' locale/ directories, and the default locale/ directory for django.po and djangojs.po files, skips third-party packages and obsolete entries, and sends the strings that need translating to the provider you configured in settings.py. Strings are grouped into batches that fit the model's token limits, so large projects don't need one API call per string.
When it's done, compile as usual:
python manage.py compilemessages
Preserving Placeholders and HTML Without Breaking Your Templates
One of the biggest fears with any automated translation tool is that it will mangle your templates. We've all seen it happen: a tool turns Hello, %(name)s! into Bonjour, % (nom) s!, and suddenly your views are throwing ValueError exceptions in production. This is where TranslateBot is obsessive about getting it right.
It’s been built with specific safeguards to protect Django's template syntax. These are kept intact:
- Named placeholders like
%(name)sand%(count)d. - Positional format specifiers like
%sand%d. - Brace placeholders like
{0}and{name}. - HTML tags like
<strong>or<a href="...">, so your translated content keeps its styling and structure. - Line breaks (
\n).
How that works depends on the provider. With LLMs, these preservation rules are part of TranslateBot's built-in system prompt, and every response is checked to contain exactly one translated string per input string, in order. With DeepL, placeholders are swapped out for protected tokens before the request and restored afterward, so DeepL never sees them. As a final safety net, Django's compilemessages runs msgfmt --check-format, which rejects a translation whose python-format placeholders don't match the source.
Here's an entry in locale/fr/LC_MESSAGES/django.po before the command runs:
#: templates/base.html:12
#, python-format
msgid "Hello, %(name)s! You have <strong>%(count)d</strong> new messages."
msgstr ""
And after:
#: templates/base.html:12
#, python-format
msgid "Hello, %(name)s! You have <strong>%(count)d</strong> new messages."
msgstr "Bonjour %(name)s ! Vous avez <strong>%(count)d</strong> nouveaux messages."
Look familiar? The empty msgstr is now filled in with the placeholders and HTML untouched, turning an untranslated entry into a ready-to-use one. This happens for every string that needs translating, across all your language files, in one run. Plural entries (msgid_plural / msgstr[n]) are handled too. For more detail, see the PO file translation docs.
Only Translating What's New
Re-translating your entire project every time you run the command would be a colossal waste of API credits and time. TranslateBot is smarter than that. It intelligently finds only the strings that actually need attention.
It specifically targets:
- Empty
msgstrentries: These are the brand-new strings from your latestmakemessagesrun. - Fuzzy strings: Entries marked with
#, fuzzy, whichmakemessagesadds when amsgidchanged and it guessed a translation from a similar string. TranslateBot re-translates these and clears the fuzzy flag.
Existing, non-fuzzy translations are never touched, including ones you've fixed by hand. If you really do want to re-translate everything (say, after a big TRANSLATING.md change), use --overwrite.
By focusing only on this delta, the whole process is fast and cheap. If you add five new strings to your templates, only those five strings are sent to the API. This is what makes this kind of web page localization so effective for small, fast-moving projects.
You aren't paying a monthly subscription for some big platform. You're paying pennies for the exact number of new strings you've added since your last deployment. This completely changes the economics of localization for indie developers and small teams.
This one-command translation is the core of the whole workflow. It bridges the gap between generating strings with makemessages and shipping them with compilemessages. It automates the tedious, error-prone part in the middle, letting you get back to writing code instead of managing translations.
Next, we'll look at how to review these AI-generated translations just like you would any other code change.
Reviewing Translations and Managing Your Glossary
Just because a bot is doing the translating doesn’t mean you’re flying blind. This developer-centric workflow gives you more transparent control than any external SaaS platform. When TranslateBot writes to your .po files, it’s just modifying code files in your repository. Nothing more.
This means you review AI-generated translations the same way you review any other code change: in a pull request. This is a huge win over SaaS platforms that hide your translation history behind a web portal. With TranslateBot, every single change is a Git commit.
Developer-Centric Review in a Pull Request
The review process is simple because it’s already your daily routine. You run python manage.py translate, it updates the files, and you open a PR.
The diff view in GitHub, GitLab, or your editor tells the whole story.
# locale/fr/LC_MESSAGES/django.po
#: templates/base.html:15
msgid "Dashboard"
- msgstr ""
+ msgstr "Tableau de bord"
#: templates/base.html:20
msgid "Settings"
- msgstr ""
+ msgstr "Paramètres"
#: templates/profile.html:5
msgid "Update Profile"
- msgstr ""
+ msgstr "Mettre à jour le profil"
Any developer on your team can understand this diff instantly. You can see the new msgstr values and approve them. If a teammate speaks the language, they can comment on a specific line just like any other code review. The entire history of your web page localization lives right inside your version control system.
When your translations live in Git, they get all the benefits of code. You get history, blame, and a transparent review process. You can even revert a bad batch of translations with a single
git revertcommand.
This is a fundamental shift away from black-box SaaS tools. You own your data. You control the workflow.
Evolving Your Glossary for Better Translations
Your TRANSLATING.md file isn't a "set it and forget it" document. It’s a living part of your codebase that should evolve with your application. As you add features or refine your product's voice, you'll update the glossary to keep the AI on track. It becomes the single source of truth for your project's terminology.
Let's say the first translation for "Submit" came back as "Soumettre" in French. But after seeing it in context, your team decides "Envoyer" (to send) is a much better fit for sending a message.
You have two jobs:
- Fix the current translation: Manually edit the
django.pofile and change "Soumettre" to "Envoyer". - Prevent future mistakes: Update your
TRANSLATING.mdfile.
You’d add a new instruction to your glossary to guide all future runs.
# TRANSLATING.md
## Terminology
- "Submit": on buttons that send a message, translate as "Envoyer" in French.
Done. The next time you run python manage.py translate, every request includes this context, so new strings follow your preference for "Envoyer". Your hand-fixed entry stays as it is, because TranslateBot never overwrites existing translations unless you pass --overwrite.
TRANSLATING.md applies to every string. When only one string is ambiguous, put the hint right next to it in your code with a Translators: comment instead:
# Translators: Button that sends a chat message
_("Submit")
makemessages copies this into the .po file as a #. comment, and TranslateBot sends it to the LLM alongside that one string. This simple feedback loop is what makes AI-powered localization so practical.
This version-controlled glossary helps maintain consistency over years of development. For more examples, see the translation context docs or our guide on glossary terms. Maintaining a good glossary is the most effective way to improve the quality of your automated web page localization efforts over time.
Integrating Localization Into Your CI/CD Pipeline
Reviewing translations in a pull request is a good first step, but it’s still a manual process someone has to remember to kick off. The real goal is to make localization a completely hands-off part of your deployment pipeline.
This is how you turn web page localization from a recurring chore into a predictable, automated background process.
The endgame is simple: a developer merges a feature, and the translations just happen. No one needs to run a command or remember a script. You just code, commit, and merge. Your CI/CD service handles the rest.
This is the secret to how a small team can manage a multilingual app without hiring a dedicated localization manager. It’s not about adding more work; it’s about building a system that eliminates it.
Automating Translations with GitHub Actions
Let's walk through a practical workflow using GitHub Actions. The core logic is the same whether you use GitLab CI, CircleCI, or another provider. This workflow will trigger on every push to your main branch, run makemessages, run TranslateBot, and then automatically open a pull request with the updated .po files.
You can save the following YAML as .github/workflows/translate.yml in your project. It assumes a uv-managed project with translatebot-django in the dev dependencies and your settings reading OPENAI_API_KEY as shown earlier.
# .github/workflows/translate.yml
name: Translate PO Files
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install dependencies
run: |
uv python install
uv sync --frozen
- name: Install gettext
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gettext
- name: Find new strings
run: uv run python manage.py makemessages --all --no-obsolete
- name: Translate new strings
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: uv run python manage.py translate
# makemessages always bumps the POT-Creation-Date header, even when
# no strings changed. Revert files where that's the only change.
- name: Drop header-only changes
run: |
for f in $(git diff --name-only -- '*.po'); do
if ! git diff -U0 -- "$f" | grep '^[+-][^+-]' | grep -v 'POT-Creation-Date' > /dev/null; then
git checkout -- "$f"
fi
done
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
commit-message: 'i18n: update translations'
title: 'i18n: automated translation updates'
body: |
New and fuzzy strings translated by `python manage.py translate`.
Please review before merging.
branch: 'i18n/auto-translations'
labels: 'i18n'
This workflow sets up the environment, finds any new strings that need translating, runs TranslateBot, and then uses a common community action to package the changes into a new PR.
| Step Name | Command / Action | Purpose |
|---|---|---|
| Checkout code | actions/checkout@v4 |
Checks out your repository's code into the runner. |
| Set up uv | astral-sh/setup-uv@v4 |
Installs uv (with caching). |
| Install dependencies | uv sync --frozen |
Installs Django, TranslateBot, and your other dependencies from the lockfile. |
| Install gettext | apt-get install gettext |
makemessages shells out to GNU gettext, which isn't a Python package. |
| Find new strings | makemessages --all --no-obsolete |
Scans code for new translatable strings and updates .po files. |
| Translate new strings | python manage.py translate |
Sends only empty and fuzzy entries to your provider for translation. |
| Drop header-only changes | git checkout |
Discards .po files where makemessages only bumped the POT-Creation-Date timestamp, so you don't get a PR on every push. |
| Create Pull Request | peter-evans/create-pull-request@v6 |
Bundles the modified .po files into a PR for review. Needs the contents and pull-requests write permissions above. |
This simple file automates the entire translation review process. The moment code with new strings hits main, a PR with the translations appears, ready for a quick sanity check before you merge. If nothing changed, no PR is opened.
Catching Missing Translations with check_translations
If you'd rather translate locally and have CI just enforce that nobody forgot, TranslateBot ships a second command built for that. check_translations scans every .po file and exits with code 1 if it finds any untranslated or fuzzy entries. With --makemessages, it runs makemessages -a --no-obsolete first, so it also catches strings that were never extracted:
- name: Install gettext
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gettext
- name: Check translations
run: uv run python manage.py check_translations --makemessages
This check makes no API calls, so it doesn't need an API key. The CI integration docs have complete GitHub Actions and GitLab CI examples.

The real beauty of this setup is that every step happens inside your existing Git workflow. Translations become transparent, version-controlled artifacts, just like your code.
Why This Automation Matters for Growth
Automating localization is far more than a developer convenience, it’s a direct lever for growth. Consider that 76% of customers prefer buying products with information in their native language.
Studies consistently show that 60% of shoppers rarely or never buy from English-only websites. This automation is how you bridge that gap without slowing down your development cycle. The impact on marketing is just as significant, with localized ad campaigns outperforming English-only versions by 86%. You can dig into more data on how this drives sales in this detailed website localization guide.
By making web page localization a zero-effort, automated step in your CI pipeline, you ensure your app is always ready for a global audience. You ship features, and the translations follow.
This hands-off approach closes the loop. You’ve gone from the manual misery of copy-pasting strings into a spreadsheet to a fully automated system that detects new content, translates it with context, and presents it for a standard code review.
It's the most practical and cost-effective way for a Django developer to build and maintain a truly multilingual application. Now you can get back to focusing on features, confident that your localization work will always keep pace.
Frequently Asked Questions
You've seen the setup and the automated workflow. But I know what you're thinking, because I've been there myself. There are always those lingering "but what about..." questions that pop up when you're considering a new tool. Let's tackle some of the most common ones Django developers ask about this kind of automated localization.
How does the AI handle context for ambiguous strings?
This is the big one, right? An AI's biggest weakness is a lack of real-world context. With an LLM provider, you can give it context at two levels.
For project-wide rules, use your TRANSLATING.md file. Think about a word like 'Check'. Is it a verb? A noun? A checkbox label? If your app is a banking product, you can tell the AI exactly what you mean: 'Translate "Check" as a noun meaning a financial instrument'. That instruction goes along with every translation request.
For a single ambiguous string, add a # Translators: comment right above it in your code. makemessages copies it into the .po file as a #. comment, and TranslateBot automatically sends it to the model together with that string:
# Translators: Verb. Button that verifies the form, not a bank check.
_("Check")
Together, these are the most powerful tools you have for turning a generic AI into a specialist that speaks your product's language. (DeepL supports neither; it translates each string without extra context.)
Your glossary is what delivers accurate, consistent results. It transforms a general-purpose AI into an expert on your app's specific vocabulary.
What are the real costs compared to a SaaS like Lokalise?
Let's talk money, because it matters. A SaaS platform like Lokalise or Transifex usually involves a recurring subscription fee, often priced per user. You might be looking at $50-$150 per month for a small team, and you pay that whether you translate five new strings or five thousand.
TranslateBot is open source (MPL-2.0), so there's no subscription. Your only direct cost is API usage with the provider you pick. With the default gpt-4o-mini (around $0.15 per million input tokens), a small-to-medium app with about 500 translatable strings costs under $0.01 per language to translate from scratch. DeepL's free API tier covers 500,000 characters per month.
Since only new and fuzzy strings are sent after that first run, adding 50 new strings this month costs a fraction of a cent. It’s a pay-as-you-go model that’s fundamentally cheaper and more transparent, which is exactly what you want on a lean team.
Can I use a source language other than English?
Yes. While most Django projects use English as the source, gettext and TranslateBot don't care. Your source strings are whatever you wrapped in gettext() or {% translate %} in your code; makemessages copies them into each target language's .po file as msgid entries.
If your project's primary language is German, set LANGUAGE_CODE = "de" and run makemessages for your target languages, say French (fr) and Spanish (es). TranslateBot reads the German msgid strings and fills in the French and Spanish msgstr fields. Because LANGUAGE_CODE is excluded from the target list automatically, it won't try to "translate" German into German. The workflow doesn't change at all.
What if the AI makes a mistake?
It will. No AI is perfect. The key is that fixing mistakes is not only easy but also directly improves all future translations.
Since the output is just a standard .po file in your repository, you fix errors right in your code editor. Just find the faulty msgstr entry and correct it. This is a huge win compared to hunting for a string in some disconnected web UI.
Your fix sticks: TranslateBot only fills empty or fuzzy entries, so it won't overwrite a translation you corrected by hand on the next run.
To prevent the same mistake in new strings, update your TRANSLATING.md glossary (or add a # Translators: comment in the code for a one-off). Then commit both the .po file correction and the context update. Unlike a fix buried in a proprietary database, your correction is now version-controlled, transparent, and guides the AI on every future run.
TranslateBot makes powerful, automated web page localization practical for every Django developer. Stop wasting time on manual translation and start shipping faster. Get started at https://translatebot.dev.