Database Models
TranslatableMixin gives a SQLAlchemy or SQLModel field multi-language content — it reads and writes like a plain string, but stores every locale as JSON under the hood. No separate translation table, no joins.
[sqlalchemy]
Introduction
Content that lives in the database — blog post titles, product names, category labels — needs a different approach than JSON translation files. TranslatableMixin solves this by storing every locale for a field in a single JSON column, while making the field itself behave like an ordinary string:
article.set_locale("en")
article.title = "Hello World"
article.set_locale("es")
article.title = "Hola Mundo"
article.set_locale("en")
print(article.title) # "Hello World" — looks like a plain string field
Under the hood, the database column stores {"en": "Hello World", "es": "Hola Mundo"} as JSON — a single row per record, no separate translations table.
Quick Example
from sqlalchemy import JSON
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from fastkit_i18n import TranslatableMixin
class Base(DeclarativeBase):
pass
class Article(TranslatableMixin, Base):
__tablename__ = "articles"
__translatable__ = ["title", "content"]
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[dict] = mapped_column(JSON)
content: Mapped[dict] = mapped_column(JSON)
author: Mapped[str] = mapped_column() # not translatable — works normally
TranslatableMixin must come first in the base class list — class Article(TranslatableMixin, Base), not the other way around. See SQLAlchemy and SQLModel below for why.
Defining a Model
__translatable__
A class-level list naming which fields store per-locale content. Every other field behaves exactly like a normal SQLAlchemy column:
class Product(TranslatableMixin, Base):
__tablename__ = "products"
__translatable__ = ["name", "description"]
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[dict] = mapped_column(JSON)
description: Mapped[dict] = mapped_column(JSON)
price: Mapped[int] = mapped_column() # not translatable
Translatable fields must be declared with a JSON column type — that's what actually stores the per-locale dict.
__fallback_locale__
Optional, per-model. Defaults to the app-wide default (get_default_locale(), itself "en" unless changed) when not set:
class Product(TranslatableMixin, Base):
__translatable__ = ["name"]
__fallback_locale__ = "es" # this model falls back to Spanish, not the app default
...
SQLAlchemy and SQLModel
SQLModel isn't a separate database backend — a SQLModel(table=True) class is a real SQLAlchemy mapped class underneath. That means TranslatableMixin works with it exactly the same way, including database persistence:
from sqlmodel import SQLModel, Field, Column, JSON
from fastkit_i18n import TranslatableMixin
class Article(TranslatableMixin, SQLModel, table=True):
__translatable__ = ["title", "content"]
id: int | None = Field(default=None, primary_key=True)
title: dict = Field(sa_column=Column(JSON))
content: dict = Field(sa_column=Column(JSON))
Base class order matters
TranslatableMixin overrides __setattr__/__getattribute__ to make translatable fields look like plain strings — and it doesn't cooperate with super() there. If another base (like SQLModel's underlying Pydantic BaseModel) also overrides these and comes first, writes and reads can silently split across two different implementations.
TranslatableMixin catches this itself: get the order backwards, and it raises a TypeError the moment the class is defined — not a silently empty field discovered later at runtime:
# Wrong order — SQLModel first
class Article(SQLModel, TranslatableMixin, table=True):
__translatable__ = ["title"]
...
# TypeError: Article: another base class overrides __setattr__
# resolves to SQLModel.__setattr__, which would silently break
# TranslatableMixin's transparent field access ...
# Put TranslatableMixin FIRST in the base class list, e.g.:
# class Article(TranslatableMixin, , ...): ...
Plain SQLAlchemy's DeclarativeBase doesn't override these dunder methods, so order technically doesn't matter there — but putting TranslatableMixin first everywhere is the one rule to remember, rather than one rule for SQLAlchemy and a different one for SQLModel.
Locale Management
Instance locale
set_locale() on an instance scopes the locale to that object only:
article.set_locale("es") # returns self — chainable
article.title = "Hola"
article.get_locale() # "es"
Shared context locale
Without an instance locale, TranslatableMixin reads the same context-local locale as _() and LocaleMiddleware — set it once per request and every model reads it consistently:
from fastkit_i18n import set_locale # same shared context as _() and LocaleMiddleware
set_locale("es")
article.title # Spanish — no article.set_locale() call needed
_("messages.welcome") # also Spanish
TranslatableMixin also exposes this context directly as classmethods, for when you'd rather not import from fastkit_i18n.locale:
TranslatableMixin.set_global_locale("es") # equivalent to set_locale("es")
TranslatableMixin.get_global_locale() # equivalent to get_locale()
Resolution order for any translatable field access: instance locale (if set) → shared context locale (if set) → the model's fallback locale.
Fallback Locales
Requesting a locale with no translation falls back to the model's fallback locale, rather than returning nothing:
article.set_locale("en")
article.title = "Hello"
# No French translation was ever set
article.set_locale("fr")
article.title # "Hello" — falls back to en
article.get_translation("title", fallback=False) # None — fallback disabled
Custom per-model fallback via __fallback_locale__:
class Product(TranslatableMixin, Base):
__translatable__ = ["name"]
__fallback_locale__ = "es"
...
product.set_locale("es")
product.name = "Producto"
product.get_translation("name", locale="fr", fallback=True) # "Producto" — falls back to es
Validation
Check which translations are missing before publishing content — useful in an admin panel or a pre-save check:
article.set_locale("en")
article.title = "Hello"
# content was never set
missing = article.validate_translations(required_locales=["en", "es"])
# {"content": ["en", "es"], "title": ["es"]}
if missing:
raise ValueError(f"Missing translations: {missing}")
has_translation() checks a single field/locale pair:
article.has_translation("title", locale="es") # False
article.has_translation("title") # checks current locale
Database Persistence
Translations round-trip through the database transparently — set fields, commit, and reload from a fresh session:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
engine = create_engine("sqlite:///app.db")
Base.metadata.create_all(engine)
article = Article(author="Jane")
article.set_locale("en")
article.title = "Hello World"
article.set_locale("es")
article.title = "Hola Mundo"
with Session(engine) as session:
session.add(article)
session.commit()
article_id = article.id
# Fresh session, fresh object — genuinely reloaded from the database
with Session(engine) as session:
loaded = session.get(Article, article_id)
loaded.set_locale("es")
print(loaded.title) # "Hola Mundo"
SQLAlchemy distinguishes a fresh load (a new Python object materialized from a query) from a refresh (re-querying an object already in the session's identity map). Deserialization only runs on the former. If you call session.expire_all() and re-query within the same session, you'll get the same Python object back with its in-memory state — which happens to look correct, but isn't proof of a real round-trip. Use a fresh Session (as above) to actually verify persistence.
Standalone Usage (No ORM)
TranslatableMixin also works as a plain in-memory mixin, with no SQLAlchemy base at all — useful for a DTO, a value object, or anything that wants locale-aware fields without a database:
from fastkit_i18n import TranslatableMixin
class Note(TranslatableMixin):
__translatable__ = ["title"]
note = Note()
note.set_locale("en")
note.title = "Hello"
note.set_locale("es")
note.title = "Hola"
note.set_locale("en")
note.title # "Hello"
note.get_translations("title") # {"en": "Hello", "es": "Hola"}
Still requires the [sqlalchemy] extra to be installed — TranslatableMixin uses SQLAlchemy's event system internally even when no actual database model is involved, but it degrades gracefully when there's nothing to attach events to.
API Reference
Class attributes
get_default_locale()) when None.Instance methods
__fallback_locale__.self for chaining.ValueError if the field isn't in __translatable__.self.{field: [missing locales]} for every translatable field. Defaults to requiring just the fallback locale.Classmethods (shared context)
fastkit_i18n.set_locale()/get_locale() and LocaleMiddleware.Complete Example
A product catalog with three languages, backed by SQLite:
from sqlalchemy import JSON
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from fastkit_i18n import TranslatableMixin
class Base(DeclarativeBase):
pass
class Product(TranslatableMixin, Base):
__tablename__ = "products"
__translatable__ = ["name", "description"]
__fallback_locale__ = "en"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[dict] = mapped_column(JSON)
description: Mapped[dict] = mapped_column(JSON)
price_cents: Mapped[int] = mapped_column()
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from fastkit_i18n import set_locale
from models import Base, Product
engine = create_engine("sqlite:///catalog.db")
Base.metadata.create_all(engine)
product = Product(price_cents=2999)
product.set_locale("en")
product.name = "Laptop"
product.description = "High-performance laptop"
product.set_locale("es")
product.name = "Portátil"
product.description = "Portátil de alto rendimiento"
product.set_locale("fr")
product.name = "Ordinateur portable"
product.description = "Ordinateur portable haute performance"
with Session(engine) as session:
session.add(product)
session.commit()
product_id = product.id
# In a FastAPI route, `set_locale()` would already be called by
# LocaleMiddleware — no per-request code needed here.
set_locale("fr")
with Session(engine) as session:
p = session.get(Product, product_id)
print(p.name) # "Ordinateur portable"
print(p.description) # "Ordinateur portable haute performance"
missing = p.validate_translations(required_locales=["en", "es", "fr"])
print(missing) # {} — fully translated