Skip to content

Internationalization (i18n)

If you're already familiar with Pinmok's multilingual features, expand the Quick Start below for a concise reference.

Quick Start

Enable Django i18n

Follow Django's standard internationalization setup. The padmin interface language automatically follows the current locale — no additional configuration required.

Multilingual model base classes

Pinmok provides two model base classes for simple main-table + translation-table scenarios:

  • TranslatableModel: main table base class, holds language-independent fields
  • TranslationModel: translation table base class, holds per-language content. Subclasses must satisfy three constraints: the FK pointing to the main table must use related_name='translations' (use the constant TRANSLATION_RELATED_NAME instead of hardcoding the string); a UniqueConstraint must be defined; get_display_text() must be implemented
from pinmok.core.translatable import TranslatableModel, TranslationModel
from pinmok.core.constants import TRANSLATION_RELATED_NAME


class Article(TranslatableModel):  # main table
    sort_order = models.IntegerField(default=0)  # language-independent fields go here


class ArticleTranslation(TranslationModel):  # translation table
    article = models.ForeignKey(
        Article,
        on_delete=models.CASCADE,
        related_name=TRANSLATION_RELATED_NAME,  # must be 'translations'
    )
    title = models.CharField(max_length=255)

    class Meta(TranslationModel.Meta):
        constraints = [  # required — prevents duplicate entries for the same language
            models.UniqueConstraint(
                fields=['article', 'language'],
                name='uniq_article_language'
            )
        ]

    def get_display_text(self):  # required — return the primary display field
        return self.title

Reading translations:

# List query — avoids N+1
articles = Article.with_translations()

# Read the current language value of `title`
article.translation.title

Clean up redundant .po entries (optional)

makemessages can produce duplicate entries in .po files, and Django's built-in apps already ship their own translations — there's no need to maintain duplicates in your project. Run the following command to clean everything up in one pass:

python manage.py dedupe_po [-l LANGUAGE_CODE] [-d {django,djangojs}] [--dir SCAN_DIRECTORY]
                           [--no-backup] [--no-comments]
Option Default Description
-l, --lang zh-hans Target language code
-d, --domain all Restrict to a specific domain: django or djangojs, repeatable
--dir current directory Root directory to scan
--no-backup Skip backup (a .bak file is created by default)
--no-comments Strip comments

Internationalization (i18n)

Pinmok's multilingual support is built entirely on Django's i18n framework, without introducing any additional configuration layer. padmin ships with built-in translations for Simplified Chinese (zh-hans) and English (en). The admin interface language follows Django's current locale, requiring no extra setup. For other languages, you can add your own by following the existing translation files in the locale/ directory.

Pinmok supports three levels of multilingual configuration:

  1. Fixed language: Set LANGUAGE_CODE in settings.py. The admin interface will use that language regardless of user preference.
  2. Automatic switching: Enable USE_I18N and add LocaleMiddleware. Django will detect the user's language preference in priority order from the URL prefix, session, cookie, and Accept-Language request header, then switch automatically.
  3. Manual switching: Building on the above, add the i18n/ route in urls.py. A language switcher will appear in the admin top bar, allowing users to explicitly select their preferred language.

Since manual switching requires the complete configuration, the following section covers only that scenario. For other cases, refer to the Django documentation.


Language Switcher

The padmin admin template has a built-in language switcher. It is displayed when USE_I18N is enabled and the set_language route is accessible (Django enables USE_I18N by default). Two steps are required to make the switcher functional:

1. Add LocaleMiddleware

Add django.middleware.locale.LocaleMiddleware to MIDDLEWARE in settings.py, after SessionMiddleware and before CommonMiddleware:

MIDDLEWARE = [
    ...
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.locale.LocaleMiddleware',  # here
    'django.middleware.common.CommonMiddleware',
    ...
]

Django will infer the user's language preference from the session, cookie, and Accept-Language header in that order. See How Django discovers language preference.

2. Add the i18n route

In your project's urls.py:

from django.urls import path, include

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

This registers Django's built-in set_language() view, which sets the user's language preference and redirects back to the previous page. Once configured, the language switcher will appear in the admin top bar.

About LANGUAGES

The language list in the switcher is taken from LANGUAGES in settings.py. If not set, Django will list all built-in supported languages, which is an extensive list. It is recommended to specify explicitly:

LANGUAGES = [
    ('zh-hans', 'Simplified Chinese'),
    ('en', 'English'),
]

Interface Translation

Translating interface text — buttons, labels, messages — follows the standard Django workflow: mark strings with gettext or gettext_lazy, extract them with makemessages, edit the .po files, then compile with compilemessages.

Translation files for Pinmok apps follow Django conventions and live in each app's locale/ directory, where Django discovers them automatically. Language switching, URL prefixes, and user preference storage all use Django's standard mechanisms — Pinmok imposes no additional constraints. See the Django documentation for the complete i18n reference.

In addition to Django's standard i18n, Pinmok provides two supplementary tools for scenarios not covered by the standard approach: * Translatable Models (TranslatableModel / TranslationModel) for multilingual storage and retrieval of database content; and the PO Deduplication Tool* (dedupe_po) for cleaning up redundant entries in .po files.


Multilingual Models

The problem

Django's i18n system (.po / .mo files) handles interface text — strings that are statically defined in code and known at build time. It cannot handle database content entered by users at runtime: product names, article titles, category labels, navigation items. That content only exists once the application is running.

The standard approach for this kind of content is a main table + translation table structure: the main table stores language-independent attributes (ordering, status, timestamps), and the translation table stores per-language text, linked to the main table via a foreign key with one row per language. The pattern itself is straightforward, but implementing it from scratch for every model generates a lot of boilerplate: each model ends up with the same "fetch current language" logic, the same fallback strategy, the same N+1 prevention logic.

TranslatableModel and TranslationModel centralize all of that shared logic. You define the translation table's specific fields; everything else works out of the box.

What the base classes provide

TranslatableModel (main table base class) provides:

  • translation property: returns the translation object for the current language, falling back gracefully when an exact match isn't found. Templates and serializers can access it directly as obj.translation.name without any additional code
  • get_translation(language=None): explicitly request a specific language, returns the corresponding translation object
  • with_translations(queryset=None): class method that uses prefetch_related to batch-load translation data, eliminating N+1 queries in list views
  • invalidate_translation_cache(): clears the per-instance translation cache after saving a translation within the same request
  • __str__ returns the result of get_display_text() for the current language, so the main model displays the correct language text in admin list views, dropdowns, and logs

When an exact language match isn't found, get_translation() falls back in this order:

  1. Exact match for the current language (e.g. zh-hans)
  2. Base language code match (zh-hanszh)
  3. The default language defined in settings.LANGUAGE_CODE
  4. The first available translation record

TranslationModel (translation table base class) provides:

  • language field (CharField, max_length=10): choices come from settings.LANGUAGES, default is settings.LANGUAGE_CODE, indexed in the database
  • get_display_text() (abstract method, subclasses must implement): the base class raises NotImplementedError; both the main table and the translation table's __str__ depend on this method
  • __str__ returns [{language}] {get_display_text()}, e.g. [zh-hans] Tech Category

The actual content fields (name, description, title, etc.) are defined by the developer in each subclass.

Subclasses must satisfy the following constraints: the FK pointing to the main table must use related_name='translations' (use the constant TRANSLATION_RELATED_NAME rather than hardcoding the string); a UniqueConstraint on [fk_field, 'language'] must be defined to prevent duplicate records for the same language.

Defining models

The main table inherits from TranslatableModel; the translation table inherits from TranslationModel:

from django.db import models
from pinmok.core.translatable import TranslatableModel, TranslationModel
from pinmok.core.constants import TRANSLATION_RELATED_NAME


# Main table
class Category(TranslatableModel):
    sort_order = models.IntegerField(default=0)
    is_active = models.BooleanField(default=True)

    class Meta:
        ordering = ['sort_order']


# Translation table
class CategoryTranslation(TranslationModel):
    # FK — related_name must be the string "translations" or the constant TRANSLATION_RELATED_NAME
    category = models.ForeignKey(
        Category,
        on_delete=models.CASCADE,
        related_name=TRANSLATION_RELATED_NAME,
    )
    name = models.CharField(max_length=255)
    description = models.CharField(max_length=200, blank=True, default='')

    class Meta(TranslationModel.Meta):
        # Required — prevents duplicate entries for the same language
        constraints = [
            models.UniqueConstraint(
                fields=['category', 'language'],
                name='uniq_category_language'
            )
        ]

    # Implement the abstract method — return the primary display field
    def get_display_text(self):
        return self.name

Reading translations

Once the models are in place, accessing translated content in views, templates, and serializers requires no extra code:

category = Category.objects.get(pk=1)

# Current language
category.translation.name
category.translation.description

# Explicit language
category.get_translation('en').name

# __str__ returns the current language automatically
str(category)

translation and get_translation() check for a prefetch_related cache first; if one exists, they use it directly. Otherwise, the result is cached on the instance, so the database is only queried once per instance lifetime.

Batch queries and the N+1 problem

In list views or bulk-processing scenarios, accessing translation on each object without preloading will trigger one extra database query per object — the classic N+1 problem. Use with_translations() to batch-load all translation data upfront via prefetch_related:

# Load translations for all records
categories = Category.with_translations()

# Works on an existing queryset too
categories = Category.with_translations(
    Category.objects.filter(is_active=True)
)

# No extra queries during iteration
for category in categories:
    print(category.translation.name)

Refreshing the cache after saving

get_translation() caches its result on the instance after the first call to avoid redundant queries within the same request. If you save a translation record and need to read the updated value in the same request, call invalidate_translation_cache() to clear the cache:

translation.save()
category.invalidate_translation_cache()
# The next access to category.translation will query the database again

Managing translations in admin

The main table and translation table are two separate database tables. Registering them independently would require editors to jump between two pages to update a single record. Embedding the translation table as an Inline in the main table's edit page keeps everything on a single page — this is the recommended approach. Use PinmokStackedInline when the translation table has many fields; PinmokTabularInline works better when there are only a few.

from pinmok import padmin
from pinmok.padmin.options import PinmokModelAdmin, PinmokStackedInline
from .models import Category, CategoryTranslation


# Translation table Inline — embedded in the main table's edit page
class CategoryTranslationInline(PinmokStackedInline):
    model = CategoryTranslation
    extra = 1


# Main table ModelAdmin — links to the translation table via inlines
@padmin.register(Category)
class CategoryAdmin(PinmokModelAdmin):
    inlines = [CategoryTranslationInline]

PO Deduplication Tool

Why it exists

Two kinds of redundant entries can accumulate in .po files over time.

Self-duplicates: makemessages can produce entries with identical msgid values in the same .po file under certain conditions — running it multiple times, merging branches, or modifying templates. These duplicates don't break compilation, but they create maintenance confusion and can cause conflicts in translation tools.

Duplicates of system translations: Django's built-in apps (django.contrib.admin, django.contrib.auth, etc.) already maintain their own translations in their own locale/ directories, and Django loads them automatically at runtime. Any matching msgid in your project's .po files is redundant — and if Django updates those translations in a future release, your stale copies will shadow the newer versions.

dedupe_po handles both kinds in a single pass: it removes self-duplicates within each file, then compares against system translations and removes any entries already covered by Django's built-in apps.

Command-line usage

Run from the project root:

# Process the default language (zh-hans), all domains
python manage.py dedupe_po

# Specify a language
python manage.py dedupe_po -l en

# Restrict to the djangojs domain
python manage.py dedupe_po -d djangojs

# Specify the scan root
python manage.py dedupe_po --dir /path/to/project

The tool recursively scans locale/<lang>/LC_MESSAGES/ under every app in the specified directory — no need to run it per app.

Command-line options:

Option Default Description
-l, --lang zh-hans Target language code
-d, --domain all Restrict to a specific domain: django or djangojs, repeatable
--dir current directory Root directory to scan
--no-backup Skip backup (a .bak file is created by default)
--no-comments Strip comments

Programmatic usage

PoDeduplicator can also be instantiated directly in code, which is useful when you need to automate deduplication in a deployment script, capture results for logging or notifications, or dynamically decide which languages and domains to process.

from pinmok.core.utils.po_deduplicator import PoDeduplicator, PoDedupeResult
from pinmok.core.libs.console import MessageLevel

# Progress callback
def my_progress(level: MessageLevel, message: str, metadata: dict) -> None:
    print(f"[{level}] {message}", metadata)


deduper = PoDeduplicator(
    lang='zh-hans',              # target language code, default 'zh-hans'
    domain=None,                 # 'django', 'djangojs', a list, or None (all domains)
    base_dir='/path/to/project', # scan root, defaults to the current directory
    backup=True,                 # create a .bak backup before modifying, default True
    keep_comments=True,          # preserve comments in .po files, default True
    emit_progress=my_progress,   # optional progress callback; omit to run silently
)

results: list[PoDedupeResult] = deduper.handle()

Progress callback

emit_progress is an optional callback invoked at each processing step, including: file discovered, domain processing started, each file processed, backup created, and processing completed. The required signature is:

def my_progress(level: MessageLevel, message: str, metadata: dict) -> None:
  • level: a MessageLevel enum value indicating the event severity — LOG, INFO, SUCCESS, WARNING, ERROR, DEBUG, STEP, STEP_DONE, or STEP_FAIL
  • message: a human-readable description of the event
  • metadata: a dict of event-specific data, such as the file path when processing a file or the count of files processed when finishing a domain

Omit emit_progress entirely to run silently.

Results

handle() returns a list of PoDedupeResult objects, one per processed file:

Field Type Description
file str Absolute path to the .po file
original int Entry count before processing
removed int Number of entries removed
remaining int Entry count after processing
backup_path str Path to the backup file; empty string if no backup was created
backup_created bool Whether a backup was created

Backups

By default, a timestamped backup is written to the same directory before each file is modified:

django_250601120000.bak

A backup is only created when the file actually changes — files with no redundant entries are left untouched. Delete the backup files once you've confirmed the results, or pass --no-backup to skip them entirely.