ORM backends

SQLAlchemy 2.0 and Tortoise behind one adapter contract — and the four lines that differ between them.

Available since v0.2.0

Nothing above fastfort/orm/ may import SQLAlchemy or Tortoise, and a test fails the build on a violation. Introspection turns a model into a ModelSpec — plain, immutable, JSON-serialisable data — and every layer above works from that.

The payoff was concrete: the second adapter was added without changing a line of the admin.

SQLAlchemy

uv add "fastfort[sqlalchemy,postgres]"
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase

from fastfort.orm.sqlalchemy import SQLAlchemyBackend


class Base(DeclarativeBase):
    pass


engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/shop")

# Not a preference. The admin's views read attributes off an object *after* the
# unit of work has committed — the label in a success message, the primary key
# to redirect to. With the default, each of those is a synchronous refresh
# against an async session, which raises MissingGreenlet.
session_factory = async_sessionmaker(engine, expire_on_commit=False)

fort = FastFort(
    settings=settings,
    backend=SQLAlchemyBackend(session_factory=session_factory, base=Base),
)

FastFort never opens a connection of its own. It takes the factory you built.

Tortoise

uv add "fastfort[tortoise,postgres]"

Four lines differ. Everything else — the settings, @admin.register, list_display, actions, export, import — is identical, because everything else is above the ORM layer and never sees a model.

from tortoise import Tortoise

from fastfort.orm.tortoise import TortoiseBackend

await Tortoise.init(
    db_url="postgres://user:pass@localhost/shop",
    modules={"models": ["app.models"]},
    # Tortoise keeps its connections in a contextvar, and an ASGI server runs
    # the lifespan in a different task from the requests. Without this flag the
    # init above is invisible to every view: start-up looks healthy and the
    # first page that touches the database is a 500. `RegisterTortoise` from
    # `tortoise.contrib.fastapi` passes it for you.
    _enable_global_fallback=True,
)

fort = FastFort(settings=settings, backend=TortoiseBackend())
fort.set_user_model(User)
fort.mount(app)

“The second one behaves the same” is a test

tests/orm/test_conformance.py asks both backends the same questions over identical model shapes — the same columns, the same relations, the same deletions — and compares the answers. It is why the claim above is a test rather than an assertion.

fastfort/orm/coerce.py is the one thing the two adapters share, because turning a query string into a column’s type is about the column rather than about either ORM.

Databases

SQLitePostgreSQLMySQL
Core CRUD, filters, search
JSON✅ JSONB
Arrays, hstore, ranges, inet, citext
PostGIS geometry
pgvector
uv add "fastfort[sqlalchemy,sqlite]"
uv add "fastfort[sqlalchemy,postgres]"
uv add "fastfort[sqlalchemy,mysql]"

Anything touching SQL that differs between engines lives in the backend’s dialect module, which exists precisely so that “works on all three” is testable rather than hoped for.

The two rules an adapter follows

Adapters never commit

They flush. The UnitOfWork context manager commits on a clean exit and rolls back on an exception, so a half-failed request leaves nothing behind — including a bulk action that fails on its fortieth row.

Rollback expires every attribute

After await uow.rollback(), reading instance.id is a synchronous refresh against an async session outside the greenlet bridge — it crashes.

So: read every value you still need — the label, the primary key — before rolling back, or render the response first and roll back afterwards. This is the single most common way to break code that sits on top of the adapters.

Request flow

introspect(model) → ModelSpec          once, cached on the backend
ModelAdmin(spec)                       once, validated when built
request → ListQuery.from_params(...)   the validation boundary
        → backend.unit_of_work()       one transaction per request
        → backend.adapter(model, uow)  request-scoped, never cached
        → adapter.list/get/create/...  flushes, never commits
        → Renderer.render(template)    server-side HTML

The adapter is request-scoped and never cached; the spec is cached, which is why register_type has to run before anything is introspected.

Adding a backend

The protocols are in fastfort/orm/base.py: Backend, ModelAdapter, UnitOfWork. Implement those three and the conformance suite tells you whether you got it right — and nothing above fastfort/orm/ needs to change, because that boundary is enforced by a test rather than by convention.