Fields¶
A field shows a value, puts it into a form input, and reads it back when the form is saved. adminsite picks one for every column from its type, so you rarely write one yourself.
The examples on this page use the example models.
The Field gallery on the live demo shows every field on this page, each labelled with its class.
| Column | Field | Shown as | Edited with |
|---|---|---|---|
str, without a length or with one up to 255 |
StringField |
the text | a text input |
Text, or a str longer than 255 |
TextAreaField |
the text | a text box |
int |
IntegerField |
42 |
a number input |
float |
FloatField |
4.2 |
a number input |
Decimal |
DecimalField |
1,234.50 |
a number input |
bool |
BooleanField |
Yes or No |
a switch |
date |
DateField |
Sep 8, 2026 |
a date picker |
datetime |
DateTimeField |
Sep 8, 2026 14:05 |
a date and time picker |
time |
TimeField |
14:05 |
a time picker |
| an enum | EnumField |
Shipped |
a select, or a picker for more than 10 options |
| a relationship | RelationField |
the linked record's name | a picker |
Labels come from the column name, so created_at reads "Created at" and customer_id reads
"Customer". A value that does not fit, such as letters in a number, comes back on the form with a
message under the field. A DateTimeField shows times on the reader's clock, as
Time zones explains.
Choose a field¶
Field is the field adminsite picks for the column, with your options:
from adminsite import Field
class ProductView(ModelView[Product]):
fields = [
Field(Product.name, label="Product name"),
Field(Product.description, help_text="Shown on the shop page."),
Field(Product.price, format="€{:,.2f}", read_only=True),
]
A kind such as EmailField or TextAreaField picks the field itself:
from adminsite.fields import EmailField, RelationField, TextAreaField
class CustomerView(ModelView[Customer]):
fields = [
Customer.name,
EmailField(Customer.email),
TextAreaField(Customer.notes, label="Internal notes"),
]
class OrderView(ModelView[Order]):
fields = [
Order.id,
RelationField(Order.customer, record_title="{name} ({email})"),
]
Either way the field starts from its column: its label, the length of a String(80), whether it
may be empty, an enum's choices and the model a relationship links to. An option you give wins. The
options are typed keywords, so a type checker refuses a misspelt one, or TextAreaField(Order.total)
on a Decimal column. A column of a type of your own, such as a TypeDecorator, gets the field of
the type it wraps.
Every field takes the options BaseField lists, such as label,
help_text, read_only, default, format and secret. With format="€{:,.2f}" the list, the
record page, the export and the dashboard read €1,234.50, while the form's input keeps the plain
number.
EnumField on a string column takes its options from choices=[("S", "Small"), ("L", "Large")],
or from an Enum given as enum=. On an Enum column, choices relabel or narrow its members.
Without a label, a related model's column names the relation as well, so
Link(Order.customer, Customer.name) reads "Customer name".
Badge colours¶
A choice and a yes or no are drawn as a coloured badge. Without tones, each choice takes a colour
by its place in the list. Give the field tones to say which colour means what:
from adminsite.fields import BooleanField, EnumField
class OrderView(ModelView[Order]):
fields = [
EnumField(
Order.status,
tones={
OrderStatus.PENDING: "amber",
OrderStatus.SHIPPED: "green",
OrderStatus.FAILED: "rose",
},
),
EnumField(Order.country, tones="grey"),
BooleanField(Order.high_risk, tones={True: "rose", False: None}),
]
The tones are amber, blue, green, grey, violet and rose. A value left out is grey, and
None draws no badge, so a risk flag shows only when it is set. A single tone, such as "grey",
colours every value alike. A value is named by its enum member or by the value the column stores.
Say what an empty value means¶
An empty value often means something: a supplier with no closing date is still open, and a rule
with no regions picked applies in every region. Give the field empty_text, and the list and the
record page show it faint, like a placeholder, where they would show a hyphen, so it never reads
as a value someone set. The export writes it into the empty cell:
from adminsite import Field
class SupplierView(ModelView[Supplier]):
fields = [Supplier.name, Field(Supplier.closed_at, empty_text="Still open")]
A link to many records and a multi-select take it too, such as empty_text="Every region". The
form, the filters and the search are unchanged, and an import reads the text back as empty.
Relationships¶
A relationship shows the linked records' names in the list and a picker in the form, a box that
narrows the records as you type. A link to one record among 10 or fewer is a plain select instead.
On a table of more than 100 records the picker searches as you type, twenty records at a time, so
give the other model's view a record_title that tells its records apart.
The picker reads through the other model's view, so its scope_query and its permissions apply,
and it searches that view's searchable_fields. An EnumField gets the same picker over its
options when it has more than 10, or with multiple=True.
Keep a link in order¶
Some links are kept in order, such as servers tried in turn. Mark the field ordered, and the
form shows its records as a numbered list to put in order:
class ConfigView(ModelView[Config]):
fields = [Config.name, RelationField(Config.proxies, ordered=True)]
The relationship needs an order of its own to keep. A link table with a serial id, read in the order of that id, is the usual way:
from sqlalchemy import Column, ForeignKey, Integer, Table
from sqlalchemy.orm import Mapped, relationship
config_proxies = Table(
"config_proxies",
Base.metadata,
Column("id", Integer, primary_key=True),
Column("config_id", ForeignKey("configs.id"), nullable=False),
Column("proxy_id", ForeignKey("proxies.id"), nullable=False),
)
class Config(Base):
...
proxies: Mapped[list[Proxy]] = relationship(
secondary=config_proxies, order_by=config_proxies.c.id
)
JSON columns¶
A JSON or JSONB column gets a JSONField. The form edits the document in a code box that
checks it as you type, and the record page shows it laid out, with each part folding. A document
that does not parse comes back on the form, with its line and column named and the text kept as it
was written.
A form from a schema¶
Settings kept as JSON are often described already, by a Pydantic model or a TypedDict. Give it to
the field as its schema, and the form asks for each part with the control its type calls for:
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from typing_extensions import NotRequired, TypedDict # typing's own on 3.12 and later
from adminsite.fields import JSONField
class Channel(TypedDict):
id: int
url: str
title: NotRequired[str]
class ShopSettings(BaseModel):
open: bool = True
free_shipping_over: Annotated[
float, Field(ge=0, description="Orders above this amount ship free.")
] = 50
payment_methods: list[Literal["card", "cash", "transfer"]] = ["card"]
exchange_rates: dict[Literal["USD", "EUR"], Annotated[float, Field(gt=0)]] = {}
announcement_channels: list[Channel] = []
class ShopView(ModelView[Shop]):
fields = [Shop.name, JSONField(Shop.settings, schema=ShopSettings)]
| In the schema | In the form |
|---|---|
bool |
A switch. |
int, float, Decimal |
A number input, with limits such as ge=0 checked. |
str, EmailStr, HttpUrl, date, datetime, time |
An input of that kind, with lengths and pattern checked. |
Literal[...] or an Enum |
A select, or the picker for more than 10 options or a list of them. |
| A list of plain values | A box with one value per line. |
| A model or a TypedDict inside | Its properties under its title. |
| A list of models or TypedDicts | A table whose rows can be added and removed. |
A dict |
A table of keys and values. |
| Anything else, such as a choice between two models | A code box for that part. |
Labels come from the property names, so free_shipping_over reads "Free shipping over", and a
property's description is the note under it. When the form is saved, Pydantic checks the document,
so your model's validators run, and each problem shows beside its input. What is saved is the
document as Pydantic writes it, with its defaults filled in.
A few things to know:
- A JSON Schema written as a dict works too. Each part's own field checks it then.
- Keys a stored document holds that the schema does not name are kept, unless the schema refuses them.
- A stored value the schema cannot hold, such as a list where it asks for an object, opens in the code box, so none of it is lost.
- The JSON API checks a document against the same schema.
- Titles and descriptions come from your schema, so adminsite does not translate them.
A schema for each record¶
When the rows of one table hold documents of different shapes, such as a settings table with a row
per setting, give a function that returns each record's schema, or None for the code box:
SCHEMAS: dict[str, type[BaseModel]] = {"delivery": Delivery, "maintenance": Maintenance}
def schema_for(setting: Setting) -> type[BaseModel] | None:
return SCHEMAS.get(setting.key)
class SettingView(ModelView[Setting]):
fields = [Setting.key, JSONField(Setting.value, schema=schema_for)]
On the form for a new record, the function gets an unsaved record holding what the form holds so far, and the document's part of the form is drawn again when the key changes.
Partial documents¶
An override stores only what it changes, such as a customer group that gets free shipping whatever
the shop's own settings say. With partial=True each property may be left unset:
class CustomerGroupView(ModelView[CustomerGroup]):
fields = [
CustomerGroup.name,
JSONField(CustomerGroup.overrides, schema=ShopSettings, partial=True),
]
The form shows the properties the document sets, and Add a field sets another. A property left unset is left out of the document.
Lists of values¶
A Postgres ARRAY column gets a ListField. The form takes one value per line, each read by the
field its type calls for, and the other pages show the values separated by commas. A JSON column
holding a list works the same way, on any database:
from adminsite.fields import IntegerField, ListField
class ProductView(ModelView[Product]):
fields = [
Product.name,
ListField(Product.keywords),
ListField(Product.sizes, item=IntegerField("sizes")),
]
Computed values¶
ComputedField shows a value the view works out from the record, in the list, on the record page
and in the export:
from adminsite.fields import ComputedField
def capacity(product: Product) -> str:
return f"{len(product.slots)}/{product.limit}"
class ProductView(ModelView[Product]):
fields = [
Product.name,
Product.price,
ComputedField("capacity", capacity, needs=[Product.slots]),
]
needs names what the function reads, so it is loaded with the page instead of once per row. A
computed value is shown, and never written, sorted or filtered.
A value from a query¶
A count or a sum over related rows needs a query. Give load instead of a function: an async
function that gets the session and every record on the page, and answers with each record's value
by its primary key. It runs once for the whole page:
from collections.abc import Sequence
from sqlalchemy import func, select
from adminsite.database import SessionAdapter
async def member_counts(
session: SessionAdapter, groups: Sequence[Group]
) -> dict[int, int]:
rows = await session.execute(
select(Member.group_id, func.count())
.where(Member.group_id.in_([group.id for group in groups]))
.group_by(Member.group_id)
)
return {group_id: count for group_id, count in rows.all()}
class GroupView(ModelView[Group]):
fields = [Group.name, ComputedField("members", load=member_counts, default=0)]
A record the answer leaves out gets default, here 0 for a group with no members.
Form-only inputs¶
A form can hold an input that is not a column, such as a password to hash or settings kept in
another table. Give its field form_only=True. Its value reaches before_save and after_save in
context.values, and your hooks store it where it belongs. No other page shows it.
A password¶
PasswordField is a form-only input that is never filled in. On a record that exists, leaving it
empty keeps the password, and password is then not in context.values:
from adminsite import RefusedError, SaveContext
from adminsite.fields import PasswordField
class UserView(ModelView[User]):
fields = [User.email, PasswordField("password", required=True)]
async def before_save(self, context: SaveContext[User]) -> None:
password = context.values["password"].get()
if password is None:
return
if len(password) < 12:
raise RefusedError("Use 12 characters or more.", field="password")
context.values[User.password_hash].set(hash_password(password))
required holds for a new record. The audit log keeps *** for a password, and the
JSON API takes one and never sends it back.
A value kept in another table¶
A form-only field starts empty. form_only_values fills it in from somewhere else. It gets the
session and the record, or None on the form for a new one:
from typing import Any
from sqlalchemy import delete, select
from starlette.requests import Request
from adminsite.database import SessionAdapter
from adminsite.fields import JSONField
class GroupView(ModelView[Group]):
fields = [Group.name, JSONField("settings", form_only=True)]
async def form_only_values(
self, session: SessionAdapter, record: Group | None, *, request: Request
) -> dict[str, Any]:
if record is None:
return {}
rows = await session.scalars(
select(GroupSetting).where(GroupSetting.group_id == record.id)
)
return {"settings": {row.name: row.value for row in rows}}
async def after_save(self, context: SaveContext[Group]) -> None:
settings = context.values["settings"].get()
if settings is None:
return
group = context.record
await context.session.execute(
delete(GroupSetting).where(GroupSetting.group_id == group.id)
)
for name, value in settings.items():
await context.session.add(
GroupSetting(group_id=group.id, name=name, value=value)
)
after_save runs in the save's transaction, once a new group has its id, so a refusal undoes the
rows and the record together.
A value that depends on the record¶
A field's display(value) sees only the value, but an amount reads differently per currency.
Override text_for, which gets the record too:
from decimal import Decimal
from adminsite.fields import DecimalField
class Money(DecimalField):
def text_for(self, record: Order, value: Decimal | None) -> str:
if value is None:
return ""
return f"{value:,.2f} {record.currency}"
The list, the record page and the export all show a value through text_for, which falls back to
display.
HTML in a cell¶
A cell is escaped text. Return Html where it is meant to be markup, such as a link to another
system:
from adminsite import Html, Link
from adminsite.fields import ComputedField
def tracking(order: Order) -> Html:
return Html('<a class="link" href="../{}">Track</a>').format(order.tracking_url)
class OrderView(ModelView[Order]):
fields = [
Order.id,
Link(Order.customer, Customer.name),
ComputedField("tracking", tracking, needs=[Order.tracking_url]),
]
Html keeps its own text as markup and escapes everything put in with format or %, so a value
from the database cannot carry markup into the page. Write the markup yourself and pass the values
through format. The CSV export and the JSON API get the same cell without its tags.
Files and pictures¶
A file field keeps the upload in a storage and its key in a string column:
from adminsite.fields import FileField, ImageField
from adminsite.files import LocalStorage
uploads = LocalStorage("uploads")
class ProductView(ModelView[Product]):
fields = [
Product.name,
ImageField(Product.photo, storage=uploads),
FileField(
Product.datasheet,
storage=uploads,
accept=".pdf",
max_size=20 * 1024 * 1024,
),
]
The form shows the current file and a box to remove it, and the list shows a thumbnail or a link.
storage says where the files go, accept which types are taken and max_size the largest file,
as FileField says. ImageField takes PNG, JPEG, GIF and WebP, and
checks each file's first bytes. It refuses SVG, which can carry script.
A file is stored under a key such as 2026/09/k3j9x2-datasheet.pdf, so make the column
String(255). A new upload replaces the old file once the save has committed, and deleting a
record keeps its files.
Files are served through the admin, behind the sign in. If your app already serves the folder, say where, and links point there:
uploads = LocalStorage("uploads", url_prefix="https://cdn.example.com/uploads")
To keep files somewhere else, such as S3, subclass FileStorage: save stores an upload and
returns its key, delete removes one, and url or response say how a file is fetched.
File fields do not work inside inlines yet.
Your own field type¶
Subclass Field with the type its column holds, and say how to show and read the value:
from adminsite.exceptions import FieldValidationError
from adminsite.fields import Field
class PercentField(Field[float | None]):
widget = "number"
python_type = float
error_message = "Enter a percentage, for example 12.5."
def display(self, value: float | None) -> str:
return "" if value is None else f"{value:.1f}%"
def to_python(self, text: str) -> float:
number: float = super().to_python(text.rstrip("%"))
if not 0 <= number <= 100:
raise FieldValidationError(self.name, "Enter a value between 0 and 100.")
return number
Fields are dataclasses, so options of your own go after _: KW_ONLY, under
@dataclass(eq=False, repr=False), as the built-in kinds do. To use the field for every column of
a type, register it:
from adminsite.fields import default_registry
default_registry.register(Percentage, PercentField)
The input is drawn by its widget. Customizing the look shows how to
draw your own.