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 fieldsTranslationModel: translation table base class, holds per-language content. Subclasses must satisfy three constraints: the FK pointing to the main table must userelated_name='translations'(use the constantTRANSLATION_RELATED_NAMEinstead of hardcoding the string); aUniqueConstraintmust 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:
- Fixed language: Set
LANGUAGE_CODEinsettings.py. The admin interface will use that language regardless of user preference. - Automatic switching: Enable
USE_I18Nand addLocaleMiddleware. Django will detect the user's language preference in priority order from the URL prefix, session, cookie, andAccept-Languagerequest header, then switch automatically. - Manual switching: Building on the above, add the
i18n/route inurls.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:
translationproperty: 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 asobj.translation.namewithout any additional codeget_translation(language=None): explicitly request a specific language, returns the corresponding translation objectwith_translations(queryset=None): class method that usesprefetch_relatedto batch-load translation data, eliminating N+1 queries in list viewsinvalidate_translation_cache(): clears the per-instance translation cache after saving a translation within the same request__str__returns the result ofget_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:
- Exact match for the current language (e.g.
zh-hans) - Base language code match (
zh-hans→zh) - The default language defined in
settings.LANGUAGE_CODE - The first available translation record
TranslationModel (translation table base class) provides:
languagefield (CharField,max_length=10): choices come fromsettings.LANGUAGES, default issettings.LANGUAGE_CODE, indexed in the databaseget_display_text()(abstract method, subclasses must implement): the base class raisesNotImplementedError; 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: aMessageLevelenum value indicating the event severity —LOG,INFO,SUCCESS,WARNING,ERROR,DEBUG,STEP,STEP_DONE, orSTEP_FAILmessage: a human-readable description of the eventmetadata: 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.