Skip to content

Nested Forms and Lists

A bigger form is built from smaller ones. A field typed as another FormModel becomes a sub-form, and a field typed as a list of one becomes a repeating sub-form with Add and Remove buttons. There are no special classes to learn: it's ordinary Pydantic nesting.

from pydantic_schemaforms import Field, FormModel


class Address(FormModel):
    street: str = Field(title='Street')
    city: str = Field(title='City', min_length=2)


class Pet(FormModel):
    name: str = Field(title='Name')
    age: int = Field(0, title='Age', ge=0)


class ProfileForm(FormModel):
    nickname: str = Field(title='Nickname')
    address: Address = Field(title='Home address')
    pets: list[Pet] = Field(
        default_factory=list,
        title='Pets',
        max_length=5,
        ui_add_button_label='Add a pet',
        ui_item_title_template='{name}',
    )

Use it with the routes from Handling Submissions. The form shows the nickname, then a "Home address" section, then a "Pets" list with an "Add a pet" button.

Sub-forms

address: Address renders the Address fields as a titled section. The sub-form's own rules (min_length=2 on the city) apply as usual, and its fields can have their own sub-forms, as deep as you need.

Make a section collapsible with ui_collapsible=True, or start it collapsed with ui_collapsed=True (it opens by itself when it contains an error). An optional sub-form is typed Address | None.

Repeating sub-forms

pets: list[Pet] renders one card per pet with Add and Remove buttons. Constraints count items: max_length=5 allows at most five pets, and min_length=1 would require at least one.

Setting Effect
ui_add_button_label="Add a pet" Text of the Add button
ui_item_title_template="{name}" Each card's title, filled from the item's fields; {index} is the 1-based position
ui_collapsible_items=False Plain cards instead of collapsible ones
ui_items_expanded=False Start the cards collapsed

Data and errors are nested

validate() returns the data shaped exactly like the model:

{
    "nickname": "mk",
    "address": {"street": "1 Main", "city": "Paris"},
    "pets": [{"name": "Rex", "age": 3}, {"name": "Tux", "age": 1}],
}

and the errors shaped exactly like the data, so an error is found at the same path as its value:

{
    "address": {"city": "String should have at least 2 characters"},
    "pets": {1: {"age": "Must be 0 or greater"}},
}
  • A list's errors are keyed by the item's position, and only failing items appear.
  • A message about a whole list, such as too many pets, is a string at the list's key, or under "_errors" there when some items also have errors: {"pets": {"_errors": "...", 0: {...}}}.
  • result.error_list() gives the same errors as a flat list, [{"loc": ["pets", 1, "age"], "msg": "Must be 0 or greater"}], which is handy for logging and API responses.

When the form is re-rendered, each message appears next to its input and the summary at the top names the item: "Pet #2 — Age: Must be 0 or greater".

How the inputs are named

The inputs are named address.city and pets[0].name so the browser can submit them, and validate() decodes those names back into the nested data. You never need to build or parse them yourself, except when you post raw form data in a test.

Sub-forms are also what the grouped layouts arrange into tabs, columns, panels or wizard steps: that's the next chapter, Layouts.