Tutorials

Django i18n with AI Translation: Complete Setup Guide

Eray Gündoğmuş
Eray Gündoğmuş
·13 min read
Share
Django i18n with AI Translation: Complete Setup Guide

Django i18n with AI Translation: Complete Setup Guide

Django ships with a mature internationalization (i18n) framework built on GNU gettext. When you combine that foundation with AI-powered translation, you get a workflow that scales from a solo project to a multi-locale production application without drowning in manual translation work.

This guide walks you through the entire process: configuring Django for i18n, marking strings, managing .po files, automating translation with AI, and wiring it all into a CI/CD pipeline.

Key Takeaways

  • Django's i18n system uses GNU gettext — strings are marked in Python and templates, extracted into .po files, and compiled to binary .mo files for runtime use.
  • AI translation can automate .po file translation — machine translation APIs process untranslated entries in bulk, cutting days of manual work to minutes.
  • A quality review step is essential — AI-generated translations should be reviewed by native speakers before production deployment, especially for user-facing content.
  • CI/CD integration closes the loop — automated pipelines can extract new strings, translate them, compile message files, and deploy without manual intervention.
  • Better i18n provides a managed workflow — instead of building custom scripts, you can sync .po files, manage translations, and automate the entire lifecycle from a single platform.

What Is Django i18n?

Django i18n is Django's built-in internationalization framework that lets you translate your web application into multiple languages. It wraps GNU gettext to provide string extraction, translation file management, and runtime language switching — all integrated into Django's templates, forms, and URL routing.

Django separates internationalization (i18n) from localization (l10n). Internationalization is the process of preparing your code to support multiple languages. Localization is the process of actually translating content and adapting formats for a specific locale. Django handles both through its django.utils.translation module and the gettext toolchain.

The framework supports translation of:

  • Python strings in views, models, and forms
  • Template content using built-in template tags
  • URL patterns for locale-prefixed routing
  • Date, time, number, and currency formatting via django.utils.formats

For the official reference, see the Django internationalization documentation.

Setting Up Django for Internationalization

Before you mark any strings for translation, you need to configure Django's settings and install the required middleware.

Configure Settings

Open your settings.py and set the following:

# settings.py

from django.utils.translation import gettext_lazy as _

# Default language for the application
LANGUAGE_CODE = "en"

# Enable the internationalization framework
USE_I18N = True

# Enable localized formatting of dates, numbers, etc.
USE_L10N = True

# Enable timezone-aware datetimes
USE_TZ = True

# Languages your application supports
LANGUAGES = [
    ("en", _("English")),
    ("es", _("Spanish")),
    ("fr", _("French")),
    ("de", _("German")),
    ("ja", _("Japanese")),
]

# Where Django looks for translation files
LOCALE_PATHS = [
    BASE_DIR / "locale",
]

You also need the LocaleMiddleware in your middleware stack. It must come after SessionMiddleware and before CommonMiddleware:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",  # Must be here
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

Create the locale directory structure:

mkdir -p locale/{es,fr,de,ja}/LC_MESSAGES

Mark Strings for Translation

Django provides two main functions for marking translatable strings:

  • gettext() (aliased as _()) — translates the string immediately at runtime
  • gettext_lazy() (aliased as _() from gettext_lazy) — delays translation until the string is rendered, which is required for module-level code like model fields and form labels

In views (use gettext):

from django.utils.translation import gettext as _

def dashboard_view(request):
    welcome_message = _("Welcome to your dashboard")
    context = {
        "title": _("Dashboard"),
        "welcome": welcome_message,
    }
    return render(request, "dashboard.html", context)

In models (use gettext_lazy):

from django.db import models
from django.utils.translation import gettext_lazy as _

class Article(models.Model):
    title = models.CharField(
        max_length=200,
        verbose_name=_("Title"),
    )
    body = models.TextField(
        verbose_name=_("Body"),
        help_text=_("The main content of the article."),
    )
    created_at = models.DateTimeField(
        auto_now_add=True,
        verbose_name=_("Created at"),
    )

    class Meta:
        verbose_name = _("Article")
        verbose_name_plural = _("Articles")

    def __str__(self):
        return self.title

The distinction matters: gettext_lazy returns a lazy string proxy that resolves to the correct language when the string is actually displayed. Model definitions are evaluated once at import time, so using gettext there would lock in whatever language was active during import.

Template Translation Tags

Django templates use {% load i18n %} to access translation tags:

Simple string translation with {% trans %}:

{% load i18n %}

<h1>{% trans "Welcome to our site" %}</h1>
<p>{% trans "This content will be translated." %}</p>

Block translation with {% blocktrans %} for strings that contain variables:

{% load i18n %}

{% blocktrans with username=user.username %}
    Hello, {{ username }}! You have new notifications.
{% endblocktrans %}

Pluralization:

{% load i18n %}

{% blocktrans count counter=item_count %}
    You have {{ counter }} item in your cart.
{% plural %}
    You have {{ counter }} items in your cart.
{% endblocktrans %}

Setting the language context:

{% load i18n %}

{% get_current_language as LANGUAGE_CODE %}
<html lang="{{ LANGUAGE_CODE }}">

URL Internationalization

Django's i18n_patterns function prefixes your URL patterns with the active language code:

# urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include

urlpatterns = [
    # Non-localized URLs (admin, API, etc.)
    path("api/", include("api.urls")),
]

urlpatterns += i18n_patterns(
    path("", include("pages.urls")),
    path("blog/", include("blog.urls")),
    path("accounts/", include("accounts.urls")),
    prefix_default_language=True,
)

With this configuration, your URLs become:

  • /en/blog/ — English blog
  • /es/blog/ — Spanish blog
  • /fr/blog/ — French blog

You should also include Django's built-in language-switching view:

from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include

urlpatterns = [
    path("i18n/", include("django.conf.urls.i18n")),
]

urlpatterns += i18n_patterns(
    # ... your patterns
)

This enables the {% url 'set_language' %} template tag that lets users switch languages via a form POST.

Working with .po and .mo Files

Django's translation system relies on GNU gettext's file format. Understanding this pipeline is essential before automating it.

How gettext Works in Django

The workflow follows three steps:

  1. Extractmakemessages scans your Python files and templates for translatable strings and writes them to .po (Portable Object) files.
  2. Translate — Translators (or AI) fill in the msgstr field for each msgid in the .po files.
  3. Compilecompilemessages converts .po files to binary .mo (Machine Object) files that Django reads at runtime for fast lookups.

Extracting Messages

Run the extraction command from your project root:

# Extract messages for all configured languages
python manage.py makemessages --all --no-obsolete

# Extract for a specific language
python manage.py makemessages -l es

# Include JavaScript strings (for Django's JS i18n catalog)
python manage.py makemessages -d djangojs --all

The --no-obsolete flag removes entries for strings that no longer exist in your code, keeping your .po files clean.

The .po File Structure

After extraction, each locale gets a .po file:

locale/
├── es/
│   └── LC_MESSAGES/
│       └── django.po
├── fr/
│   └── LC_MESSAGES/
│       └── django.po
└── de/
    └── LC_MESSAGES/
        └── django.po

A .po file entry looks like this:

#: templates/dashboard.html:5
msgid "Welcome to your dashboard"
msgstr ""

#: myapp/models.py:12
msgid "Article"
msgstr ""

#. Translators: This is a button label
#: templates/base.html:42
msgid "Submit"
msgstr ""

Each entry has:

  • #: comments — source file and line references
  • msgid — the original string (in your source language)
  • msgstr — the translated string (empty until translated)
  • #. comments — notes for translators (added via Translators: comments in your code)

Compiling Messages

Once translations are complete, compile them:

python manage.py compilemessages

This creates .mo files alongside the .po files. Django loads these binary files at startup for fast translation lookups. You must restart your Django server (or worker processes) after compiling new translations.

Adding AI-Powered Translation to Django

Manual translation of .po files is accurate but slow. AI-powered translation can process hundreds of entries in seconds, giving you a working first draft that human reviewers can then refine.

Parsing and Translating .po Files with Python

The polib library provides a clean API for reading and writing .po files programmatically:

pip install polib openai

Here is a script that translates untranslated entries in a .po file using an AI translation API:

# scripts/translate_po.py

import sys
import polib
from openai import OpenAI

client = OpenAI()  # Uses OPENAI_API_KEY env var

TARGET_LANGUAGES = {
    "es": "Spanish",
    "fr": "French",
    "de": "German",
    "ja": "Japanese",
}


def translate_text(text: str, target_language: str) -> str:
    """Translate a single string using an AI model."""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    f"You are a professional translator. Translate the following "
                    f"text to {target_language}. Preserve any Python format strings "
                    f"like %(name)s or {{variable}} exactly as they are. "
                    f"Return only the translated text, nothing else."
                ),
            },
            {"role": "user", "content": text},
        ],
        temperature=0.3,
    )
    return response.choices[0].message.content.strip()


def translate_po_file(po_path: str, lang_code: str) -> int:
    """Translate all untranslated entries in a .po file."""
    language_name = TARGET_LANGUAGES.get(lang_code)
    if not language_name:
        print(f"Unsupported language code: {lang_code}")
        return 0

    po = polib.pofile(po_path)
    untranslated = po.untranslated_entries()

    if not untranslated:
        print(f"No untranslated entries in {po_path}")
        return 0

    print(f"Translating {len(untranslated)} entries to {language_name}...")

    translated_count = 0
    for entry in untranslated:
        try:
            entry.msgstr = translate_text(entry.msgid, language_name)
            entry.flags.append("fuzzy")  # Mark as needing review
            translated_count += 1
        except Exception as e:
            print(f"  Error translating '{entry.msgid[:50]}...': {e}")

    po.save()
    print(f"Translated {translated_count} entries in {po_path}")
    return translated_count


if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: python translate_po.py <path/to/django.po> <lang_code>")
        sys.exit(1)

    translate_po_file(sys.argv[1], sys.argv[2])

Run it:

python scripts/translate_po.py locale/es/LC_MESSAGES/django.po es
python scripts/translate_po.py locale/fr/LC_MESSAGES/django.po fr

Batch Translation Across All Locales

For larger projects, a batch script processes every locale at once:

# scripts/translate_all.py

from pathlib import Path
from translate_po import translate_po_file, TARGET_LANGUAGES

LOCALE_DIR = Path("locale")


def translate_all_locales():
    """Translate untranslated entries for all configured locales."""
    total = 0
    for lang_code in TARGET_LANGUAGES:
        po_path = LOCALE_DIR / lang_code / "LC_MESSAGES" / "django.po"
        if po_path.exists():
            count = translate_po_file(str(po_path), lang_code)
            total += count
        else:
            print(f"No .po file found at {po_path}")

    print(f"\nTotal translations: {total}")


if __name__ == "__main__":
    translate_all_locales()

Quality Review Workflow

AI-translated entries are marked with the fuzzy flag, which tells Django (and human reviewers) that the translation needs verification. This is intentional and important:

  1. AI translates — all untranslated msgstr values are filled in and flagged as fuzzy
  2. Reviewers verify — native speakers check fuzzy entries and remove the flag once approved
  3. Compile — only non-fuzzy entries are included in the compiled .mo files by default

To review fuzzy entries:

# Count fuzzy entries per locale
for lang in es fr de ja; do
    count=$(grep -c "^#, fuzzy" locale/$lang/LC_MESSAGES/django.po 2>/dev/null || echo 0)
    echo "$lang: $count fuzzy entries"
done

You can also use .po file editors like Poedit or web-based platforms for the review step.

CI/CD Pipeline for Django Localization

Automating the extract-translate-compile cycle in your CI/CD pipeline ensures that translations stay in sync with your code.

GitHub Actions Workflow

Here is a GitHub Actions workflow that runs on every push to main:

# .github/workflows/i18n.yml

name: i18n Translation Pipeline

on:
  push:
    branches: [main]
    paths:
      - "**.py"
      - "**.html"
      - "locale/**"

jobs:
  translate:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install polib openai

      - name: Install gettext
        run: sudo apt-get install -y gettext

      - name: Extract messages
        run: python manage.py makemessages --all --no-obsolete

      - name: AI-translate new strings
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: python scripts/translate_all.py

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

      - name: Commit translation updates
        run: |
          git config user.name "i18n-bot"
          git config user.email "i18n-bot@users.noreply.github.com"
          git add locale/
          git diff --staged --quiet || git commit -m "chore(i18n): update translations"
          git push

Pipeline Breakdown

The pipeline follows four stages:

  1. Extractmakemessages scans all Python files and templates for new or changed translatable strings
  2. Translate — the AI translation script fills in any untranslated entries and marks them as fuzzy
  3. Compilecompilemessages generates the binary .mo files
  4. Commit — changes are pushed back to the repository so they are available in the next deployment

For production-critical applications, add a manual approval gate between the translate and compile steps. This gives your team a window to review fuzzy translations before they ship.

Pre-commit Hook for Local Development

You can also add a pre-commit hook to catch missing translations during development:

#!/bin/bash
# .git/hooks/pre-commit

# Check for untranslated strings
python manage.py makemessages --all --no-obsolete 2>/dev/null

if git diff --name-only locale/ | grep -q ".po$"; then
    echo "Warning: New translatable strings detected."
    echo "Run 'python scripts/translate_all.py' to translate them."
fi

How better-i18n Integrates with Django

The scripts above work, but they require you to maintain custom translation code, manage API keys, handle rate limits, and build review workflows from scratch. better-i18n provides a managed platform that handles the entire Django translation lifecycle.

Here is how better-i18n fits into a Django project:

1. Sync .po files to better-i18n

Instead of writing custom parsing scripts, you can sync your .po files directly to better-i18n. The platform reads the gettext format natively, mapping each msgid/msgstr pair to translation keys.

2. AI translation with review workflow

better-i18n provides built-in AI translation that is specifically tuned for software localization. Translations go through a managed review workflow where team members can approve, edit, or reject suggestions — no custom tooling needed.

3. Publish and pull translations

Once translations are approved, you can pull them back into your Django project as updated .po files. The publish step ensures only reviewed translations reach your codebase.

4. CI/CD integration

better-i18n's CLI can replace the custom scripts in your CI pipeline. The sync-translate-pull cycle becomes a single command in your GitHub Actions workflow.

For a deeper look at Django's i18n capabilities, see our Django i18n framework guide. If you are evaluating translation tools for your workflow, our guide to AI translation tools covers the current landscape.

FAQ

What is the difference between i18n and l10n in Django?

Internationalization (i18n) is the process of making your Django application translatable — marking strings, configuring middleware, setting up URL patterns. Localization (l10n) is the process of actually providing translations and locale-specific formatting for a particular language and region. In Django, USE_I18N = True enables the translation framework, while USE_L10N = True enables localized formatting of dates, numbers, and calendars.

How do I handle pluralization in Django?

Django handles pluralization through the ngettext() function in Python code and the {% blocktrans count %} template tag. Gettext supports complex plural rules — unlike English which has two forms (singular/plural), languages like Arabic have six plural forms and Polish has three. Django's gettext integration handles all of these through the plural form definitions in the .po file header.

Python example:

from django.utils.translation import ngettext

def item_count_message(count):
    return ngettext(
        "You have %(count)d item.",
        "You have %(count)d items.",
        count,
    ) % {"count": count}

Template example:

{% load i18n %}
{% blocktrans count counter=notifications %}
    You have {{ counter }} new notification.
{% plural %}
    You have {{ counter }} new notifications.
{% endblocktrans %}

Can I use AI to translate Django .po files?

Yes. AI translation models can parse .po files and translate msgstr entries in bulk. The recommended approach is to use a library like polib to programmatically read .po files, send untranslated strings to a translation API, write the results back with a fuzzy flag, and then have native speakers review the output. Tools like better-i18n automate this entire pipeline, including the review workflow, so you don't need to maintain custom scripts. The key consideration is always marking AI translations for human review before deploying to production.