Meta description: Fix un-translated DRF API errors with a production-ready workflow for Django REST Framework translation, testing, and CI automation.
Your API is live. A user in Berlin submits the wrong password and gets back:
{"detail": "Unable to log in with provided credentials."}
That one response is enough to make a multilingual product feel half-finished. It's worse with mobile clients, where API text is the product. If your frontend, onboarding emails, and marketing site are localized but your DRF errors stay in English, users notice.
A lot of teams treat django rest framework translation as a side task. Then release week lands, someone edits a .po file, forgets to compile messages, and now you're debugging language issues next to actual incidents. If you're already juggling API failures, a good primer on troubleshooting server error 500 helps with the production side of that mess. Translation bugs are usually less dramatic, but they waste just as much attention because they slip through normal happy-path testing.
The fix isn't one setting. It's a loop. You need DRF defaults translated, your own serializer and view strings marked correctly, tests that hit Accept-Language, and CI that stops stale locale files from drifting. If your team keeps getting bitten by stale catalogs, this write-up on why Django translations break is worth bookmarking before you touch another locale commit.
That Un-translated API Error Message
The common failure mode is boring. That's why it ships.
You've enabled multiple languages in the product roadmap, maybe even translated templates and model content, but the API still returns English validation text. Login errors, permission failures, serializer field messages, all of it. DRF already ships with translatable default error messages, but it won't activate them unless Django's language machinery is wired correctly.
Practical rule: If your API returns user-facing text, treat it like UI. JSON doesn't get a pass.
The bigger issue is inconsistency. A 400 response might have one translated field error and one untranslated DRF default. A custom ValidationError might be localized, while a permission denial stays in English. Clients can't paper over that cleanly, especially when mobile apps display server messages directly.
Here's the production-safe mindset:
- Translate defaults: DRF's built-in messages need locale setup, not custom hacks.
- Mark your own strings: Serializer labels, help text, and explicit errors need
gettext_lazy. - Test the header path: If you don't send
Accept-Language, you're not testing the actual behavior. - Compile before asserting: Edited
.pofiles are dead weight until Django reads compiled catalogs.
That loop is what keeps translated APIs from decaying after the first release.
Laying the Groundwork for DRF Translation
If the base config is wrong, nothing above it matters. DRF's built-in translations only kick in when Django i18n is active and request language detection is working. The official DRF docs are explicit: to activate DRF's built-in translations, you must set the standard Django LANGUAGE_CODE and add django.middleware.locale.LocaleMiddleware to your middleware. DRF then translates its default messages into any locale defined in LOCALE_PATHS after you run manage.py compilemessages, as documented in the Django REST framework internationalization guide.

Settings that actually matter
In settings.py, get the basics in place first:
from pathlib import Path
from django.utils.translation import gettext_lazy as _
BASE_DIR = Path(__file__).resolve().parent.parent
USE_I18N = True
LANGUAGE_CODE = "en"
LANGUAGES = (
("en", _("English")),
("de", _("German")),
("es", _("Spanish")),
)
LOCALE_PATHS = [
BASE_DIR / "locale",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
If you want only approved locales served, keep LANGUAGES tight. Don't leave random locale directories lying around and assume that's enough.
The project layout should look like this:
locale/
└── de/
└── LC_MESSAGES/
└── django.po
Regional variants follow the same pattern:
locale/
└── pt_BR/
└── LC_MESSAGES/
└── django.po
If you need a refresher on catalog structure, this guide to the Django .po file format covers the parts teams usually break.
The manual loop you need to understand first
Before you automate anything, run the native flow a few times by hand:
python manage.py makemessages -l de
python manage.py compilemessages -l de
Django's translation system uses gettext under the hood and does not read .po files directly at runtime. It reads compiled .mo files after compilemessages, which the Django docs spell out in the translation framework documentation.
If someone edits
locale/de/LC_MESSAGES/django.poand skipscompilemessages, your app keeps serving stale translations.
One more thing. If your deployment runs collectstatic, keep that separate in your head from message compilation. Both are release-time chores, but they solve different problems.
Translating Serializers, Views, and Core DRF Messages
Once the groundwork is in, most of the work moves into your own code. DRF can translate its defaults, but your app-specific strings won't be discovered unless you mark them.

Mark serializer text the same way you mark template text
Use gettext_lazy in serializers for labels, help text, and custom validation errors:
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
class LoginSerializer(serializers.Serializer):
email = serializers.EmailField(
label=_("Email address"),
help_text=_("Use the email associated with your account."),
)
password = serializers.CharField(
label=_("Password"),
write_only=True,
error_messages={
"blank": _("Please enter your password."),
"required": _("Password is required."),
},
)
def validate_email(self, value):
if value.endswith("@example.invalid"):
raise serializers.ValidationError(
_("This email domain is not allowed.")
)
return value
That gets your strings into makemessages. It also keeps them close to the code that owns them, which matters once serializers start growing custom rules.
Raise translated errors in views
For view-level checks, don't hardcode English strings:
from django.utils.translation import gettext_lazy as _
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
class InviteView(APIView):
def post(self, request, *args, **kwargs):
if "email" not in request.data:
raise ValidationError({"email": [_("Invitation email is required.")]})
return Response(
{"detail": _("Invitation sent.")},
status=status.HTTP_200_OK,
)
That pattern works well for small APIs. On larger codebases, teams often drift back to raw strings in service classes or helper functions. When that happens, makemessages misses strings tucked inside odd code paths or constants. Keep user-facing text near Django-aware modules when you can.
Override core DRF messages in your locale files
The piece people miss is built-in DRF authentication and permission errors. Those strings already exist as translatable message IDs. If you want your own locale output, add the matching msgid to your project catalog and provide msgstr.
A realistic django.po entry looks like this:
msgid "Unable to log in with provided credentials."
msgstr "Anmeldung mit den angegebenen Zugangsdaten nicht möglich."
You can do the same for your own strings with placeholders:
msgid "Invitation sent to %(name)s."
msgstr "Einladung an %(name)s gesendet."
msgid "You selected %s items."
msgstr "Sie haben %s Elemente ausgewählt."
msgid "Processed {0} records."
msgstr "{0} Datensätze verarbeitet."
Preserve placeholders exactly. If you change or drop them, you'll get broken output or formatting errors.
Keep
%s,%(name)s, and{0}untouched inmsgstr. Translate around them, not through them.
Use context when English is ambiguous
Short UI and API strings are where translators lose context fastest. Django gives you pgettext() for that. It adds a msgctxt line to the .po file so translators can distinguish identical English source strings with different meanings, as described in Crowdin's write-up on Django i18n context with pgettext().
from django.utils.translation import pgettext
month_label = pgettext("month name", "May")
permission_label = pgettext("permission verb", "May")
The generated catalog will carry context:
msgctxt "month name"
msgid "May"
msgstr "Mai"
msgctxt "permission verb"
msgid "May"
msgstr "dürfen"
For APIs, this matters most in terse status text, button-equivalent labels returned to clients, and admin-backed content snippets reused across endpoints.
Automating PO File Translation
A familiar failure mode looks like this: a serializer change lands on Friday, makemessages adds fresh msgid entries, nobody fills them before deploy, and your German API starts returning English validation errors again on Monday. The extraction step worked. The translation loop did not.

makemessages only creates work. It does not close it. Without automation, .po updates turn into side tasks: copy strings into a vendor UI, paste results back, fix placeholder damage, compile, then hope nothing was missed. That process is tolerable for a marketing site. It breaks down fast for DRF projects where serializers, permissions, and error messages change every sprint.
Manual translation still belongs in the workflow. Legal copy, onboarding text, and anything tied to product voice should get a human pass. For the rest, especially default validation messages and routine API text, automation is the only way to keep catalogs current without slowing releases.
What usually fails in practice
The same problems show up over and over:
- Placeholders get corrupted.
%s,%(name)s, and{0}come back altered and crash formatting at runtime. - HTML and escaping drift. A translated string may return with broken tags or escaped content you did not expect.
- Locale coverage becomes uneven. English and one secondary language get updated. The rest lag behind.
- Git history gets messy. Engineers review giant
.podiffs with no clue which changes came from source updates and which came from translation tooling.
The fix is not “use machine translation everywhere.” The fix is a repeatable loop that handles extraction, fills only missing entries, preserves formatting, and writes changes back to the repository so review happens in normal pull requests.
Build the loop around your actual deployment path
For DRF, the production-safe loop is straightforward:
- Run
makemessagesafter source changes. - Translate only new or empty
msgstrentries. - Preserve placeholders, plural forms, and HTML.
- Commit updated
.pofiles. - Compile messages before tests or deploy.
That full loop matters more than the translation provider. Teams get into trouble when they automate only the middle step and ignore compilation, review, or drift between branches.
Here is the standard I recommend:
| Need | What holds up in production | What causes trouble |
|---|---|---|
| New strings | Target only untranslated entries | Reprocessing every string on every run |
| Formatting safety | Placeholder and HTML preservation | Raw text replacement |
| Review | .po diffs in Git |
Changes hidden in a separate portal |
| Provider choice | Tooling that can swap providers | Hardcoded dependence on one API |
If you want a Django-native path, TranslateBot works directly with locale files and adds a management command instead of another translation dashboard. That keeps the workflow close to the codebase. The practical upside is simple: engineers can run translation locally, reviewers can inspect diffs in Git, and CI can enforce the same process. The multilingual API testing examples are a good companion once you start automating catalog updates.
Keep automation narrow and predictable
Automation should fill gaps, not rewrite approved copy. In production, the safest rule is to translate only blank entries unless a developer explicitly asks for a refresh. That avoids churn in existing locales and keeps pull requests readable.
I also prefer provider output to remain visible in the .po file history. If a translation is awkward, the reviewer should fix the catalog directly and keep that correction under version control. For teams already using Postman collections to verify API behavior, Adamant Code's Postman guide fits well with this approach because it keeps translated responses testable alongside normal endpoint checks.
Human review still matters
Some strings need a second look every time:
- Short labels with weak context
- Pluralized messages
- Gendered languages
- Brand or legal copy
Everything else can be automated first and reviewed as part of normal code review. That is the balance that holds up in real DRF projects. Let automation clear the backlog. Let humans review the strings that can hurt the product if they are slightly wrong.
Writing Tests for Your Multilingual API
If you don't test language negotiation, you're relying on manual spot checks and luck. For APIs, the fastest regression test is usually a request with Accept-Language set and an assertion on the response payload.
Django reads compiled .mo files at runtime, not raw .po files. The Django docs also note a common testing failure: developers edit .po, forget compilemessages, and then tests hit stale translations in the translation docs for Django 5.0.
A copy-paste test pattern
from rest_framework import status
from rest_framework.test import APITestCase
class AuthTranslationTests(APITestCase):
def test_login_error_is_translated_to_german(self):
response = self.client.post(
"/api/login/",
{"email": "wrong@example.com", "password": "wrongpass"},
format="json",
HTTP_ACCEPT_LANGUAGE="de",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.json()["detail"],
"Anmeldung mit den angegebenen Zugangsdaten nicht möglich.",
)
That test is intentionally boring. Good translation tests should be boring. They should fail only when catalogs are stale, language detection is broken, or someone changed a message ID and forgot to update the locale files.
What to cover beyond one login failure
Use a small matrix, not a giant combinatorial suite:
- Default DRF errors: auth, permission, throttling, and serializer validation.
- Custom serializer errors: field-level and object-level validation.
- Success payloads: only if your API returns human-readable detail text.
- Fallback behavior: unsupported locales should fall back to your default language.
A translated API test that never sets
HTTP_ACCEPT_LANGUAGEisn't testing translation. It's testing English.
For manual verification before writing assertions, Adamant Code's Postman guide is a good reminder that API tooling still has value when you want to inspect headers and payloads quickly. Then codify the behavior in tests and stop checking it by hand. If you want more concrete patterns for locale assertions, these translation test examples for Django are useful references.
Integrating Translation into Your CI Pipeline
A release passes staging. Production traffic switches over. Then a German client hits a validation error and gets English. That usually means the translation loop broke somewhere between makemessages, catalog updates, compilation, and test execution.

CI should catch that before deploy. For DRF, I want the pipeline to do the same work the app depends on at runtime: extract message IDs, update catalogs, compile them, then run tests that request non-default locales. If any step is optional, it will eventually be skipped under release pressure.
For teams with more than one contributor, keep .po files in Git. Generated locale state that lives only on one laptop creates stale catalogs, noisy diffs, and hard-to-reproduce failures. The safer pattern is simple: every pull request runs the full translation loop, and any catalog changes show up as reviewable diffs.
A practical pipeline shape
name: i18n
on:
pull_request:
workflow_dispatch:
jobs:
translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Extract messages
run: |
python manage.py makemessages -l de -l es
- name: Translate catalogs
run: |
python manage.py translate
- name: Compile messages
run: |
python manage.py compilemessages
- name: Run tests
run: |
python manage.py test
That order matters. makemessages has to run before any automated translation step, or new message IDs never make it into the catalogs. compilemessages has to run before tests, or you are testing source files instead of the binaries Django loads. And if your tests never send Accept-Language, CI can go green while localized API responses are still broken.
A few production trade-offs are worth calling out. Auto-committing updated .po files from CI sounds convenient, but it can create noisy pull requests and awkward branch permissions. I usually prefer failing the build when extraction changes are detected, then making the catalog diff part of the PR review. For lower-risk projects, a scheduled job that refreshes translations can work well, but it still needs tests guarding the messages your API exposes to clients.
If your ops team already scripts recurring maintenance, these Python scripts for server upkeep follow the same logic. Repeated operational work belongs in code, including i18n checks.
What to run before your next deploy
Keep the checklist short and enforce it in automation:
- Extract strings: run
makemessagesafter any change to user-facing text in serializers, views, exceptions, or auth flows. - Update catalogs: use machine translation, human review, or both, based on how visible the strings are to customers.
- Compile translations: run
compilemessagesin CI so runtime catalogs are always validated before release. - Run locale-aware tests: hit API endpoints with
HTTP_ACCEPT_LANGUAGEset to supported and unsupported locales. - Review locale diffs in Git: treat translation updates like code changes, not generated debris.
If you want to stop hand-editing .po files and keep Django localization in your normal dev workflow, TranslateBot is built for that. It runs as a manage.py translate command, translates .po files and model fields, supports providers like GPT-4o-mini, Claude, Gemini, and DeepL, and writes reviewable diffs back to your repo while preserving placeholders and HTML.