Back to blog

Writing for Translation: A Django Dev's Guide

2026-04-09 17 min read
Writing for Translation: A Django Dev's Guide

You did everything “right.” You wrapped strings in gettext, ran makemessages, sent the .po files off for translation, then merged them back.

Then the bugs started.

A placeholder disappeared. A button label became weirdly vague. Two parts of the UI translated the same word differently. One language broke because your English string baked in word order that only makes sense in English. None of that feels like a Django problem, but it is. The translator only sees the source string you wrote.

That’s the part many teams miss. Writing for translation is an engineering task. If the English source is messy, the output will be messy too, whether you use a human translator, Google Translate, or an LLM.

Why Your Translations Keep Breaking

Most translation bugs start before translation.

They start in Python files, templates, model labels, admin actions, form errors, and toast messages. The problem is usually not that the translator is careless. The problem is that developers often write source strings for themselves, not for a translation system.

If your string depends on English grammar tricks, hidden context, or fragile formatting, the translated result will inherit that mess.

Bad source strings create technical bugs

These are the patterns that break first:

A common example:

message = _("Your invite went through.")

That sounds natural in English. It’s also vague. Did the user send an invite? Was it accepted? Was it delivered?

Now compare it to this:

message = _("Invitation sent.")

Short. Direct. Hard to misread.

Translation quality starts with source quality

The translation management software market is projected to grow by USD 1.58 billion between 2020 and 2025, at a 14.81% CAGR, which tells you how much money teams spend trying to make localization less painful. The number comes from Redokun’s translation industry statistics roundup. The practical lesson is simple. Teams pay for tooling because fixing translation problems late is expensive.

You can avoid a lot of that by treating source strings like API design. Clear inputs produce predictable outputs.

Tip: If a string would confuse a new teammate reading it in a code review, it will also confuse a translator.

Your .po file is not the problem

Developers often blame the .po file because that’s where the bad output becomes visible. But .po files are just containers. They preserve whatever decisions you already made upstream.

If your workflow still feels brittle, start with the source. Then review your extraction flow. If you need a quick refresher on how Django stores and compiles translations, this gettext and PO file guide is a good baseline.

Treat translatable copy like production code

Good teams already lint Python, format templates, and review migrations. Translatable strings deserve the same discipline.

A useful rule is this:

Source string trait Likely translation result
Ambiguous Inconsistent
Long and nested Hard to review
Idiomatic Awkward or wrong
Structured and literal Stable

That shift matters. Once you stop seeing localization as “somebody else’s job,” writing for translation gets easier. It stops being fuzzy language advice and becomes straightforward engineering hygiene.

Write Simple Strings Not Clever Code

Clever English does not survive translation.

Short, plain, boring strings do. That’s what you want in a UI. A translated interface is not the place to show off your copywriting instincts or squeeze three meanings into one sentence.

A conceptual illustration contrasting tangled, complex code with a straight, clear line representing simple code.

Prefer literal wording

Write the thing you mean.

Bad:

_("You’re all set")
_("That went sideways")
_("We couldn’t hook that up")

Better:

_("Setup complete")
_("Request failed")
_("Connection failed")

The better versions are less fun. They’re also easier to translate correctly.

Research on translation difficulty found that non-standard word order and syntactic ambiguity are strong predictors of translation difficulty, and simplifying source strings can decrease error rates in the final translation by 35 to 45% according to this study on predictive difficulty metrics in translation. You don’t need a linguistics background to use that result. Just stop writing strings that require interpretation.

Keep one sentence equal to one idea

Developers often pack logic into a single string because it feels tidy in code.

Bad:

_("If your payment fails, update your billing details and try again before your account is restricted.")

That’s too much for one message. Split it.

Better:

_("Payment failed.")
_("Update your billing details and try again.")
_("Your account will be restricted if payment is not received.")

Short strings are easier to translate, easier to review, and easier to reuse.

Use normal English word order

Translation systems handle predictable grammar better than stylized phrasing.

Bad:

_("Only after verification can this action be completed.")

Better:

_("You must verify your account before completing this action.")

The second version says who does what. That matters.

Avoid idioms and phrasal verbs

Idioms are bad source material. Phrasal verbs are often bad too.

Bad Better
_("Log in to kick things off") _("Log in to start")
_("Your session timed out, please jump back in") _("Your session expired. Log in again.")
_("We ran into a problem") _("An error occurred")

A translator can work with literal language. “Kick things off” just creates extra work.

Don’t compress meaning to save lines of code

This is a common anti-pattern:

label = _("Save and continue editing")

That might be fine if the action always does exactly that. But many apps use the same English text in slightly different places, where the behavior changes. Then the translation becomes misleading.

Prefer explicit strings tied to specific actions:

_("Save")
_("Save and continue")
_("Save and add another")

Django code gets a little longer. The product gets clearer.

Rule of thumb: If a string contains “just,” “even,” “kind of,” or some playful phrase you’d never put in an API error, rewrite it.

Don’t build Franken-strings

String concatenation is still one of the worst habits in i18n code.

Bad:

_("Welcome back, ") + user.first_name

Also bad:

_("Deleted ") + str(count) + _(" files")

These patterns assume English order. Many languages won’t use that order.

Use complete translatable units instead. Named placeholders help, which matters even more in the next section, but the first fix is conceptual. Write full strings, not fragments.

Simple beats elegant

A lot of writing for translation advice says “use plain language.” That’s correct, but too vague for developers. A stronger rule is this: write strings the same way you write good variable names.

Clear. Specific. No jokes. No hidden context. No grammar tricks.

That style feels plain in English. It performs better in every target language.

Mastering Placeholders HTML and Plurals

The language itself is only half the problem. The other half is syntax.

A lot of Django translation bugs happen because the string contains something fragile: a placeholder, HTML, or plural logic. If you write those carelessly, translators can produce correct words and still break your app.

A hand-drawn illustration depicting localization techniques including placeholder syntax, HTML formatting, and pluralization logic for software translation.

Named placeholders beat positional ones

Use placeholders that carry meaning.

Bad:

_("Hello %s") % name
_("User %s invited %s") % (inviter, invitee)

Better:

_("Hello %(name)s") % {"name": name}
_("User %(inviter)s invited %(invitee)s") % {
    "inviter": inviter,
    "invitee": invitee,
}

Named placeholders survive word reordering. That matters because many languages won’t keep the English sentence structure.

A translator can move %(invitee)s earlier or later in the sentence without guessing what %s means.

Don’t mix formatting styles

Pick one style in a project and stick with it. In Django gettext strings, %() placeholders are still the safest choice because they work cleanly with gettext extraction and old-style interpolation.

Bad:

_("Hello %(name)s, you have {} messages")

Good:

_("Hello %(name)s, you have %(count)s messages") % {
    "name": name,
    "count": count,
}

Consistency matters as much as correctness here.

Keep HTML outside the translated text when you can

If the markup is decorative, don’t put it inside the string.

Bad:

_("Click <strong>Save</strong> to continue.")

Now your translator has to preserve the tag correctly.

Better:

{% blocktrans %}Click <strong>Save</strong> to continue.{% endblocktrans %}

That still includes HTML, but blocktrans is at least designed for this pattern.

Even better, where possible, separate the markup from the sentence structure:

<p>{% trans "Click" %} <strong>{% trans "Save" %}</strong> {% trans "to continue." %}</p>

That said, don’t split strings so aggressively that you destroy grammar. Translators need whole thoughts, not isolated word tokens. Use judgment.

Use blocktrans for variables and markup together

Template strings often need both text and dynamic values.

{% blocktrans with username=user.username %}
Welcome back, {{ username }}.
{% endblocktrans %}

That’s fine.

What isn’t fine is doing this:

{% trans "Welcome back," %} {{ user.username }}

Some languages need punctuation or word order to change around the username. A whole sentence gives translators room to do that.

Plurals are not optional logic

English plural rules feel simple, so developers often fake it.

Bad:

_("1 file deleted" if count == 1 else f"{count} files deleted")

That bypasses Django’s plural system and assumes every language has the same singular/plural pattern as English. They don’t.

Use ngettext:

from django.utils.translation import ngettext

message = ngettext(
    "%(count)s file deleted",
    "%(count)s files deleted",
    count,
) % {"count": count}

That gives translators the plural forms their language needs.

Tip: If a string changes when count changes, use plural APIs first. Don’t hand-roll it with if count == 1.

Don’t hide placeholders in ambiguous labels

Another common mistake:

_("Order: %(id)s")

What kind of order? A purchase order? Sort order? Command order?

Better:

_("Order number %(id)s")

The placeholder is fine. The label around it needs context.

A quick review checklist

Before you commit a translatable string, check these:

Those habits prevent a surprising amount of pain. Most translation breakage around syntax comes from trying to be clever, concise, or “close enough.” Close enough is bad in i18n.

Provide Context to Eliminate Guesswork

English is full of words that look simple and translate badly without context.

“Open.” “Charge.” “Post.” “Book.” “Order.” “Clear.”

A developer sees the screen and understands the meaning. A translator often sees a bare string in a .po file with no UI around it. If you don’t provide context, they guess.

Use pgettext when a word has multiple meanings

Django gives you a direct tool for this.

from django.utils.translation import pgettext

status = pgettext("subscription status", "Active")
button = pgettext("verb for opening a file", "Open")

Now the translator doesn’t have to guess whether “Open” means “not closed” or “open this thing.”

That’s a much better pattern than hoping repeated usage makes the meaning obvious later.

Add translator comments in code and templates

Comments are still useful, especially for short labels and strings with business meaning.

Python:

# Translators: "Charge" here means billing a customer card.
label = _("Charge")

Template:

{# Translators: "Post" is a blog post noun, not the verb. #}
{% trans "Post" %}

These comments won’t solve every problem, but they remove avoidable ambiguity.

Context reduces review effort

Research using eye-tracking on translators found a big difference in error detection when translators had better tools and context. In the study, a control group identified around 40 to 60% of drafting errors, while a group with better context caught 80 to 90%, according to this eye-tracking study on translator revision. The practical lesson is not academic. Clear source text lowers cognitive load.

If the translator has to stop and infer meaning, quality drops.

Key takeaway: Every ambiguous string creates a tiny debugging session for the translator.

Comments help, but they don’t scale well

The problem with comments is not that they’re bad. The problem is that they’re easy to forget.

They also don’t enforce consistency. You can write:

_("Repository")

in one file and:

_("Repo")

in another. Both might be technically understandable. Both might also produce different translations for the same product concept.

That’s where manual context starts to hit a wall.

A small comparison

Tool Good for Weak point
pgettext Disambiguating one string Easy to miss in a large codebase
Translator comments Explaining business meaning Scattered and inconsistent
Rewriting the source Removing ambiguity early Requires discipline from developers

The best immediate fix is still rewriting the source string to be clearer. But once your app grows, you need one place to define terminology on purpose, not ad hoc.

Use a TRANSLATING.md Glossary for Consistency

A codebase with serious i18n needs a source of truth for terminology.

Not a spreadsheet someone forgot to update. Not a wiki page hidden in another tool. Not a bunch of scattered translator comments across templates and views. Put it in the repo.

That file should be TRANSLATING.md.

Infographic

Why a glossary belongs in Git

Translation choices are product decisions.

If your app uses “workspace,” “organization,” “team,” and “project” as distinct concepts, those words need stable translations. The same is true for developer-facing terms like “commit,” “branch,” and “merge,” which often have domain-specific meanings that differ from everyday English.

A glossary in version control gives you three things comments do not:

That fits how engineering teams already work.

Source string complexity is a primary enemy

Generic advice about plain language often targets marketers writing web copy. That misses the mess in software projects. According to analysis cited in Big Duck’s piece on writing for clearer translation, 70% of localization problems in open-source projects come from source string complexity. That matches what most Django teams run into. The issue is not only sentence style. It’s terminology drift inside code.

You see it in apps where one screen says “Sign in,” another says “Log in,” and a third says “Authenticate.” English readers tolerate that. Translation systems turn it into inconsistent product language.

What to put in TRANSLATING.md

Keep it simple. Markdown is enough.

# TRANSLATING.md

## Product terms

- Workspace
  - Meaning: top-level container for projects and members
  - Do not translate as: folder, area, desk

- Organization
  - Meaning: billing and ownership entity
  - Keep distinct from: Workspace, Team

## Developer terms

- Commit
  - Meaning: Git commit
  - Do not translate as: promise, obligation

- Branch
  - Meaning: Git branch
  - Context: version control

## Style rules

- Use formal second person in UI text
- Keep button labels short
- Do not translate product name "AcmeCloud"
- Preserve placeholders and HTML tags exactly

That file does two jobs. It teaches human reviewers what your terms mean. It also gives automated tooling a stable guide.

A glossary solves a different class of problem

pgettext helps one string.

A glossary helps every string that reuses a concept.

That difference matters once your codebase gets large enough that the same term appears in admin screens, onboarding flows, billing pages, emails, API docs, and support tooling. If each area invents its own wording, your translations will drift.

A good glossary also settles naming debates early. If the repo says “workspace” is the canonical product term, developers stop introducing “space” and “project area” in random commits.

Keep it narrow and opinionated

A bad glossary tries to define every word.

A useful glossary defines the words that break products when translated inconsistently:

That’s enough.

For a fuller breakdown of the terminology you may want to standardize, this glossary of localization terms is a useful reference.

Tip: If a term appears in your navigation, billing screens, permissions UI, or onboarding flow, put it in TRANSLATING.md.

Treat glossary changes like schema changes

Don’t casually rename terms after translation starts.

If you change “workspace” to “hub,” that is not just a copy edit. It affects translators, support docs, screenshots, and user expectations. Review those changes with the same care you’d apply to a migration that touches production data.

A practical pattern is to assign glossary ownership to the team that owns product language. For small teams, that’s often the lead developer plus whoever writes user-facing copy.

One file beats scattered tribal knowledge

The reason this works is boring. Boring is good.

A repo file survives onboarding, branch changes, CI runs, and contributor turnover. Slack messages don’t. Shared spreadsheets usually don’t either. Writing for translation gets much easier when the rules live next to the code that needs them.

Automate Translation Quality in Your CI Pipeline

Good string hygiene helps. Manual discipline alone won’t hold up.

Someone will add a fuzzy string at 6 PM on a Friday. Someone else will merge it without context. Then a release branch picks it up, the .po files lag behind, and your multilingual app becomes a scavenger hunt.

The fix is to make translation quality part of CI, not a side task.

A simple flow diagram showing a software development process starting with code commit, translatebot scan, and deploy.

Why CI matters now

LLM-based translation is good at simple text and much worse at idioms and messy product strings. According to Lionbridge’s article on writing for translation, models like GPT-4o reach 95% fidelity on simple text but drop to 72% on idiomatic strings, and developer queries around this problem have seen a 40% spike. That tells you where the risk lives. Not in clean labels. In the odd, inconsistent strings developers add under pressure.

CI is where you catch those strings before they ship.

A practical workflow

A sane Django translation pipeline usually looks like this:

  1. Extract new strings with makemessages
  2. Check what changed in .po files
  3. Run translation automation only on new or changed entries
  4. Review the diff in Git
  5. Compile messages before deploy

That keeps the work incremental.

Start with local commands

Keep the commands boring and scriptable.

python manage.py makemessages -a
translate-po-files
python manage.py compilemessages

If you install via pip:

pip install translatebot

Or with uv:

uv add --dev translatebot

The key is that translation should run like formatting or tests. One command in the repo, not a trip to a portal.

Add a quality gate before translation

Don’t feed bad strings into automation and hope the output saves you.

Lint the source first. You can do this with a small custom script if you want. Flag things like:

A simple example:

# scripts/check_i18n_strings.py
import re
from pathlib import Path

BAD_PATTERNS = [
    re.compile(r'_\("Open"\)'),
    re.compile(r'_\("Clear"\)'),
    re.compile(r'_\("Post"\)'),
    re.compile(r'_\(".*%s.*"\)'),
]

for path in Path(".").rglob("*.py"):
    text = path.read_text(encoding="utf-8")
    for pattern in BAD_PATTERNS:
        if pattern.search(text):
            print(f"Check translatable string in {path}: {pattern.pattern}")
            raise SystemExit(1)

Crude is fine. You can refine it later.

Tip: The best CI rule is the one your team will keep enabled.

Example GitHub Actions job

Here’s a simple workflow shape:

name: i18n

on:
  pull_request:
  push:
    branches: [main]

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
          pip install translatebot

      - name: Extract messages
        run: python manage.py makemessages -a

      - name: Check source strings
        run: python scripts/check_i18n_strings.py

      - name: Translate changed PO entries
        run: translate-po-files

      - name: Compile messages
        run: python manage.py compilemessages

      - name: Fail on dirty diff
        run: git diff --exit-code

That last step is useful on pull requests. It forces contributors to commit updated locale files.

Auto-commit or fail fast

You have two workable policies.

Policy one: fail the build if locale files changed and tell the developer to run the command locally.

Policy two: let CI commit the updated .po files back to the branch.

Small teams often prefer the second option because it removes one more manual step. Larger teams sometimes prefer the first because auto-commits can surprise people.

Both are fine. Pick one and stick to it.

Put the glossary in the loop

The value of CI goes up when your terminology rules are checked on every run.

That means your TRANSLATING.md file should sit in the repo root and be reviewed like code. If a product term changes, CI should pick it up on the next translation pass. That turns the glossary from documentation into part of the build input.

A short demo helps if you want to see that flow in practice.

Review diffs, not dashboards

This is the biggest workflow improvement for developers.

Review translated .po changes in GitHub or GitLab the same way you review code. You can comment on a changed msgid, spot a broken placeholder, and compare terminology without leaving the PR.

That is far easier than jumping into a translation SaaS UI for every small copy change.

Keep the pipeline boring

A reliable setup usually follows these rules:

Rule Why it matters
Translate only changed strings Reduces noise in diffs
Keep glossary in Git Makes terminology reviewable
Compile in CI Catches broken files before deploy
Review .po diffs in PRs Fits normal developer workflow

If you want the exact command patterns and CI examples, the TranslateBot CI usage docs show the developer-oriented setup clearly.

Writing for translation works best when the process stops depending on memory. Put the rules in source strings, put terminology in TRANSLATING.md, and let CI enforce the rest.


If you’re tired of copy-pasting .po files into web translators or paying for a full SaaS platform just to keep Django locale files updated, TranslateBot is worth a look. It runs from the CLI, works with your existing gettext flow, preserves placeholders and HTML, and keeps translations reviewable in Git instead of hiding them behind another dashboard.

Stop editing .po files manually

TranslateBot automates Django translations with AI. One command, all your languages, pennies per translation.