Skip to main content

django-dynamic-translations

PyPI Python versions License: MIT

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 and django-filter integrations.

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.

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 ModelForm integration for standalone workflows.
  • Optional Django REST Framework serializer support.
  • Optional django-filter FilterSet support.
  • Normal Django migrations, relations, field validation, and database constraints.
  • Typed public APIs for Python 3.12 and newer.

Contents

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]"

For django-filter support, install its optional extra:

python -m pip install "django-dynamic-translations[filters]"

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 TranslatableLookupMixin when a detail endpoint's lookup_field is translated. The mixin resolves the value through translated() in Django's active language, with the same default-language fallback used when reading translated aliases:

from django_dynamic_translations.rest_framework import TranslatableLookupMixin


class ArticleViewSet(TranslatableLookupMixin, ModelViewSet):
    queryset = Article.objects.prefetch_translations("categories")
    serializer_class = ArticleSerializer
    lookup_field = "slug"

The mixin leaves ordinary, non-translated lookup fields unchanged. Use a prefetched queryset in list endpoints to avoid per-object translation queries.

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.

django-filter

Install the optional extra:

python -m pip install "django-dynamic-translations[filters]"

Subclass TranslatableFilterSet to declare translated fields in Meta.fields just like ordinary model fields:

from django_dynamic_translations.django_filters import TranslatableFilterSet


class ArticleFilter(TranslatableFilterSet):
    class Meta:
        model = Article
        fields = {
            "title": ["exact", "icontains"],
            "is_published": ["exact"],
        }

Generated filters use the field type and options from the generated translation model. Translated lookups use Django's active language and follow the same stored-value query semantics as QuerySet.translated(): filtering does not apply the read-time default-language fallback.

Declared filters whose field_name is a translated field are handled automatically. Filters using method= remain application-defined; call queryset.translated() inside the method when it needs to query a translated field.

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.
  • TranslatedField supports 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
django_dynamic_translations.django_filters TranslatableFilterSet when the filters 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.

Metadata

Release files for django-dynamic-translations 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-dynamic-translations 0.3.0
File Size Uploaded
django_dynamic_translations-0.3.0.tar.gz 24.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-dynamic-translations 0.3.0
File Interpreter ABI Platform
django_dynamic_translations-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 45.7 kB

Release files / django_dynamic_translations-0.3.0.tar.gz

Download URL django_dynamic_translations-0.3.0.tar.gz
Size 24.8 kB
Tags Source
SHA-256 checksum
How to use checksums
7e99dbabe900ab80a345a8d3ea2b810e2a93a5c87f33554679b752f0257ba940
BLAKE2b-256 checksum
How to use checksums
ef75d26f2decdcbf5383c6f6731088c9b45d76e6f2489fa7900ee8136cec0d5e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Release files / django_dynamic_translations-0.3.0-py3-none-any.whl

Download URL django_dynamic_translations-0.3.0-py3-none-any.whl
Size 21.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
53918cbbd12738087c99f8e744f488c3f80306bbf67875d525e9f9cbd4470140
BLAKE2b-256 checksum
How to use checksums
81d3c03e647f74c022a1b76fffa96cd6c038ca6dbda10edcfba6d4935aacd891
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page