django-dynamic-translations
Natural, model-specific translations for Django.
django-dynamic-translations lets you declare translated values directly on a Django
model while storing them in a generated, model-specific translation table. Application
code reads familiar attributes such as article.title; the library handles active
languages, fallback, forms, admin integration, query helpers, and optional Django REST
Framework serializers.
class Article(TranslatableModel):
title = TranslatedField[str](models.CharField(max_length=200))
slug = TranslatedField[str](models.SlugField(max_length=200, unique=True))
body = TranslatedField[str](models.TextField())
is_published = models.BooleanField(default=False)
The declaration above generates an ArticleTranslation model and exposes title,
slug, and body as language-aware attributes on Article.
[!IMPORTANT] The project is currently alpha software. Its public API is usable and tested, but it may evolve before version 1.0.
Highlights
- One concrete translation table per translatable model; no generic foreign keys.
- Natural attribute access through
article.title,article.slug, and similar aliases. - Automatic fallback to the configured default language.
- A required, complete default translation for every saved object.
- Query helpers for translated filtering and constant-query prefetching.
- Automatic all-languages Django Admin forms.
- Reusable
ModelFormintegration for standalone workflows. - Optional Django REST Framework serializer support.
- Normal Django migrations, relations, field validation, and database constraints.
- Typed public APIs for Python 3.12 and newer.
Contents
- Installation
- Quick start
- How storage works
- Reading and writing translations
- Queries and performance
- Forms
- Django Admin
- Django REST Framework
- Validation and lifecycle rules
- Static typing
- Development and contributing
Installation
The package requires Python 3.12+ and Django 5.2–6.1.
python -m pip install django-dynamic-translations
For Django REST Framework support, install the optional extra:
python -m pip install "django-dynamic-translations[drf]"
Add the application to INSTALLED_APPS:
INSTALLED_APPS = [
# Django and project applications...
"django_dynamic_translations",
]
Configure the default language and every language your application supports:
LANGUAGE_CODE = "en-us"
LANGUAGES = [
("en-us", "English (United States)"),
("it", "Italiano"),
("de", "Deutsch"),
]
Only add application-supported languages to LANGUAGES. Each configured language is
available to the translation API and becomes a section in generated forms.
For request-driven language selection, enable Django's locale middleware after session middleware:
MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
Quick start
1. Declare translated fields
Inherit from TranslatableModel and wrap each translated Django field with
TranslatedField:
from django.db import models
from django_dynamic_translations.models import TranslatableModel, TranslatedField
class Category(TranslatableModel):
name = TranslatedField[str](models.CharField(max_length=100))
parent = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="children",
)
def __str__(self) -> str:
return self.name
class Article(TranslatableModel):
title = TranslatedField[str](models.CharField(max_length=200))
slug = TranslatedField[str](models.SlugField(max_length=200, unique=True))
body = TranslatedField[str](models.TextField())
is_published = models.BooleanField(default=False)
categories = models.ManyToManyField(Category, blank=True)
def __str__(self) -> str:
return self.title
TranslatedField accepts ordinary, non-relational Django model fields. Their settings
are preserved on the generated translation model, including blank, max_length,
validators, widgets, and uniqueness.
2. Create migrations
Use Django's normal migration workflow:
python manage.py makemigrations
python manage.py migrate
The migration contains the shared models plus generated CategoryTranslation and
ArticleTranslation models. Each translation belongs to one object and one normalized
language code.
3. Create the default translation
Translated keyword arguments passed while creating an object become its default-language translation:
article = Article.objects.create(
title="A practical guide to Django",
slug="practical-django-guide",
body="The English article body.",
is_published=True,
)
The default translation is required. Saving a new object without all required default
translated fields raises MissingDefaultTranslation.
4. Add another language
article.set_translation(
"it",
title="Una guida pratica a Django",
slug="guida-pratica-django",
body="Il contenuto italiano dell'articolo.",
)
article.save()
set_translation() changes the in-memory translation. save() persists the shared
object and every dirty translation atomically.
5. Read using the active language
from django.utils import translation
with translation.override("it"):
assert article.title == "Una guida pratica a Django"
with translation.override("de"):
# No German translation exists, so the default English value is returned.
assert article.title == "A practical guide to Django"
In an HTTP request, LocaleMiddleware activates the language selected by Django, such
as the language negotiated from Accept-Language.
How storage works
For every concrete TranslatableModel, the library generates a concrete translation
model:
Article ArticleTranslation
-------------------------- --------------------------------
id id
is_published article_id -> Article
categories language_code
title
slug
body
The generated table has a uniqueness constraint on the parent object and language code. There can therefore be at most one translation for each object-language pair.
The generated model is registered in the same Django application and module as its parent. It can be imported when direct access is useful:
from myapp.models import Article, ArticleTranslation
Most application code should use the natural aliases and translation methods on
Article rather than querying ArticleTranslation directly.
Reading and writing translations
Select a language for one object
set_current_language() overrides the active Django language for a specific instance:
article.set_current_language("it")
print(article.title)
article.set_current_language(None) # Follow Django's active language again.
Assignments use the selected language. Existing objects without an explicit selection use Django's active language:
article.set_current_language("it")
article.title = "Django: guida pratica"
article.save(update_fields={"title"})
New objects always treat translated constructor values as the required default-language translation.
Access translation objects directly
italian = article.get_translation("it")
print(italian.title)
available = article.get_translations("en-us", "it", "de")
print(available.keys()) # dict_keys(['en-us', 'it'])
get_translation() does not fall back unless requested explicitly:
translation_object = article.get_translation("de", use_fallback=True)
Clear the instance cache
Translations are cached per model instance. Clear that cache after out-of-band database updates when the current instance must reload translation rows:
article.clear_translation_cache()
refresh_from_db() also resets translation state.
Queries and performance
Filter translated values
Use translated() rather than manually spelling the generated relation:
article = Article.objects.translated(slug="guida-pratica-django").get()
Without an explicit language, the query uses the effective active language:
with translation.override("it"):
articles = Article.objects.translated(title__icontains="django")
Pass one or more language codes when the query must be explicit:
articles = Article.objects.translated("en-us", "it", title__icontains="django")
Translated filtering matches stored translations; it does not apply fallback inside the SQL query.
Avoid N+1 queries
Use prefetch_translations() for lists, pagination, serializers, and templates:
articles = Article.objects.order_by("pk").prefetch_translations()
This evaluates as one query for the shared objects and one query for their translations. Reading any translated alias afterwards does not issue another query.
Pass relation paths to prefetch translations belonging to related translatable models:
articles = Article.objects.prefetch_translations("categories")
Nested paths are supported:
articles = Article.objects.prefetch_translations("categories__parent")
Each path is expanded to its generated translations relation. The related model at the
end of the path must be translatable.
Forms
Standalone generated forms
Create a reusable form class with translatable_modelform_factory():
from django_dynamic_translations.forms import translatable_modelform_factory
ArticleForm = translatable_modelform_factory(Article)
By default, the form includes every editable shared model field plus translated fields for every configured language. Restrict only the shared fields when needed:
ArticleForm = translatable_modelform_factory(
Article,
fields=("is_published", "categories"),
)
Translated fields are always added for every configured language. exclude=(...) is
also supported for shared model fields.
Custom forms
Inherit from TranslatableModelForm for custom validation, widgets, or form methods:
from django.core.exceptions import ValidationError
from django_dynamic_translations.forms import TranslatableModelForm
class ArticleForm(TranslatableModelForm):
class Meta:
model = Article
fields = ("is_published", "categories")
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("is_published") and not cleaned_data.get("categories"):
raise ValidationError("Published articles need at least one category.")
return cleaned_data
The default-language section is mandatory. An optional language is saved only when all of its required fields are complete. Clearing every field for an optional language deletes that translation.
save(commit=False) follows Django conventions. Call save_m2m() after saving the
instance to persist many-to-many values and deferred translation deletions.
Django Admin
Use TranslatableAdmin; no form declaration or factory call is required:
from django.contrib import admin
from django_dynamic_translations.admin import TranslatableAdmin
from .models import Article
@admin.register(Article)
class ArticleAdmin(TranslatableAdmin):
list_display = ("title", "slug", "is_published")
search_fields = ("translations__title", "translations__slug")
list_filter = ("is_published",)
The admin automatically:
- generates a form backed by
TranslatableModelForm; - creates one fieldset for each configured language;
- identifies the default-language section;
- loads translations efficiently for list views;
- saves shared values and translations together.
When custom form behavior is needed, inherit from TranslatableModelForm and assign the
class to the admin. The admin supplies the model and field list, so Meta is optional
when the form is used only by that admin:
class ArticleAdminForm(TranslatableModelForm):
def clean(self):
cleaned_data = super().clean()
# Project-specific validation.
return cleaned_data
@admin.register(Article)
class ArticleAdmin(TranslatableAdmin):
form = ArticleAdminForm
Using a plain forms.ModelForm with TranslatableAdmin raises an explicit configuration
error instead of silently omitting translations.
Django REST Framework
Install the optional extra:
python -m pip install "django-dynamic-translations[drf]"
Subclass TranslatableModelSerializer. It discovers translated fields from the
generated translation model, including when fields = "__all__" is used:
from django_dynamic_translations.rest_framework import TranslatableModelSerializer
class CategorySerializer(TranslatableModelSerializer):
class Meta:
model = Category
fields = ("id", "name")
class ArticleSerializer(TranslatableModelSerializer):
categories = CategorySerializer(many=True, read_only=True)
class Meta:
model = Article
fields = "__all__"
Translated serializer fields preserve the generated model field's type, length, blank
rules, and validators. Explicit field lists, exclude, and nested serializers created
through Meta.depth are supported.
Use a prefetched queryset in list endpoints:
class ArticleViewSet(ModelViewSet):
queryset = Article.objects.prefetch_translations("categories")
serializer_class = ArticleSerializer
With LocaleMiddleware, serialized aliases follow the request's active language and
fall back to the default language. Creates write the required default translation;
updates assign translated values to the instance's selected or active language.
For endpoints that accept several languages in one request, model that payload
explicitly and call set_translation() for each language rather than relying on the
single-language aliases.
Validation and lifecycle rules
- A complete default-language translation is required before a new object can be saved.
- Language codes are normalized to lowercase BCP 47-style values such as
en-us. - Unsupported language codes are rejected when translations are written directly.
- A translated alias reads the active language first, then the default language.
- Forms require every non-blank field when an optional language contains any content.
save(update_fields={...})accepts translated alias names.- Shared-object and translation writes run inside a database transaction.
bulk_create()is intentionally unavailable for translatable models because it would bypass the required default translation.TranslatedFieldsupports scalar Django fields, not relations or primary keys.
Static typing
The package ships a py.typed marker and exposes generic TranslatedField declarations:
title = TranslatedField[str](models.CharField(max_length=200))
Some language servers collapse Django custom managers to BaseManager. If the editor
does not see translated() or prefetch_translations(), narrow the manager without
changing runtime behavior:
from typing import cast
from django_dynamic_translations.models import TranslatableManager
manager = cast(TranslatableManager, Article.objects)
articles = manager.prefetch_translations("categories")
Public API overview
| Module | Main APIs |
|---|---|
django_dynamic_translations.models |
TranslatableModel, TranslatedField, TranslatableManager, TranslatableQuerySet |
django_dynamic_translations.forms |
TranslatableModelForm, translatable_modelform_factory, translation form-field naming helpers |
django_dynamic_translations.admin |
TranslatableAdmin |
django_dynamic_translations.rest_framework |
TranslatableModelSerializer when the drf extra is installed |
Development and contributing
The project uses uv for dependency and environment management:
git clone https://github.com/AndreaBellomia/django-dynamic-translations.git
cd django-dynamic-translations
uv sync
uv run pytest
Run the complete local quality gate before opening a pull request:
uv run ruff check .
uv run ruff format --check .
uv run pyright
uv run pytest
Bug reports, documentation improvements, tests, and focused feature proposals are welcome. See the contribution guide for the development workflow and pull request expectations.
Releases
GitHub releases publish the matching wheel and source distribution to PyPI through
Trusted Publishing. Release tags must be v followed by the exact package version.
Release notes are maintained in the
changelog.
License
django-dynamic-translations is available under the
MIT License.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_dynamic_translations-0.2.0.tar.gz.
File metadata
- Download URL: django_dynamic_translations-0.2.0.tar.gz
- Upload date:
- Size: 21.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
982c89b56e3754619a94d754813f2a67012c3ee19b76e40da2271be1d9405d99
|
|
| MD5 |
d55bd0e39776b11246ad92788a2132ca
|
|
| BLAKE2b-256 |
7a3a9c275a082c64482b38232db6c94d091ef8596b493ffbd7098c1378c6980d
|
Provenance
The following attestation bundles were made for django_dynamic_translations-0.2.0.tar.gz:
Publisher:
publish.yml on AndreaBellomia/django-dynamic-translations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_dynamic_translations-0.2.0.tar.gz -
Subject digest:
982c89b56e3754619a94d754813f2a67012c3ee19b76e40da2271be1d9405d99 - Sigstore transparency entry: 2520561670
- Sigstore integration time:
-
Permalink:
AndreaBellomia/django-dynamic-translations@f0f648b03d1aacfb05b3e3d045c99c714c8f85f1 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/AndreaBellomia
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f0f648b03d1aacfb05b3e3d045c99c714c8f85f1 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_dynamic_translations-0.2.0-py3-none-any.whl.
File metadata
- Download URL: django_dynamic_translations-0.2.0-py3-none-any.whl
- Upload date:
- Size: 18.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2114980d35374d93d5b9480916f87fc08fb624be6bb5c4494e55e713b907b95d
|
|
| MD5 |
3dfada1d0cbc88e4a3e0167445266d4a
|
|
| BLAKE2b-256 |
45bec940b5280d64505a979f117ca457f8ddc41d378e3a14dc4a713e851f3fc1
|
Provenance
The following attestation bundles were made for django_dynamic_translations-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on AndreaBellomia/django-dynamic-translations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_dynamic_translations-0.2.0-py3-none-any.whl -
Subject digest:
2114980d35374d93d5b9480916f87fc08fb624be6bb5c4494e55e713b907b95d - Sigstore transparency entry: 2520561717
- Sigstore integration time:
-
Permalink:
AndreaBellomia/django-dynamic-translations@f0f648b03d1aacfb05b3e3d045c99c714c8f85f1 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/AndreaBellomia
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f0f648b03d1aacfb05b3e3d045c99c714c8f85f1 -
Trigger Event:
release
-
Statement type: