SQLAlchemy
The SQLAlchemy backend serves as the reference implementation for BaseModelView. It has only been tested against SQLAlchemy 2 DeclarativeBase models. Other backends (such as Beanie, MongoEngine, Tortoise ORM, or your custom implementation) fulfill this identical contract against their respective data stores.
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from starlette_admin.contrib.sqla import Admin, ModelView
engine = create_engine(
"sqlite:///store.sqlite", connect_args={"check_same_thread": False}
)
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
price: Mapped[float]
class ProductView(ModelView):
fields = ["id", "name", "price"]
Base.metadata.create_all(engine)
admin = Admin(engine, title="Store Admin", secret_key="change-me")
admin.add_view(ProductView(Product, icon="fa fa-box"))
Install
Swap aiosqlite for asyncpg (PostgreSQL) or aiomysql/asyncmy (MySQL) if you prefer not to use SQLite. The database driver is only relevant for asynchronous engines. A synchronous engine uses the standard DBAPI driver required by plain SQLAlchemy (like psycopg2 or pymysql) and requires no additional packages from starlette-admin.
Async vs. sync engines
Admin accepts either an Engine or an AsyncEngine. Pass whichever instance you have configured:
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import create_async_engine
sync_engine = create_engine("postgresql+psycopg2://user:pass@localhost/store")
async_engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/store")
starlette_admin.contrib.sqla.middleware.DBSessionMiddleware inspects the engine once at request time and opens the matching session type: an AsyncSession for an AsyncEngine or a plain Session for a sync Engine. Internally, ModelView branches on isinstance(session, AsyncSession). For synchronous sessions, it routes the blocking call through anyio.to_thread.run_sync to avoid blocking the event loop.
Passing a sessionmaker instead of an engine
The session_provider parameter also accepts a sessionmaker or async_sessionmaker. Provide a session maker instead of a bare engine when you must configure the session directly.
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from starlette_admin.contrib.sqla import Admin
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/store")
session_maker = async_sessionmaker(engine, autoflush=False)
admin = Admin(
session_provider=session_maker, title="Store Admin", secret_key="change-me"
)
session_maker() to generate a new session for each request rather than constructing one internally.
sqla.Admin and sqla.ModelView
sqla.Admin accepts the same arguments as starlette_admin.BaseAdmin alongside one required positional argument: session_provider. This provider can be an Engine, AsyncEngine, sessionmaker, or async_sessionmaker. During initialization, the administration panel configures a session middleware bound to your chosen provider and inserts it at the front of the middleware stack. This mechanism guarantees that request.state.session is populated automatically on every request before your view code executes.
sqla.ModelView requires a SQLAlchemy model. At initialization, it inspects the model to automatically detect fields, manage primary keys, and configure the filter registry directly from the metadata.
Model declaration
Define your models using standard SQLAlchemy declarative classes:
from datetime import datetime
from enum import Enum
from sqlalchemy import Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class PostStatus(str, Enum):
DRAFT = "DRAFT"
PUBLISHED = "PUBLISHED"
ARCHIVED = "ARCHIVED"
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
content: Mapped[str] = mapped_column(Text)
status: Mapped[PostStatus]
views: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
If you do not set fields on the view, ModelView uses every attribute declared on the model in the order they appear. The primary key is automatically detected and excluded from create and edit forms. Every other column and relationship converts to an appropriate field type (like IntegerField, StringField, EnumField, HasOne, or HasMany) automatically.
Auto-detected defaults
A column's Python-side default= configuration populates automatically the first time you open the creation form. You are not required to repeat this definition on the field itself:
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
views: Mapped[int] = mapped_column(default=0) # form shows 0
created_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow
) # form shows now()
A scalar default (default=0) copies exactly as defined. A callable default (default=datetime.utcnow or default=uuid.uuid4) triggers once when rendering the form, allowing the reader to see a real value rather than the function's repr format.
Important
Primary key columns never receive a pre-filled default even if one is defined. They are assumed to be server-generated (via autoincrement or a sequence) and are excluded entirely from create and edit forms. SQL-expression defaults (such as server_default=func.now() or a database-side DEFAULT) are also skipped because there is no Python-level value to display. The database handles populating these values upon insertion.
Relationship fields
A SQLAlchemy relationship() on the model automatically converts to HasOne (many-to-one or one-to-one) or HasMany (one-to-many or many-to-many) based on the RelationshipProperty.direction attribute. You do not need to explicitly declare the field type:
from sqlalchemy.orm import Mapped, mapped_column, relationship
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="posts")
Setting PostView.fields = ["id", "title", "author"] renders author as a Select2 dropdown. This dropdown is populated via AJAX from the related view's /_api/{key}/relation-lookup endpoint (where {key} represents author, the key of AuthorView). The application never loads the entire table of authors into the page at once. This lazy-loading behavior is critical for performance when the related table contains thousands of rows. Similarly, setting AuthorView.fields = ["id", "name", "posts"] renders posts as a multi-select control utilizing the same lookup endpoint.
Composite primary keys
Models utilizing multiple primary_key=True columns for composite primary keys are supported out of the box without any extra configuration. This includes scenarios where every primary-key column also serves as a foreign key, such as a many-to-many association object.
Please refer to examples/12-sqla-composite-pks for a fully runnable example.
Filter registry
Every field type receives a default set of filters from SqlaFilterRegistry. These are resolved by traversing the field's class hierarchy as detailed in the Filters documentation. A crucial SQLAlchemy-specific detail is how each filter translates into a query fragment. Every apply() method in this module returns a standalone SQLAlchemy boolean clause (such as column == value or column.between(a, b)).
Note
The Is null filter on a relationship evaluates ~column.has() (for many-to-one relations) or ~column.any() (for one-to-many and many-to-many relations) instead of column.is_(None). Because a relationship attribute is not a standard column containing a NULL value, its nullability depends entirely on whether any related rows exist.
Note
The SQLAlchemy backend does not provide ArrayInFilter or ArrayNotInFilter (the "is one of" filters for list-valued columns) as Beanie and MongoEngine do. You must write your own apply() logic if you require "is one of" filtering on a TagsField-backed JSON or ARRAY column. Consult the Custom Filters documentation for more details.
Sessions and transactions
The session middleware opens exactly one session per request and stores it securely on request.state.session. This object will be an AsyncSession when using an asynchronous engine (or async_sessionmaker) and a standard Session otherwise. Every component touched by the request shares this single session. The list query, the relationship lookups within forms, and any custom logic running in a hook, action, or endpoint will all operate inside the exact same transaction. You retrieve it uniformly regardless of the underlying engine type:
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.requests import Request
from starlette_admin import action
from starlette_admin.contrib.sqla import ModelView
class PostView(ModelView):
@action(name="publish", text="Publish selected")
async def publish(self, request: Request, pks: list[Any]) -> str:
session: AsyncSession = request.state.session
for post in await self.find_by_pks(request, pks):
post.status = "PUBLISHED"
session.add(post)
await session.flush()
return f"{len(pks)} post(s) published."
When using a synchronous engine, request.state.session evaluates to a standard Session and session.flush() is called without an await. The remainder of the preceding code snippet remains identical. You do not need to import a separate get_session() dependency. The session attaches to the request before your hook or action executes because the session middleware establishes the connection prior to invoking the route handler.
One commit per request. You should never invoke session.commit() manually. Calling flush() (or doing nothing if performing read-only queries) is sufficient. The session middleware commits the session exactly once after the route handler returns, provided the response indicates success. If an error occurs, the middleware rolls back the entire transaction automatically:
- If the handler raises an exception, the session rolls back and the application re-raises the exception.
- If the handler returns a response with a
status_code >= 400(such as a form validation failure), the session rolls back and the server returns the response unmodified. This rollback is crucial because the transaction might contain a failed flush at that stage. Committing it could inadvertently persist a partially written record. - If the commit operation itself raises an exception (such as a database constraint violation caught at flush time), the session rolls back and re-raises the exception.
In all other scenarios where the handler yields a 2xx or 3xx response, the middleware commits the session and releases the connection. This lifecycle explains why the publish action demonstrated above does not require explicit commit or close statements. The session middleware opens the session before your code executes and subsequently handles the commit or rollback operations before the response leaves the view.
Pydantic validation
You might use plain SQLAlchemy models but still want to validate form data against a Pydantic schema before saving. In this case, starlette_admin.contrib.sqla.ext.pydantic.ModelView accepts a pydantic_model argument to execute validation against that schema rather than the underlying SQLAlchemy column types:
from sqlalchemy import ForeignKey, String, Text, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from pydantic import BaseModel, EmailStr, Field, HttpUrl, field_validator
from starlette_admin.contrib.sqla import Admin
from starlette_admin.contrib.sqla.ext.pydantic import ModelView
engine = create_engine(
"sqlite:///users.sqlite", connect_args={"check_same_thread": False}
)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
full_name: Mapped[str | None] = mapped_column(String(100))
email: Mapped[str | None] = mapped_column(String(255))
website: Mapped[str | None] = mapped_column(String(512))
class UserIn(BaseModel):
id: int | None = None
full_name: str = Field(min_length=3)
email: EmailStr
website: HttpUrl
@field_validator("full_name")
@classmethod
def validate_full_name(cls, v: str) -> str:
if len(v.strip().split()) < 2:
raise ValueError("Must include both first and last name (e.g. John Doe)")
return v
admin = Admin(engine, title="Users Admin", secret_key="change-me")
admin.add_view(ModelView(User, pydantic_model=UserIn, icon="fa fa-users"))
Submitting full_name="Madonna" (a single word) fails the validate_full_name method. The create or edit form then re-renders with the error explicitly attached to the full_name field. The underlying SQLAlchemy String(100) column has no such rule. The constraint resides entirely within the UserIn Pydantic model. Refer to examples/11-sqla-pydantic-fastapi for the complete example, which also includes a secondary view (PostIn) attached to the same administration panel.
Full working example
Here is a full SQLAlchemy example with starlette-admin. See examples/01-quickstart for the runnable version.
1. Install dependencies
The fastapi[standard] package includes the FastAPI CLI, allowing you to start the development server by running fastapi dev.
2. Create the Application
Save the following code as main.py. This script uses a local SQLite database (blog.db) for demonstration purposes, though Starlette-Admin supports both synchronous and asynchronous engines for PostgreSQL, MySQL, and SQLite.
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from fastapi import FastAPI
from sqlalchemy import ForeignKey, Text, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from starlette_admin import SlugField
from starlette_admin.contrib.sqla import Admin, ModelView
engine = create_engine("sqlite:///blog.db")
class Base(DeclarativeBase):
pass
class PostStatus(str, Enum):
DRAFT = "DRAFT"
PUBLISHED = "PUBLISHED"
ARCHIVED = "ARCHIVED"
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
slug: Mapped[str]
content: Mapped[str] = mapped_column(Text)
status: Mapped[PostStatus] = mapped_column(default=PostStatus.DRAFT)
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="posts")
class AuthorView(ModelView):
fields = ["id", "name", "posts"]
class PostView(ModelView):
fields = [
"id",
"title",
SlugField("slug", populate_from="title"),
"content",
"status",
"created_at",
"author",
]
exclude_fields_from_create = ["created_at"]
exclude_fields_from_edit = ["created_at"]
searchable_fields = ["title", "content", "status"]
fields_default_sort = [("created_at", True)]
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
yield
app = FastAPI(lifespan=lifespan)
admin = Admin(engine, title="Blog Admin", secret_key="change-me")
admin.add_view(AuthorView(Author, icon="fa fa-user"))
admin.add_view(PostView(Post, icon="fa fa-newspaper"))
admin.mount_to(app)
3. Run the Server
Start the FastAPI development server:
You can now navigate to http://127.0.0.1:8000/admin in your browser to view and interact with the admin dashboard.
What to Read Next
- Views: Explore
BaseModelViewconfiguration options independent of the backend. - Filters: Details on the filter builder and URL format powered by the filter registry.
- Views: A comprehensive list of every
ModelViewconfiguration option (backend-agnostic). - SQLModel: A thin wrapper around this backend that adds Pydantic validation to forms.
- Beanie: A guide to using the identical
ModelViewAPI against a MongoDB database. - Tortoise ORM: The other relational backend built into starlette-admin.