Skip to content

Models & Fields

Models are the central building blocks of Ferro. They define your schema in plain Python type hints, validate data with Pydantic, and are mapped to database tables by the Rust engine.

Defining a Model

Inherit from ferro.Model and declare fields with standard type annotations:

from datetime import datetime
from typing import Annotated

from ferro import BackRef, Field, ForeignKey, Model, Relation, connect, engines, transaction


class Author(Model):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    email: str = Field(unique=True)
    posts: Relation[list["Post"]] = BackRef()


class Post(Model):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    body: str
    published: bool = False
    created_at: datetime = Field(default_factory=datetime.now)
    author: Annotated[Author, ForeignKey(related_name="posts")]
from datetime import datetime
from typing import Annotated

from ferro import BackRef, Field, ForeignKey, Model, Relation, connect, engines


class Author(Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    name: str
    email: Annotated[str, Field(unique=True)]
    posts: Relation[list["Post"]] = BackRef()


class Post(Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    title: str
    body: str
    published: bool = False
    created_at: Annotated[datetime, Field(default_factory=datetime.now)]
    author: Annotated[Author, ForeignKey(related_name="posts")]

Every Ferro model is a full Pydantic BaseModel, so validation, serialization (model_dump(), model_dump_json()), and model_config all work as you'd expect.

Declaration Styles

Ferro's Field() merges database options (primary_key, unique, index, ...) with Pydantic's validation options in a single call. You can attach it in two equivalent ways:

Put defaults and options on the assignment side — the classic Pydantic style:

from decimal import Decimal

from ferro import Field, Model


class Product(Model):
    id: int | None = Field(default=None, primary_key=True)
    slug: str = Field(unique=True, index=True)
    name: str = Field(max_length=200, description="Display name")
    price: Decimal = Field(ge=0, decimal_places=2)

Keep the plain type on the left and pass Field(...) inside typing.Annotated:

from decimal import Decimal
from typing import Annotated

from ferro import Field, Model


class Product(Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    slug: Annotated[str, Field(unique=True, index=True)]
    name: Annotated[str, Field(max_length=200, description="Display name")]
    price: Annotated[Decimal, Field(ge=0, decimal_places=2)]

Both produce identical schemas — every example in these docs shows both styles in tabs; pick one and stay consistent within a project. The Annotated style keeps the bare type visible at a glance and is also how forward relationships are declared (Annotated[Author, ForeignKey(...)] — see Relationships).

Advanced: FerroField

For a type-first style that carries only database flags (no Pydantic validation kwargs), you can attach ferro.FerroField(...) as Annotated metadata instead: id: Annotated[int, FerroField(primary_key=True)]. It accepts the same database options as Field().

Field Types

Ferro maps Python annotations to backend-appropriate column types:

Python type SQLite PostgreSQL Notes
int INTEGER INTEGER / BIGINT
str TEXT TEXT Override with db_type=varchar(n)
bool BOOLEAN (0/1) BOOLEAN
float DOUBLE DOUBLE PRECISION
datetime.datetime TEXT (ISO 8601) TIMESTAMP / TIMESTAMPTZ
datetime.date TEXT (ISO 8601) DATE
datetime.time TEXT (ISO 8601) TIME
uuid.UUID TEXT (36 chars) UUID
decimal.Decimal NUMERIC NUMERIC For money and other exact values
bytes BLOB BYTEA
enum.Enum TEXT native ENUM See note below
dict / list TEXT (JSON) JSONB (default) db_type="json" opts out

Overriding the column type with db_type

When the inferred type isn't what you want — for example varchar(255) instead of unbounded TEXT — pass a db_type token:

from ferro import Field, Model, varchar


class Document(Model):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(db_type=varchar(255))
    body: str = Field(db_type="text")
from typing import Annotated

from ferro import Field, Model, varchar


class Document(Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    title: Annotated[str, Field(db_type=varchar(255))]
    body: Annotated[str, Field(db_type="text")]

Valid values are the DbTypeToken literals — "text", "smallint", "int", "bigint", "uuid", "timestamp", "timestamptz", "date", "time", "json", "jsonb" — plus varchar(n) built with ferro.varchar. Prefer varchar(255) over the raw string "varchar(255)" so type checkers see a deliberate vocabulary choice. The override is validated against the Python annotation at class-definition time — on both declaration styles and on raw json_schema_extra declarations alike — so an incompatible combination fails immediately.

Enum storage

enum.Enum fields are stored as text on SQLite and as named native ENUM types on PostgreSQL (via the Alembic bridge). For closed-domain string columns with a DB-side CHECK constraint, combine db_type with db_check=True.

JSON storage: json and jsonb

Json-family fields — dict, list (any element type, including list[SomeNestedModel]), and nested Pydantic models — store as JSON documents. On PostgreSQL the default is JSONB (binary storage, GIN-indexable, containment operators — the type PostgreSQL itself recommends); declare db_type="json" per column to opt out into plain json when you need representation fidelity:

from typing import Any

from pydantic import BaseModel

from ferro import Field, Model


class Payment(BaseModel):
    currency: str
    amount: int


class Transaction(Model):
    id: int | None = Field(default=None, primary_key=True)
    raw_payload: dict[str, Any] = Field()                # JSONB (the default)
    line_items: list[Payment] = Field(db_type="jsonb")   # explicit, same as default
    audit_note: dict[str, str] = Field(db_type="json")   # plain-json opt-out
from typing import Annotated, Any

from pydantic import BaseModel

from ferro import Field, Model


class Payment(BaseModel):
    currency: str
    amount: int


class Transaction(Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    raw_payload: Annotated[dict[str, Any], Field()]                # JSONB (the default)
    line_items: Annotated[list[Payment], Field(db_type="jsonb")]  # explicit, same as default
    audit_note: Annotated[dict[str, str], Field(db_type="json")]  # plain-json opt-out

Declaring db_type="jsonb" on anything outside the json family (an int, a set, ...) fails at class-definition time.

SQLite lowering. On SQLite both tokens store as plain JSON text — one model definition runs on both backends, and switching between json and jsonb never changes SQLite behavior.

Round-trip contract is value equality, not byte fidelity. PostgreSQL's jsonb does not preserve dict key order (keys come back sorted), duplicate keys, or whitespace — that's inherent to the storage type, not Ferro. Values compare equal with Python == (dict equality ignores order), but if you iterate keys and need insertion order — or need the stored text byte-for-byte — opt out with db_type="json".

Migrating between json and jsonb is a declaration edit. With migrate_updates enabled, a storage change emits exactly one ALTER TABLE ... TYPE ... USING ... on PostgreSQL in either direction (existing values survive the cast). The Alembic bridge renders the same DDL. On SQLite the same edit is a no-op.

Upgrading from a pre-JSONB-default release

Databases created before the JSONB default carry plain json columns for derived dict/list fields. On your first connect with migrate_updates=True, each such column upgrades in place with one ALTER ... TYPE jsonb USING ... — values survive, but dict key order stops being preserved. To keep a column as plain json, add db_type="json" to its declaration before upgrading; the diff is then zero operations.

Querying inside JSONB

Containment and path operators (@>, ->, #>>) and GIN index declarations are not in the query builder yet — they're planned follow-ups. Today JSONB gives you honest storage and round-tripping; query the column as a whole.

Field Options

Database options accepted by Field() (and FerroField()):

Option Type Default Description
primary_key bool False Mark this column as the table's primary key.
autoincrement bool \| None None Database-generated values. Inferred True for integer primary keys; pass False for manual integer keys.
unique bool False Single-column uniqueness constraint. For multi-column uniqueness see Composite Constraints.
index bool False Create a non-unique index on this column.
nullable "infer" \| bool "infer" Column nullability. "infer" follows whether the annotation allows None; True/False force it (useful when the Python type diverges from the column on purpose).
default any Pydantic default value (also used to backfill when migrate_updates adds a NOT NULL column).
default_factory callable Pydantic default factory, e.g. default_factory=datetime.now.
db_type DbType \| None None Column-type override (see above).
db_check bool False Emit a DB-side CHECK constraint for closed-domain types; only valid with db_type.

On top of these, every Pydantic validation option works in the same call: min_length, max_length, pattern, gt, ge, lt, le, multiple_of, decimal_places, description, and the rest — see Pydantic's Field docs.

Primary Keys

Auto-increment

Integer primary keys auto-increment by default. Declare them as int | None with default=None so unsaved instances can exist before the database assigns an ID:

from ferro import Field, Model


class User(Model):
    id: int | None = Field(default=None, primary_key=True)
    name: str
from typing import Annotated

from ferro import Field, Model


class User(Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    name: str

After await user.save() (or User.create(...)), the generated ID is written back onto the instance.

Manual

For natural keys, or integer keys you assign yourself, disable auto-increment:

from ferro import Field, Model


class Country(Model):
    code: str = Field(primary_key=True)  # natural key, e.g. "US"
    name: str


class LegacyRecord(Model):
    id: int = Field(primary_key=True, autoincrement=False)
    payload: str
from typing import Annotated

from ferro import Field, Model


class Country(Model):
    code: Annotated[str, Field(primary_key=True)]  # natural key, e.g. "US"
    name: str


class LegacyRecord(Model):
    id: Annotated[int, Field(primary_key=True, autoincrement=False)]
    payload: str

UUID primary keys

Generate UUIDs client-side with default_factory:

import uuid

from ferro import Field, Model


class Order(Model):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
    total_cents: int = 0
import uuid
from typing import Annotated

from ferro import Field, Model


class Order(Model):
    id: Annotated[uuid.UUID, Field(default_factory=uuid.uuid4, primary_key=True)]
    total_cents: int = 0

On PostgreSQL this is a native UUID column; on SQLite it is stored as a 36-character string.

Composite Constraints

Composite uniques

When a row must be unique across several columns together (e.g. one membership per (user_id, org_id) pair), per-column unique=True is not what you want. Declare the ClassVar __ferro_composite_uniques__ instead:

import uuid
from typing import ClassVar

from ferro import Field, Model


class OrgMembership(Model):
    __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = (
        ("user_id", "org_id"),
    )

    id: int | None = Field(default=None, primary_key=True)
    user_id: uuid.UUID
    org_id: uuid.UUID
import uuid
from typing import Annotated, ClassVar

from ferro import Field, Model


class OrgMembership(Model):
    __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = (
        ("user_id", "org_id"),
    )

    id: Annotated[int | None, Field(default=None, primary_key=True)]
    user_id: uuid.UUID
    org_id: uuid.UUID
  • Each inner tuple lists database column names (for a ForeignKey field named user, use the shadow column user_id).
  • Declare several tuples for several independent composite uniques.
  • Unknown column names raise at model registration time.

NULL semantics

SQL UNIQUE treats NULL values as distinct from each other, so nullable members of a composite unique can admit multiple rows that differ only by NULL. Prefer NOT NULL columns in composite uniques when you need strict "at most one row per pair" semantics.

Composite indexes

For non-unique multi-column indexes — read-path optimization on common filter combinations like (user_id, created_at) — declare __ferro_composite_indexes__ with the same shape:

from datetime import datetime
from typing import ClassVar

from ferro import Field, Model


class Comment(Model):
    __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = (
        ("user_id", "created_at"),
    )

    id: int | None = Field(default=None, primary_key=True)
    user_id: int
    created_at: datetime
    body: str
from datetime import datetime
from typing import Annotated, ClassVar

from ferro import Field, Model


class Comment(Model):
    __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = (
        ("user_id", "created_at"),
    )

    id: Annotated[int | None, Field(default=None, primary_key=True)]
    user_id: int
    created_at: datetime
    body: str

Validation mirrors composite uniques: at least two columns per tuple, columns must exist, and order is preserved (it matters for leftmost-prefix optimization). For single-column indexes use Field(index=True). Declaring the same ordered tuple in both __ferro_composite_uniques__ and __ferro_composite_indexes__ emits a UserWarning and drops the redundant index.

Both ClassVars flow through to Alembic autogenerate as matching UniqueConstraint / Index objects.

Table checks

When a rule spans several columns on the same row — "at most one of these two sides may be set" — a per-column option is not enough. Declare a table check: a named predicate over this model's own columns in the ClassVar __ferro_checks__.

Each entry is Check(suffix, predicate). You supply a short suffix (for example at_most_one_side); ferro derives the live constraint name ck_<table>_<suffix> (here, ck_pair_at_most_one_side). The predicate is a ferro lambda with a lowercase-singular parameter named after the model (pair for Pair), using the same style as where() filters — not a SQL string.

The predicate is the root-column where() dialect: comparisons, == None / != None (including on a forward foreign key or its shadow *_id column), & / | / ~, .in_(), .like(), and column-to-column (pair.left != pair.right). Values are literals inlined into the CHECK — not bind parameters. Relation traversal, .exists(), aggregates, and closed-over variables fail at class definition.

For a single-column closed domain (for example a StrEnum stored as text with IN ('admin', 'user')), use Field(db_check=True) instead — that emits ck_<table>_<col> and is a different artifact from table checks.

class Pair(Model):
    __ferro_checks__: ClassVar[tuple[Check, ...]] = (
        Check(
            "at_most_one_side",
            lambda pair: (pair.left == None) | (pair.right == None),  # noqa: E711
        ),
    )

    id: int | None = Field(default=None, primary_key=True)
    left: str | None = None
    right: str | None = None
class Pair(Model):
    __ferro_checks__: ClassVar[tuple[Check, ...]] = (
        Check(
            "at_most_one_side",
            lambda pair: (pair.left == None) | (pair.right == None),  # noqa: E711
        ),
    )

    id: Annotated[int | None, Field(default=None, primary_key=True)]
    left: str | None = None
    right: str | None = None
  • Each Check suffix must be a lowercase identifier ([a-z][a-z0-9_]*), unique per model, and must not collide with a column-check name (ck_<table>_<col>).
  • Unknown columns, duplicate suffixes, and unsupported predicate forms raise at model registration time.
  • Table checks are emitted inline in CREATE TABLE on both PostgreSQL and SQLite. See migrations for how they reconcile on existing tables.

A violating insert raises CheckViolationError (PostgreSQL also sets exc.constraint to the live ck_* name):

        try:
            await Pair.create(left="both", right="set")
        except CheckViolationError:
            pass  # ck_pair_at_most_one_side rejected the row
        else:
            raise AssertionError("expected CheckViolationError")

Custom table names

By default a model's table is its class name lowercased: class User → table user. Set __ferro_table__ in the class body to choose the table name yourself:

from typing import ClassVar

from ferro import Field, Model


class User(Model):
    __ferro_table__: ClassVar[str] = "app_users"

    id: int | None = Field(default=None, primary_key=True)
    email: str = Field(unique=True)
from typing import Annotated, ClassVar

from ferro import Field, Model


class User(Model):
    __ferro_table__: ClassVar[str] = "app_users"

    id: Annotated[int | None, Field(default=None, primary_key=True)]
    email: Annotated[str, Field(unique=True)]

Both declare a User model stored in app_users — every generated statement follows it:

CREATE TABLE IF NOT EXISTS "app_users" (
  "email" varchar NOT NULL,
  "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_app_users_email" ON "app_users" ("email");

Relationships follow it too: a ForeignKey to User renders REFERENCES "app_users" ("id"), and default many-to-many join tables are named from the source model's table name and the relationship's field name — a tags relation on a model stored in wiki_pages gets the join table wiki_pages_tags — with join columns (wiki_pages_id, wiki_tags_id) following both participants' configured tables.

__ferro_table__ applies only to the class that declares it — a subclass gets its own default (classname.lower()) unless it declares its own. The name must be a 1–63 character identifier ([A-Za-z_][A-Za-z0-9_]*).

Two distinct models cannot share one table: defining a second model that resolves to an already-claimed table name raises immediately at class definition, naming both models. Redefining the same class (in a REPL or re-imported module) is fine.

Pydantic Validation

Ferro models are Pydantic models, so validation runs whenever an instance is constructed — including inside Model.create(...) and before bulk_create(...) hits the database:

from ferro import Field, Model


class Account(Model):
    id: int | None = Field(default=None, primary_key=True)
    username: str = Field(unique=True, min_length=3, max_length=50)
    email: str = Field(unique=True, pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
    age: int = Field(ge=0, le=150)
from typing import Annotated

from ferro import Field, Model


class Account(Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    username: Annotated[str, Field(unique=True, min_length=3, max_length=50)]
    email: Annotated[str, Field(unique=True, pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")]
    age: Annotated[int, Field(ge=0, le=150)]
from pydantic import ValidationError

try:
    await Account.create(username="ab", email="not-an-email", age=-1)
except ValidationError as exc:
    print(exc.error_count(), "validation errors — nothing was written")

Custom @field_validator / @model_validator methods and model_config settings (e.g. validate_assignment=True, str_strip_whitespace=True) work too, since they are plain Pydantic features.

Reusing Fields Across Models

Model subclasses cannot inherit fields

You cannot declare fields on a Model base class and inherit them in subclasses. Model's metaclass registers a table schema per concrete model class at class-creation time, so a "base model with shared columns" silently produces broken subclass defaults. Don't do this:

class TimestampedModel(Model):  # WRONG — do not inherit fields from a Model
    created_at: datetime = Field(default_factory=datetime.now)


class Article(TimestampedModel):  # broken defaults
    title: str

The supported pattern is a plain mixin (not a Model subclass) for shared behavior, with the fields declared on each concrete model:

from datetime import UTC, datetime

from ferro import Field, Model


def utcnow() -> datetime:
    return datetime.now(UTC)


class TimestampMixin:
    """Touch ``updated_at`` on every save. A plain class, not a Model."""

    async def save(self, **kwargs) -> None:
        self.updated_at = utcnow()
        await super().save(**kwargs)


class Note(TimestampMixin, Model):
    id: int | None = Field(default=None, primary_key=True)
    text: str
    created_at: datetime = Field(default_factory=utcnow)
    updated_at: datetime = Field(default_factory=utcnow)


class Task(TimestampMixin, Model):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    created_at: datetime = Field(default_factory=utcnow)
    updated_at: datetime = Field(default_factory=utcnow)
from datetime import UTC, datetime
from typing import Annotated

from ferro import Field, Model


def utcnow() -> datetime:
    return datetime.now(UTC)


class TimestampMixin:
    """Touch ``updated_at`` on every save. A plain class, not a Model."""

    async def save(self, **kwargs) -> None:
        self.updated_at = utcnow()
        await super().save(**kwargs)


class Note(TimestampMixin, Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    text: str
    created_at: Annotated[datetime, Field(default_factory=utcnow)]
    updated_at: Annotated[datetime, Field(default_factory=utcnow)]


class Task(TimestampMixin, Model):
    id: Annotated[int | None, Field(default=None, primary_key=True)]
    title: str
    created_at: Annotated[datetime, Field(default_factory=utcnow)]
    updated_at: Annotated[datetime, Field(default_factory=utcnow)]

The few repeated field lines are deliberate: each concrete model owns its full schema, and the mixin contributes behavior only. For complete worked examples, see Timestamps and Soft Deletes.

See Also