Docs / fastkit-i18n / Database Models

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.

Works with SQLAlchemy Works with SQLModel Requires [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:

python
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

python
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:

python
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:

python
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:

python
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:

python
# 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:

python
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:

python
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:

python
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:

python
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__:

python
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:

python
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:

python
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:

python
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:

python
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

__translatable__: list[str]
Names of fields that store per-locale content. Required — defaults to an empty list.
__fallback_locale__: str | None
Per-model fallback locale. Defaults to the app-wide default (get_default_locale()) when None.

Instance methods

get_locale() -> str
Resolves: instance locale → shared context locale → __fallback_locale__.
set_locale(locale: str) -> Self
Sets the instance-scoped locale. Returns self for chaining.
get_translations(field: str) -> dict[str, str]
All stored locale → value pairs for a field. Raises ValueError if the field isn't in __translatable__.
set_translation(field: str, value: str, locale: str = None)
Set a field's value for a specific locale (current locale if omitted). Returns self.
get_translation(field: str, locale: str = None, fallback: bool = True) -> str | None
Get a field's value for a specific locale, with optional fallback.
has_translation(field: str, locale: str = None) -> bool
Whether a non-empty value exists for a field/locale.
validate_translations(required_locales: list[str] = None) -> dict[str, list[str]]
Returns {field: [missing locales]} for every translatable field. Defaults to requiring just the fallback locale.

Classmethods (shared context)

set_global_locale(locale: str) / get_global_locale() -> str
Read/write the same shared context-local locale used by fastkit_i18n.set_locale()/get_locale() and LocaleMiddleware.

Complete Example

A product catalog with three languages, backed by SQLite:

python
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()
python
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