django-py-translations
Typed translations for Django model fields. One line per field. A JSON column by default, or a real translation table when a language needs its own index.
class Article(TranslatableModel):
class Translations:
backend = "json" # or "table"
fallbacks = {"uk": ["en"]}
slug = models.SlugField()
title = TranslatedField(models.CharField(max_length=200))
body = TranslatedField(models.TextField(), fallback=["en"])
article.title is the string for the active language. The active language is the one pinned on the instance, then django.utils.translation.get_language(), then PY_TRANSLATIONS["DEFAULT_LANGUAGE"], then settings.LANGUAGE_CODE.
Install
pip install django-py-translations
Add py_translations to INSTALLED_APPS. Django REST framework is an extra:
pip install django-py-translations[drf]
Django 4.2+ and Python 3.10+. Django 4.2 left extended support in April 2026; it stays in the test matrix because projects still run it. Current targets are 5.2 LTS, 6.0, and 6.1.
Settings
PY_TRANSLATIONS = {
"BACKEND": "json",
"DEFAULT_LANGUAGE": "en",
"LANGUAGES": ["uk", "en"], # or settings.LANGUAGES
"FALLBACKS": {"uk": ["en"], "*": ["en"]},
}
A new language is a new key or a new row. Neither backend needs a schema migration to add one.
Backends
Pick one backend per model. A child model cannot switch the backend a parent already used for its fields.
JSON stores each translated field in its own JSON column, default {}. A list and a read are one query. There is no join. An index for one language is explicit:
from py_translations import json_translation_index
class Meta:
indexes = [json_translation_index("title", "uk", name="article_title_uk")]
max_length on a JSON field is checked in Python.
Table creates {App}{Model}I18n with db_table {app}_{model}_i18n, a master foreign key (CASCADE, related_name="translations"), language_code, and one real column per translated field (null=True, blank=True). Unique together on (master, language_code). A list is two queries: the masters, then a prefetch. count() and exists() do not prefetch. The descriptor itself never queries.
Reading and writing
article.title = "Привіт" # writes the active language
article.set_translation("en", title="Hello", body="Body")
article.get_translation("uk") # {"title": "...", "body": "..."}
article.delete_translation("en")
with article.as_language("en"):
article.title
Missing key and None fall through the fallback chain. An empty string is a stored value and does not fall through. Fallback runs in Python when you read the attribute. filter() and order_by() use one language, the queryset language, and do not walk the chain.
Field fallback=[...] replaces the chain for that field. fallback=[] disables it. Otherwise the model Translations.fallbacks is used, then the project FALLBACKS.
QuerySet
Article.objects.translated("uk").filter(title__icontains="ривіт")
Article.objects.translated("uk", required=True) # hide rows with no "uk" value
Article.objects.filter(title__en="Hello") # explicit language wins
Page.objects.translated("uk").order_by("title")
Page.objects.with_translations("uk", "en")
Page.objects.defer_translations() # a later read raises UnloadedTranslation
.language() is an alias of .translated(). Calling .translated() again with a different language, after a table queryset has already joined, raises ValueError.
values("title") returns the raw JSON object. Django's values iterator does not go through the descriptor. Use translated_values("title") for the string of the active language.
QuerySet.update(title=...) raises NotSupportedError. F("title") is not rewritten.
JSON ordering uses Django's KT lookup. The table backend filters through a FilteredRelation aliased _i18n and prefetches the real relation translations.
Admin and DRF
from py_translations.admin import TranslatableAdmin, TranslatableStackedInline
@admin.register(Article)
class ArticleAdmin(TranslatableAdmin):
pass
The change form shows every configured language and saves them in one POST. A blank input removes that language's value. On the default language, blank=False on the inner field is still required.
from py_translations.drf import TranslatableModelSerializer
class ArticleSerializer(TranslatableModelSerializer):
class Meta:
model = Article
fields = ["slug", "title", "body"]
?lang=uk returns strings. ?lang=* returns the language maps. A string writes the requested language. A dict writes each language; an empty string clears that language.
Coming from django-parler
python manage.py migrate_parler \
--from-model app.ArticleTranslation \
--to-model app.Article
--dry-run prints the count and writes nothing. The command copies overlapping translated fields and keeps the parler rows. The table name is *_i18n, so a parler *_translation table can sit beside it during the move.
| parler | django-py-translations | |
|---|---|---|
| Storage | A translation row and a join | JSON column, or a table when you ask for one |
obj.title |
Often a query inside the descriptor | The value already loaded with the instance |
| Types | TranslatedFields(**kwargs) is untyped |
TranslatedField(CharField(...)) is str |
| Fallback | Extra queries, TranslationDoesNotExist |
Python chain on attribute access |
| Admin | A tab is a new GET and saves one language | One form, one POST, every language |
| API | django-parler-rest, a nested list | ?lang= on the model serializer |
| Async | The descriptor uses the sync ORM | aget / asave / aiterator, no query in the descriptor |
| New language | A new row, but the implicit model is fragile | A new key or row, no migration |
Limits
- Multi-table inheritance is rejected (
py_translations.E001). - No
FileField, relations,BinaryField,AutoField, or a translated field inside a translated field. - No automatic unique constraint per language. Add a
UniqueConstraintyourself on the table backend. - Async is Django's
sync_to_asyncwrappers, not a separate query compiler. - Custom managers should subclass
TranslatableManager.
Tests
pytest
ruff check .
mypy src/py_translations
python benchmarks/bench.py --n 10000
benchmarks/bench.py times JSON against the table backend on SQLite. It is not part of CI. Postgres is the useful comparison for the index case.
Metadata
Release files for django-py-translations 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_py_translations-0.1.0.tar.gz | 23.0 kB | Without provenance | Details for django_py_translations-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_py_translations-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for django_py_translations-0.1.0-py3-none-any.whl |
Total release size: 47.8 kB
Release files / django_py_translations-0.1.0.tar.gz
| Download URL | django_py_translations-0.1.0.tar.gz |
|---|---|
| Size | 23.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fd38569f2c3a0ef04599c3be3008bc9c65327be299047071a3ca2b290ec0c5a4
|
|
BLAKE2b-256 checksum How to use checksums |
c133a832c400f614168d6a1e1baae4e4a112d08e8ecc8bfd758a715b658dad26
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / django_py_translations-0.1.0-py3-none-any.whl
| Download URL | django_py_translations-0.1.0-py3-none-any.whl |
|---|---|
| Size | 24.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4b7d92ce2bd6accb39d24b8ff2d3c64645c9b398fa670415eb2fd104743c9253
|
|
BLAKE2b-256 checksum How to use checksums |
af6c23f172707bad6df0b27acf186283943bcf52a58d0bea4ad7311577ec856f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for django-py-translations 0.1.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.