Quickstart
From an empty directory to a signed-in admin with a real model on the page, in about fifteen minutes.
Available since v0.1.0
This goes end to end on SQLite, which needs nothing running. Swapping to PostgreSQL at the end is one line.
1. Install
mkdir shop && cd shop
uv init
uv add "fastfort[sqlalchemy,sqlite,cli]"
The ORMs are separate extras and neither is imported at package level, so installing one never pulls in the other.
2. A database, owned by your project
FastFort never opens a connection of its own. It takes the session factory you already built.
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
engine = create_async_engine("sqlite+aiosqlite:///./shop.db")
# expire_on_commit=False is 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[AsyncSession] = async_sessionmaker(
engine, expire_on_commit=False
)
3. Models
Ordinary declarative models. There is no FastFort base class to inherit from, which is what lets an existing schema get an admin with no migration.
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(sa.String(255), unique=True)
full_name: Mapped[str | None] = mapped_column(sa.String(160), default=None)
hashed_password: Mapped[str] = mapped_column(sa.String(255), default="")
is_active: Mapped[bool] = mapped_column(default=True)
is_staff: Mapped[bool] = mapped_column(default=False)
is_superuser: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime(timezone=True), default=lambda: dt.datetime.now(dt.UTC)
)
# Every foreign-key cell, every picker option and every delete confirmation
# renders this. Without it they all show `<User object at 0x…>`.
def __str__(self) -> str:
return self.email
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(sa.String(120), unique=True)
def __str__(self) -> str:
return self.name
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(sa.String(200))
description: Mapped[str | None] = mapped_column(sa.Text(), default=None)
price: Mapped[Decimal] = mapped_column(sa.Numeric(12, 2), default=Decimal("0.00"))
stock: Mapped[int] = mapped_column(default=0)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime(timezone=True), default=lambda: dt.datetime.now(dt.UTC)
)
category_id: Mapped[int | None] = mapped_column(
sa.ForeignKey("categories.id"), default=None
)
category: Mapped[Category | None] = relationship()
def __str__(self) -> str:
return self.name
The five conventional attribute names on User — identity, password, active,
staff, superuser — are detected, so set_user_model needs no arguments. Name
them differently and you pass them explicitly.
4. Register the admin
@admin.register runs at import time and buffers the registration, which is
why this module imports models and nothing else. An admin module that imported
the application would be a circular import in every project.
from fastfort import admin
from .models import Category, Product, User
@admin.register(User, key="accounts.user")
class UserAdmin(admin.ModelAdmin):
list_display = ("id", "email", "full_name", "is_active", "is_staff", "created_at")
list_filter = ("is_active", "is_staff", "is_superuser")
search_fields = ("email", "full_name")
ordering = ("-created_at",)
readonly_fields = ("created_at",)
group_name = "Accounts"
icon = "users"
@admin.register(Category, key="shop.category")
class CategoryAdmin(admin.ModelAdmin):
list_display = ("id", "name")
search_fields = ("name",)
ordering = ("name",)
group_name = "Shop"
icon = "folder"
@admin.register(Product, key="shop.product")
class ProductAdmin(admin.ModelAdmin):
list_display = ("id", "name", "category", "price", "stock", "is_active")
list_filter = ("is_active", "category", "price")
search_fields = ("name", "description")
ordering = ("-created_at",)
# Without this, rendering the category column costs one query per row.
select_related = ("category",)
readonly_fields = ("created_at",)
group_name = "Shop"
icon = "box"
actions = ("delete", "archive")
@admin.action("Archive", icon="box")
async def archive(self, adapter, objects):
for product in objects:
await adapter.update(product, {"is_active": False})
return f"{len(objects)} products archived."
5. Mount it
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastfort import FastFort, FastFortSettings
from fastfort.orm.sqlalchemy import SQLAlchemyBackend
from app.db import Base, engine, session_factory
from app.models import User
fort = FastFort(
settings=FastFortSettings(project_name="Shop"),
backend=SQLAlchemyBackend(session_factory=session_factory, base=Base),
)
fort.set_user_model(User, identity_field="email")
# Imports app/admin.py so its decorators run. `fort.autodiscover("app")` is the
# alternative: it walks the package for every `admin.py` it can find.
fort.include_admin("app.admin")
@asynccontextmanager
async def lifespan(_: FastAPI):
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
fort.mount(app)
FastFortSettings has no default secret_key, and that is deliberate: a
framework that ships one guarantees some deployment runs with it. Generate one
and put it in the environment.
6. A key, an account, a server
export FASTFORT_SECRET_KEY=$(uv run fastfort generate-secret)
FF_PASSWORD='choose-something-real' uv run fastfort createsuperuser \
--app main:fort \
--identity you@example.com \
--password-env FF_PASSWORD \
--no-input
uv run uvicorn main:app --reload
--password-env rather than --password: a password passed as an argument is
visible in your shell history and in ps.
Open http://127.0.0.1:8000/admin and sign in.
7. Check the configuration
uv run fastfort check --app main:fort --deploy
It collects every problem instead of raising on the first, and exits non-zero — so it can gate a deployment:
Shop · 3 model(s)
warn security.cookie_secure=False sends the session cookie over plain HTTP.
Set FASTFORT_SECURITY__COOKIE_SECURE=true and serve over HTTPS.
warn debug=True exposes tracebacks and internal state. Set FASTFORT_DEBUG=false.
2 problem(s) found.
Moving to PostgreSQL
One line, plus the extra:
uv add "fastfort[postgres]"
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/shop")
Nothing above the ORM layer changes. Anything that differs between engines lives in the backend’s dialect module, which exists precisely so that “works on all three” is testable.
Next
- Model admin — every option and what it changes.
- Settings — the whole configuration surface.