Inline Forms
Inline forms allow users to manage related records directly from a parent model's create or edit page. This approach is ideal for child models that only make sense in the context of their parent, such as comments on an article or tasks within a project. Inlines eliminate the need for a separate administrative view for the child model.
See examples/06-inline-forms for a runnable app covering all three patterns described on this page: auto-detected foreign key, explicit foreign key, and composite foreign key.
A minimal inline
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from starlette.requests import Request
from starlette_admin.contrib.sqla import InlineModelView, ModelView
class Base(DeclarativeBase):
pass
class Article(Base):
__tablename__ = "articles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200))
body: Mapped[str] = mapped_column(Text, default="")
comments: Mapped[list["Comment"]] = relationship(
"Comment", back_populates="article", cascade="all, delete-orphan"
)
async def __admin_repr__(self, request: Request) -> str:
return self.title
class Comment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
article_id: Mapped[int] = mapped_column(Integer, ForeignKey("articles.id"))
author: Mapped[str] = mapped_column(String(100), default="Anonymous")
body: Mapped[str] = mapped_column(Text)
article: Mapped["Article"] = relationship("Article", back_populates="comments")
async def __admin_repr__(self, request: Request) -> str:
return f"{self.author}: {self.body[:50]}"
class CommentInline(InlineModelView):
model = Comment
fields = ["author", "body"]
extra = 1
class ArticleView(ModelView):
fields = ["title", "body"]
inlines = [CommentInline]
Setting this up requires two steps: defining an InlineModelView subclass for the child model, and adding it to the inlines list on the parent's ModelView.
The ArticleView create and edit pages will now render a Comments formset below the article's own fields. The formset begins with one empty row (extra = 1) and includes add and delete controls that the SQLAlchemy backend wires up automatically.
Notice that CommentInline never sets the fk_attr property. The SQLAlchemy backend inspects Article.comments and infers Comment.article_id as the foreign key because it is the only relationship pointing to Comment. You only need to explicitly set fk_attr when this inference is ambiguous or if the relationship is not declared on the ORM model. See Explicit and composite foreign keys for more details.
InlineModelView reference
| Attribute | Type | Default | Description |
|---|---|---|---|
model |
ORM model class | None |
The related model this inline manages. Required. |
fk_attr |
str | tuple[str, ...] |
"" |
Name of the foreign-key field on the inline model that points to the parent. A tuple declares a composite FK. Optional on the SQLAlchemy backend (auto-detected from the parent's relationship when omitted). |
extra |
int |
0 |
Number of empty rows shown on create/edit forms in addition to existing rows. |
allow_delete |
bool |
True |
Show a delete checkbox/button on each existing row. |
inline_template |
str |
"inline.html" |
Template used to render the formset. |
collapsible |
bool |
True |
Whether the formset can be expanded/collapsed by the user. |
collapsed |
bool |
False |
Initial collapsed state. Only meaningful when collapsible=True. |
The constructor will raise a ValueError if fk_attr is left empty and the backend cannot unambiguously resolve the relationship automatically.
Collapsible formsets
Every InlineModelView renders its formset with a clickable header by default (collapsible = True), so users can collapse child records that aren't relevant to the task at hand. Set collapsed = True to start the formset closed instead of open:
class CommentInline(InlineModelView):
model = Comment
fields = ["author", "body"]
extra = 1
collapsed = True
Set collapsible = False to opt a formset out entirely and always render it expanded with no toggle:
class CommentInline(InlineModelView):
model = Comment
fields = ["author", "body"]
extra = 1
collapsible = False
Explicit and composite foreign keys
You must explicitly set fk_attr when the parent has more than one relationship to the same child model, the relationship is not declared on the ORM model, or the foreign key is composite:
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from starlette.requests import Request
from starlette_admin import StringField
from starlette_admin.contrib.sqla import InlineModelView, ModelView
class Base(DeclarativeBase):
pass
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200))
tasks: Mapped[list["Task"]] = relationship(
"Task", back_populates="project", cascade="all, delete-orphan"
)
async def __admin_repr__(self, request: Request) -> str:
return self.name
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"))
title: Mapped[str] = mapped_column(String(200))
done: Mapped[bool] = mapped_column(default=False)
project: Mapped["Project"] = relationship("Project", back_populates="tasks")
async def __admin_repr__(self, request: Request) -> str:
return self.title
class TaskInline(InlineModelView):
model = Task
fk_attr = "project_id"
fields = ["title", "done"]
extra = 2
class ProjectView(ModelView):
fields = [StringField("name")]
inlines = [TaskInline]
class Order(Base):
__tablename__ = "orders"
store_id: Mapped[int] = mapped_column(Integer, primary_key=True)
seq: Mapped[int] = mapped_column(Integer, primary_key=True)
customer: Mapped[str] = mapped_column(String(100))
lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="order", cascade="all, delete-orphan"
)
async def __admin_repr__(self, request: Request) -> str:
return f"Order #{self.store_id}-{self.seq} ({self.customer})"
class OrderLine(Base):
__tablename__ = "order_lines"
order_store_id: Mapped[int] = mapped_column(Integer, primary_key=True)
order_seq: Mapped[int] = mapped_column(Integer, primary_key=True)
line_no: Mapped[int] = mapped_column(Integer, primary_key=True)
product: Mapped[str] = mapped_column(String(100))
qty: Mapped[int] = mapped_column(Integer, default=1)
order: Mapped["Order"] = relationship("Order", back_populates="lines")
__table_args__ = (
ForeignKeyConstraint(
["order_store_id", "order_seq"],
["orders.store_id", "orders.seq"],
),
)
async def __admin_repr__(self, request: Request) -> str:
return f"Line {self.line_no}: {self.product} x {self.qty}"
class OrderLineInline(InlineModelView):
model = OrderLine
fields = ["line_no", "product", "qty"]
extra = 1
class OrderView(ModelView):
fields = ["store_id", "seq", "customer"]
inlines = [OrderLineInline]
Notice that OrderLineInline does not require an fk_attr, even though the primary key for OrderLine is composite (order_store_id, order_seq, line_no). The SQLAlchemy backend resolves the composite foreign key from the ForeignKeyConstraint between Order and OrderLine, automatically populating both columns on new rows. You only need to explicitly pass a tuple[str, ...] to fk_attr if constraint introspection fails to find a match.
Validation
Each submitted row validates independently through the same create and edit paths used by a standalone ModelView. The system saves the parent first, and then processes each inline row sequentially. A typo in one comment's author field will not stop the other comments from being processed. When a row fails validation, its errors attach to that specific row. The form then re-renders the row in place with the submitted values, allowing users to correct and resubmit just that entry.
Note
On the SQLAlchemy backend, this process is entirely all-or-nothing. The parent and every inline row share the same request-scoped session, and that session only commits once the entire request succeeds. If any row fails validation, the response returns an error and the session rolls back. The parent and all inline rows revert together, including any rows that successfully passed validation. The per-row errors displayed in the UI indicate what needs fixing rather than serving as a record of saved data.
What's next
- SQLAlchemy: Explains how relationship introspection enables automatic foreign key detection.
- Custom Views: Covers building pages beyond the standard create, edit, and list workflow.
- Events: Shows how to react to inline changes after records are saved.