starlette-admin
starlette-admin generates a full admin interface (paginated list, detail, create, edit, delete, search, filters, export, import) from data models. You configure everything through view classes; no custom routes or templates are needed for standard CRUD.
Mental model
Admin is a self-contained sub-application mounted onto a Starlette or FastAPI app. All admin-wide settings (title, base_url, auth, theme, i18n) are constructor kwargs.
- One class per resource. A
ModelView subclass is the single source of truth for how one model looks, validates, and behaves. CustomView builds standalone pages, Link adds sidebar hyperlinks, DropDown groups views into folders.
- The same view API works on every backend. Only the import path changes between SQLAlchemy, SQLModel, Beanie, and MongoEngine. Fields, filters, permissions, and hooks are identical.
- Fields render themselves in three contexts: list cell, detail row, form input. Strings in
fields are auto-converted from column metadata; pass explicit field instances for control.
- List state lives in the URL (
page, order_by, q, filter), so every filtered view is bookmarkable.
Backend import matrix
| Backend |
Imports |
Admin constructor |
| SQLAlchemy 2 |
from starlette_admin.contrib.sqla import Admin, ModelView |
Admin(engine_or_sessionmaker, ...) |
| SQLModel |
from starlette_admin.contrib.sqlmodel import Admin, ModelView |
Same as sqla (re-export, adds Pydantic validation) |
| Beanie |
from starlette_admin.contrib.beanie import Admin, ModelView |
Admin(...), init Beanie in app lifespan first |
| MongoEngine |
from starlette_admin.contrib.mongoengine import Admin, ModelView |
Admin(...), call me.connect() in lifespan first |
Never import ModelView from starlette_admin directly for a real backend. Field classes, widgets, and decorators do come from the top-level starlette_admin package; backend-specific filter classes come from starlette_admin.contrib.<backend>.filters.
Minimal working app (SQLAlchemy + FastAPI)
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI
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:///blog.db", connect_args={"check_same_thread": False})
class Base(DeclarativeBase):
pass
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
content: Mapped[str]
published: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
class PostView(ModelView):
fields = ["id", "title", "content", "published", "created_at"]
searchable_fields = ("title", "content")
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
yield
app = FastAPI(lifespan=lifespan) # Starlette(lifespan=lifespan) works identically
admin = Admin(engine, title="Blog Admin", secret_key="change-me")
admin.add_view(PostView(Post, icon="fa fa-newspaper"))
admin.mount_to(app)
The admin is served at /admin. Icons accept any Font Awesome class. Async engines (create_async_engine) work with the same code.
Critical rules
These are the mistakes that break real apps. Follow them without exception.
- Register views before mounting.
mount_to(app) locks the admin. Calling add_view afterward, mounting twice, or touching admin.app before mounting raises RuntimeError.
- Always set
secret_key explicitly (from an environment variable in production). It signs the CSRF and flash cookies. Without it, each worker process generates its own key and CSRF validation fails randomly across workers.
- Never call
session.commit() on the SQLAlchemy backend. Use request.state.session (populated by middleware on every request), call session.flush() if needed, and let the middleware commit exactly once per request. It rolls back on exceptions and on responses with status >= 400.
AuthProvider requires SessionMiddleware on the host app, because login() and authenticate() persist state in request.session. See references/auth.md.
- Import filter classes from your backend's module (
starlette_admin.contrib.sqla.filters, .beanie.filters, .mongoengine.filters), never from starlette_admin.filters when passing filters= to a field.
EnumField requires exactly one of enum=, choices=, or choices_loader=.
__admin_repr__ and __admin_select2_repr__ are defined on the model, not the view. Without them, related records display as bare primary keys. __admin_select2_repr__ returns HTML: render it with Jinja2 autoescape=True or escape values manually to prevent XSS.
PasswordField only masks form input. Values render as plain text on list and detail pages and are logged at DEBUG level. Set exclude_from_list=True and exclude_from_detail=True on it.
- Storage-backed
FileField/ImageField need a JSON-capable column. The database stores FileInfo metadata only. Orphaned files are never cleaned up automatically; use sqlalchemy-file if uploads must be transactional. ListField(FileField(...)) is unsupported, use multiple=True.
- In actions, signal outcomes with
flash(request, msg, "success") and raise ActionFailed("msg"), not return values. Do not call flash() in an ActionFailed branch, the request does not redirect.
- Batch action handlers receive an
ActionSelection, never a pks list. Call await selection.rows(), .pks(), or .count(); the selection may represent "select all matching" rather than materialized rows, so treating it as a list breaks under that mode.
- Always call
super() for unhandled names when overriding is_action_allowed, is_row_action_allowed, or is_row_action_allowed_for_obj. Skipping it silently disables the permission checks behind the built-in view/edit/delete actions.
after_*_committed hooks fire only on the SQLAlchemy backend and run after the session is committed and closed. Never write through request.state.session inside them.
- Multiple
Admin instances on one app need distinct base_url and route_name, otherwise generated links resolve to the wrong admin.
- Import creates per row unless upsert is on. The wizard's "Update existing records by primary key" option calls
edit() on PK matches; without it, re-importing an export with primary keys duplicates rows or fails. The preview step validates everything before writing; unchecking the PK column lets backends auto-generate keys.
form_layout must reference each field at most once and only names present in fields; violations raise ValueError at view construction. Fields omitted from the layout are appended at the bottom, never lost.
- Inline saves submit only the edited field. With
inline_editable_fields, the view's validate() hook and the edit lifecycle hooks receive data containing just that field. Guard cross-field rules with "name" in data checks; direct indexing raises KeyError.
ModelView configuration cheat sheet
class PostView(ModelView):
# What is shown; strings are auto-converted, or pass field instances
fields = ["id", StringField("title", required=True), "content", "author"]
# Surface-specific hiding: _create, _edit, _list, _detail, _export, _import
exclude_fields_from_create = ["created_at"]
# List page behavior
searchable_fields = ["title", "content"] # enables search box + filter builder
sortable_fields = ["title", "created_at"]
fields_default_sort = [("created_at", True)] # True = descending
page_size = 25
page_size_options = [25, 50, 100, -1] # -1 renders as "All"
# Feature lists
actions = ["make_published", "delete"] # batch actions ("delete" is built in)
row_actions = ["view", "edit", "delete"] # built-in row actions
inline_editable_fields = ["title", "published"] # single-field edit popovers on the list page
inlines = [CommentInline] # nested child forms
exporters = ["csv", "xlsx"] # default: ["csv", "json"]
importers = ["csv"] # default: ["csv", "json"]
form_layout = [("title", "author"), "content"] # tuple = shared row
# Permission hooks (all default to True): is_accessible, can_create, can_edit,
# can_delete, can_view_detail, can_export, can_import, can_access_field
# Lifecycle hooks: before/after_create, before/after_edit, before/after_delete,
# after_create_committed, after_edit_committed, after_delete_committed
Registration accepts naming overrides: admin.add_view(PostView(Post, key="blog-post", menu_label="Blog Posts", display_name="Article", icon="fa fa-newspaper")). When a related view uses a custom key, declare the relation manually: HasOne("author", key="custom-key") / HasMany("books", key="...").
Field type quick map
| Data |
Field |
| Short text / long text / rich text |
StringField, TextAreaField, TinyMCEEditorField (tinymce extra) |
| Formatted strings |
EmailField, URLField, UUIDField, IPAddressField, PhoneField, ColorField, PasswordField, SlugField(populate_from=...) |
| Numbers |
IntegerField(min, max, step), DecimalField, FloatField (plain text input, no min/max) |
| Boolean |
BooleanField |
| Date/time |
DateField, DateTimeField(output_format=...), TimeField, ArrowField (arrow extra) |
| Choices |
EnumField(enum= / choices= / choices_loader=, multiple=), TimeZoneField, CountryField, CurrencyField (i18n extra) |
| Collections |
TagsField (free strings), ListField(inner_field), CollectionField(fields=[...]) (nested object) |
| JSON |
JSONField(validation_schema=...) |
| Derived read-only |
ComputedField(getter=...) or subclass and override parse_obj() |
| Files |
FileField, ImageField (both take storage=, upload_folder=, accept=, max_size=, multiple=, validators=) |
| Relations |
HasOne, HasMany (auto-detected from ORM relationships) |
Common attributes on every field: label, help_text, required, disabled, read_only, default (static, zero-arg callable, or (request) -> value), getter ((request, obj) -> value, overrides the default getattr lookup in parse_obj), formatter (dict[RequestAction, (request, value) -> value], replaces serialize_value/serialize_none_value per action; its return value is used as-is), parser (dict[RequestAction, (request, raw) -> value], replaces the field's default form/import parsing per action), validators, searchable, orderable, filters, exclude_from_* flags, and extra (free metadata dict the framework never touches).
Server-side validation: pass validators=[...] on any field. A validator is a sync or async callable (request, field, value, form_values) that raises ValueError to reject the value; form_values is the full parsed submission keyed by field name, so a validator can read other fields' values. Built-in factories live in starlette_admin.validators: length, number_range, regexp, email, url, uuid, ip_address, any_of, none_of, file_size, file_type, valid_image. Empty values are only checked against required; rules that reject across multiple fields go in the view's validate() override. Details in references/fields.md.
Task router
Read the reference that matches the task before writing code:
| Task |
Reference |
| ModelView options, relations, object repr, inline forms, inline edit, form layout |
references/views.md |
| Login, OAuth/OIDC, roles, per-field and per-action permissions |
references/auth.md |
| Batch/row actions, lifecycle hooks, global events, flash messages |
references/actions-events.md |
List filters, filter URL format, custom BaseFilter |
references/filters.md |
| File uploads, storage backends, export and import |
references/files-export-import.md |
Dashboards, CustomView, widgets, custom routes and templates |
references/dashboards.md |
| Admin constructor, backends and sessions, security, i18n, themes, deployment |
references/admin-config.md |
| Field validators, custom field types, converter registry |
references/fields.md |
Upgrading an app from starlette-admin 0.17.x to 1.0.0 is covered by the separate starlette-admin-migration skill.
When working inside the starlette-admin repository itself, the full documentation lives in docs/ and runnable apps in examples/ (numbered 01-16 plus examples/advanced/). Each example runs with cd examples/<name> && uv run app.py.
1---2name: starlette-admin3description: Build and customize admin interfaces with starlette-admin, the admin framework for Starlette and FastAPI apps. Use when creating an admin panel or CRUD dashboard for SQLAlchemy, SQLModel, Beanie, or MongoEngine models, or when working with ModelView, fields, field validators, filters, batch/row actions, inline editing, authentication, file uploads, export/import, inline forms, custom dashboards, or widgets in starlette-admin.4---56# starlette-admin78starlette-admin generates a full admin interface (paginated list, detail, create, edit, delete, search, filters, export, import) from data models. You configure everything through view classes; no custom routes or templates are needed for standard CRUD.910## Mental model1112- **`Admin`** is a self-contained sub-application mounted onto a Starlette or FastAPI app. All admin-wide settings (title, base_url, auth, theme, i18n) are constructor kwargs.13- **One class per resource.** A `ModelView` subclass is the single source of truth for how one model looks, validates, and behaves. `CustomView` builds standalone pages, `Link` adds sidebar hyperlinks, `DropDown` groups views into folders.14- **The same view API works on every backend.** Only the import path changes between SQLAlchemy, SQLModel, Beanie, and MongoEngine. Fields, filters, permissions, and hooks are identical.15- **Fields render themselves** in three contexts: list cell, detail row, form input. Strings in `fields` are auto-converted from column metadata; pass explicit field instances for control.16- **List state lives in the URL** (`page`, `order_by`, `q`, `filter`), so every filtered view is bookmarkable.1718## Backend import matrix1920| Backend | Imports | Admin constructor |21| --- | --- | --- |22| SQLAlchemy 2 | `from starlette_admin.contrib.sqla import Admin, ModelView` | `Admin(engine_or_sessionmaker, ...)` |23| SQLModel | `from starlette_admin.contrib.sqlmodel import Admin, ModelView` | Same as sqla (re-export, adds Pydantic validation) |24| Beanie | `from starlette_admin.contrib.beanie import Admin, ModelView` | `Admin(...)`, init Beanie in app lifespan first |25| MongoEngine | `from starlette_admin.contrib.mongoengine import Admin, ModelView` | `Admin(...)`, call `me.connect()` in lifespan first |2627Never import `ModelView` from `starlette_admin` directly for a real backend. Field classes, widgets, and decorators do come from the top-level `starlette_admin` package; backend-specific filter classes come from `starlette_admin.contrib.<backend>.filters`.2829## Minimal working app (SQLAlchemy + FastAPI)3031```python32from contextlib import asynccontextmanager33from datetime import datetime, timezone3435from fastapi import FastAPI36from sqlalchemy import create_engine37from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column38from starlette_admin.contrib.sqla import Admin, ModelView3940engine = create_engine("sqlite:///blog.db", connect_args={"check_same_thread": False})414243class Base(DeclarativeBase):44 pass454647class Post(Base):48 __tablename__ = "posts"4950 id: Mapped[int] = mapped_column(primary_key=True)51 title: Mapped[str]52 content: Mapped[str]53 published: Mapped[bool] = mapped_column(default=False)54 created_at: Mapped[datetime] = mapped_column(55 default=lambda: datetime.now(timezone.utc)56 )575859class PostView(ModelView):60 fields = ["id", "title", "content", "published", "created_at"]61 searchable_fields = ("title", "content")626364@asynccontextmanager65async def lifespan(app: FastAPI):66 Base.metadata.create_all(engine)67 yield686970app = FastAPI(lifespan=lifespan) # Starlette(lifespan=lifespan) works identically7172admin = Admin(engine, title="Blog Admin", secret_key="change-me")73admin.add_view(PostView(Post, icon="fa fa-newspaper"))74admin.mount_to(app)75```7677The admin is served at `/admin`. Icons accept any Font Awesome class. Async engines (`create_async_engine`) work with the same code.7879## Critical rules8081These are the mistakes that break real apps. Follow them without exception.82831. **Register views before mounting.** `mount_to(app)` locks the admin. Calling `add_view` afterward, mounting twice, or touching `admin.app` before mounting raises `RuntimeError`.842. **Always set `secret_key` explicitly** (from an environment variable in production). It signs the CSRF and flash cookies. Without it, each worker process generates its own key and CSRF validation fails randomly across workers.853. **Never call `session.commit()` on the SQLAlchemy backend.** Use `request.state.session` (populated by middleware on every request), call `session.flush()` if needed, and let the middleware commit exactly once per request. It rolls back on exceptions and on responses with status >= 400.864. **`AuthProvider` requires `SessionMiddleware`** on the host app, because `login()` and `authenticate()` persist state in `request.session`. See [references/auth.md](references/auth.md).875. **Import filter classes from your backend's module** (`starlette_admin.contrib.sqla.filters`, `.beanie.filters`, `.mongoengine.filters`), never from `starlette_admin.filters` when passing `filters=` to a field.886. **`EnumField` requires exactly one of** `enum=`, `choices=`, or `choices_loader=`.897. **`__admin_repr__` and `__admin_select2_repr__` are defined on the model, not the view.** Without them, related records display as bare primary keys. `__admin_select2_repr__` returns HTML: render it with Jinja2 `autoescape=True` or escape values manually to prevent XSS.908. **`PasswordField` only masks form input.** Values render as plain text on list and detail pages and are logged at DEBUG level. Set `exclude_from_list=True` and `exclude_from_detail=True` on it.919. **Storage-backed `FileField`/`ImageField` need a JSON-capable column.** The database stores `FileInfo` metadata only. Orphaned files are never cleaned up automatically; use sqlalchemy-file if uploads must be transactional. `ListField(FileField(...))` is unsupported, use `multiple=True`.9210. **In actions, signal outcomes with `flash(request, msg, "success")` and `raise ActionFailed("msg")`,** not return values. Do not call `flash()` in an `ActionFailed` branch, the request does not redirect.9311. **Batch action handlers receive an `ActionSelection`, never a `pks` list.** Call `await selection.rows()`, `.pks()`, or `.count()`; the selection may represent "select all matching" rather than materialized rows, so treating it as a list breaks under that mode.9412. **Always call `super()` for unhandled names** when overriding `is_action_allowed`, `is_row_action_allowed`, or `is_row_action_allowed_for_obj`. Skipping it silently disables the permission checks behind the built-in view/edit/delete actions.9513. **`after_*_committed` hooks fire only on the SQLAlchemy backend** and run after the session is committed and closed. Never write through `request.state.session` inside them.9614. **Multiple `Admin` instances on one app need distinct `base_url` and `route_name`,** otherwise generated links resolve to the wrong admin.9715. **Import creates per row unless upsert is on.** The wizard's "Update existing records by primary key" option calls `edit()` on PK matches; without it, re-importing an export with primary keys duplicates rows or fails. The preview step validates everything before writing; unchecking the PK column lets backends auto-generate keys.9816. **`form_layout` must reference each field at most once and only names present in `fields`;** violations raise `ValueError` at view construction. Fields omitted from the layout are appended at the bottom, never lost.9917. **Inline saves submit only the edited field.** With `inline_editable_fields`, the view's `validate()` hook and the edit lifecycle hooks receive `data` containing just that field. Guard cross-field rules with `"name" in data` checks; direct indexing raises `KeyError`.100101## ModelView configuration cheat sheet102103```python104class PostView(ModelView):105 # What is shown; strings are auto-converted, or pass field instances106 fields = ["id", StringField("title", required=True), "content", "author"]107108 # Surface-specific hiding: _create, _edit, _list, _detail, _export, _import109 exclude_fields_from_create = ["created_at"]110111 # List page behavior112 searchable_fields = ["title", "content"] # enables search box + filter builder113 sortable_fields = ["title", "created_at"]114 fields_default_sort = [("created_at", True)] # True = descending115 page_size = 25116 page_size_options = [25, 50, 100, -1] # -1 renders as "All"117118 # Feature lists119 actions = ["make_published", "delete"] # batch actions ("delete" is built in)120 row_actions = ["view", "edit", "delete"] # built-in row actions121 inline_editable_fields = ["title", "published"] # single-field edit popovers on the list page122 inlines = [CommentInline] # nested child forms123 exporters = ["csv", "xlsx"] # default: ["csv", "json"]124 importers = ["csv"] # default: ["csv", "json"]125 form_layout = [("title", "author"), "content"] # tuple = shared row126127 # Permission hooks (all default to True): is_accessible, can_create, can_edit,128 # can_delete, can_view_detail, can_export, can_import, can_access_field129130 # Lifecycle hooks: before/after_create, before/after_edit, before/after_delete,131 # after_create_committed, after_edit_committed, after_delete_committed132```133134Registration accepts naming overrides: `admin.add_view(PostView(Post, key="blog-post", menu_label="Blog Posts", display_name="Article", icon="fa fa-newspaper"))`. When a related view uses a custom `key`, declare the relation manually: `HasOne("author", key="custom-key")` / `HasMany("books", key="...")`.135136## Field type quick map137138| Data | Field |139| --- | --- |140| Short text / long text / rich text | `StringField`, `TextAreaField`, `TinyMCEEditorField` (tinymce extra) |141| Formatted strings | `EmailField`, `URLField`, `UUIDField`, `IPAddressField`, `PhoneField`, `ColorField`, `PasswordField`, `SlugField(populate_from=...)` |142| Numbers | `IntegerField(min, max, step)`, `DecimalField`, `FloatField` (plain text input, no min/max) |143| Boolean | `BooleanField` |144| Date/time | `DateField`, `DateTimeField(output_format=...)`, `TimeField`, `ArrowField` (arrow extra) |145| Choices | `EnumField(enum= / choices= / choices_loader=, multiple=)`, `TimeZoneField`, `CountryField`, `CurrencyField` (i18n extra) |146| Collections | `TagsField` (free strings), `ListField(inner_field)`, `CollectionField(fields=[...])` (nested object) |147| JSON | `JSONField(validation_schema=...)` |148| Derived read-only | `ComputedField(getter=...)` or subclass and override `parse_obj()` |149| Files | `FileField`, `ImageField` (both take `storage=`, `upload_folder=`, `accept=`, `max_size=`, `multiple=`, `validators=`) |150| Relations | `HasOne`, `HasMany` (auto-detected from ORM relationships) |151152Common attributes on every field: `label`, `help_text`, `required`, `disabled`, `read_only`, `default` (static, zero-arg callable, or `(request) -> value`), `getter` (`(request, obj) -> value`, overrides the default `getattr` lookup in `parse_obj`), `formatter` (`dict[RequestAction, (request, value) -> value]`, replaces `serialize_value`/`serialize_none_value` per action; its return value is used as-is), `parser` (`dict[RequestAction, (request, raw) -> value]`, replaces the field's default form/import parsing per action), `validators`, `searchable`, `orderable`, `filters`, `exclude_from_*` flags, and `extra` (free metadata dict the framework never touches).153154Server-side validation: pass `validators=[...]` on any field. A validator is a sync or async callable `(request, field, value, form_values)` that raises `ValueError` to reject the value; `form_values` is the full parsed submission keyed by field name, so a validator can read other fields' values. Built-in factories live in `starlette_admin.validators`: `length`, `number_range`, `regexp`, `email`, `url`, `uuid`, `ip_address`, `any_of`, `none_of`, `file_size`, `file_type`, `valid_image`. Empty values are only checked against `required`; rules that reject across multiple fields go in the view's `validate()` override. Details in [references/fields.md](references/fields.md).155156## Task router157158Read the reference that matches the task before writing code:159160| Task | Reference |161| --- | --- |162| ModelView options, relations, object repr, inline forms, inline edit, form layout | [references/views.md](references/views.md) |163| Login, OAuth/OIDC, roles, per-field and per-action permissions | [references/auth.md](references/auth.md) |164| Batch/row actions, lifecycle hooks, global events, flash messages | [references/actions-events.md](references/actions-events.md) |165| List filters, filter URL format, custom `BaseFilter` | [references/filters.md](references/filters.md) |166| File uploads, storage backends, export and import | [references/files-export-import.md](references/files-export-import.md) |167| Dashboards, `CustomView`, widgets, custom routes and templates | [references/dashboards.md](references/dashboards.md) |168| Admin constructor, backends and sessions, security, i18n, themes, deployment | [references/admin-config.md](references/admin-config.md) |169| Field validators, custom field types, converter registry | [references/fields.md](references/fields.md) |170171Upgrading an app from starlette-admin 0.17.x to 1.0.0 is covered by the separate `starlette-admin-migration` skill.172173When working inside the starlette-admin repository itself, the full documentation lives in `docs/` and runnable apps in `examples/` (numbered 01-16 plus `examples/advanced/`). Each example runs with `cd examples/<name> && uv run app.py`.