GitHub - Onlykh/translatable

ORM-agnostic i18n for Python. Store translations in JSON columns, resolve by locale via a context variable — no extra tables, no `.po` files.
Inspired by spatie/laravel-translatable, built with Python idioms: `contextvars`, dict subclasses, optional extras.
Install
[](https://github.com/onlykh/translatable#install) > PyPI release coming soon. Install directly from GitHub for now.
core only
pip install git+https://github.com/onlykh/translatable.git
with SQLAlchemy support
pip install "translatable[sqlalchemy] @ git+https://github.com/onlykh/translatable.git"
with Starlette / FastAPI support
pip install "translatable[starlette] @ git+https://github.com/onlykh/translatable.git"
with Flask support
pip install "translatable[flask] @ git+https://github.com/onlykh/translatable.git"
everything
pip install "translatable[all] @ git+https://github.com/onlykh/translatable.git"
Quickstart
[](https://github.com/onlykh/translatable#quickstart)
from translatable import configure, set_locale
configure(default_locale="en", available_locales=["en", "fr", "ar"])
t = {"en": "Hello", "fr": "Bonjour"} from translatable import Translations
title = Translations(t) set_locale("fr") print(title) # Bonjour
SQLAlchemy 2.0
[](https://github.com/onlykh/translatable#sqlalchemy-20)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from translatable.ext.sqlalchemy import translatable_column, MutableTranslations
class Base(DeclarativeBase): pass
class Article(Base): __tablename__ = "articles" id: Mapped[int] = mapped_column(primary_key=True)
from translatable import set_locale
set_locale("en") article.title = "Hello" # merges into current locale article.title["fr"] = "Bonjour" # merge other locales article.title.set_all({"en": "Hi"}) # explicit full replace
FastAPI / Starlette
[](https://github.com/onlykh/translatable#fastapi--starlette)
from fastapi import FastAPI from translatable.ext.starlette import TranslatableMiddleware
app = FastAPI() app.add_middleware(TranslatableMiddleware)
Query by locale
[](https://github.com/onlykh/translatable#query-by-locale)
from translatable.ext.sqlalchemy import by_locale
session.scalars( select(Article).where(by_locale(Article.title, "fr") == "Bonjour") ).all()
Dialect-specific SQL is generated automatically (PostgreSQL `->>`, SQLite `json_extract`, MySQL `JSON_EXTRACT`).
Configuration
[](https://github.com/onlykh/translatable#configuration) | Option | Default | Description | | --- | --- | --- | | `default_locale` | required | Fallback locale | | `available_locales` | `None` | Allowed locales (validated on `set_locale`) | | `fallback_map` | `{}` | e.g. `{"fr_ca": "fr"}` | | `on_missing` | `"fallback"` | `"fallback"` | `"empty"` | `"raise"` |
Concurrency
[](https://github.com/onlykh/translatable#concurrency) String and dict assignments **merge** into existing translations. Use `set_all()` to replace everything.
For concurrent updates to the same row, use one of:
1. **Optimistic locking** — SQLAlchemy `version_id_col` 2. **`atomic_set_locale()`** — PostgreSQL JSONB `||` merge (atomic per key) 3. **`SELECT FOR UPDATE`** — row lock during read-modify-write
from translatable.ext.sqlalchemy import atomic_set_locale
atomic_set_locale(session, Article, {"id": 1}, "title", "fr", "Bonjour")
PostgreSQL indexing
[](https://github.com/onlykh/translatable#postgresql-indexing) If you filter often by locale on PostgreSQL, add a GIN index in your migration:
CREATE INDEX ix_articles_title ON articles USING GIN (title);
SQLite and MySQL do not support equivalent JSON indexes; filtering may scan the full table.
Development
[](https://github.com/onlykh/translatable#development)
pip install -e ".[dev]" pytest ruff check src tests
License
[](https://github.com/onlykh/translatable#license) MIT