Models & Fields¶
Models are the central building blocks of Ferro. They define your data schema in Python and are automatically mapped to database tables by the Rust engine.
Defining a Model¶
To create a model, inherit from ferro.Model. Models use standard Python type hints, leveraging Pydantic V2 for validation and serialization.
Basic model example¶
Field Types¶
Ferro supports a wide range of Python types, automatically mapping them to the most efficient database types available in the Rust engine.
| Python Type | Database Type (General) | Notes |
|---|---|---|
int |
INTEGER |
|
str |
TEXT / VARCHAR |
|
bool |
BOOLEAN / INTEGER |
Stored as 0/1 in SQLite. |
float |
DOUBLE / FLOAT |
|
datetime |
DATETIME / TIMESTAMP |
Use datetime.datetime with timezone awareness. |
date |
DATE |
Use datetime.date. |
UUID |
UUID / TEXT |
Stored as a 36-character string if native UUID is unavailable. |
Decimal |
NUMERIC / DECIMAL |
Use decimal.Decimal for high-precision financial data. |
bytes |
BLOB / BYTEA |
Stored as binary data. |
Enum |
ENUM / TEXT |
Python enum.Enum (typically string-backed). |
dict / list |
JSON / JSONB |
Stored as JSON strings in SQLite. |
Field Constraints¶
Use ferro.Field for database constraints (primary key, unique, index, and Pydantic validation). Pydantic merges Field() the same way whether you attach it on the right-hand side of = or inside typing.Annotated[...]; Ferro reads the resulting FieldInfo and does not require you to know internal details.
Recommended patterns (pick one and stay consistent in a project):
Assignment pattern¶
Put defaults and Ferro options on the assignment side (classic Pydantic model field):
from decimal import Decimal
from ferro import Field, Model
class Product(Model):
sku: str = Field(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)
Annotation pattern¶
Keep the plain type on the left and pass Field(...) as Annotated metadata (all defaults and DB flags live inside Field):
from typing import Annotated
from decimal import Decimal
from ferro import Field, Model
class Product(Model):
sku: Annotated[str, Field(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)]
Advanced: FerroField in Annotated¶
For a type-first style without going through Field(), you can attach FerroField(...) as metadata. This is equivalent for Ferro’s database flags; you lose the single-call surface that combines Pydantic validation kwargs on Field.
from typing import Annotated
from decimal import Decimal
from ferro import FerroField, Model
class Product(Model):
sku: Annotated[str, FerroField(primary_key=True)]
slug: Annotated[str, FerroField(unique=True, index=True)]
price: Annotated[Decimal, FerroField(index=True)]
Constraint parameters¶
All of the above support the same database constraint parameters on Field / FerroField:
| Parameter | Type | Default | Description |
|---|---|---|---|
primary_key |
bool |
False |
Marks the field as the primary key for the table. |
autoincrement |
bool \| None |
None |
If True, the database generates values automatically. Defaults to True for integer primary keys. |
unique |
bool |
False |
Enforces a single-column uniqueness constraint on that column only. For uniqueness on a combination of columns, see Composite unique constraints below. |
index |
bool |
False |
Creates a database index for this column to improve query performance. |
nullable |
"infer" \| bool |
"infer" |
Controls Alembic Column.nullable emitted by get_metadata(). "infer" follows whether the Python annotation allows None; True / False force NULL / NOT NULL. |
Examples¶
Primary key:
# Pydantic-style (preferred in docs)
id: int = Field(primary_key=True)
sku: str = Field(primary_key=True) # natural key
For the Annotated[..., Field(...)] form, see the Annotation pattern above.
Autoincrement:
# Autoincrement is implied for integer primary keys
id: int = Field(primary_key=True)
# Explicit manual key (no autoincrement)
id: int = Field(primary_key=True, autoincrement=False)
Unique constraints:
# Pydantic-style (preferred in docs)
email: str = Field(unique=True)
slug: str = Field(unique=True, index=True)
Composite unique constraints¶
Sometimes a row should be unique across several columns together (for example one membership per (user_id, org_id) pair). That is a composite unique: in SQL this is typically expressed as UNIQUE (user_id, org_id) on the table, or an equivalent unique index on those columns.
Ferro does not use per-column Field(unique=True) (assignment or Annotated[..., Field(unique=True)]) or FerroField(unique=True) for that case—unique=True is only for a single column. Instead, set the typing.ClassVar __ferro_composite_uniques__ on your model (the base Model defines it as () so IDEs and type checkers know the hook exists; subclasses override when needed):
from typing import ClassVar
import uuid
from ferro import Field, Model
class OrgMembership(Model):
__ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = (
("user_id", "org_id"),
)
id: uuid.UUID | None = Field(default=None, primary_key=True)
user_id: uuid.UUID = Field()
org_id: uuid.UUID = Field()
- Each inner tuple lists database column names as they appear in the generated schema (the same names as your Pydantic fields for scalar columns; for
ForeignKey("user")useuser_idin the tuple). - You can list several groups for multiple composite uniques on one model.
- Invalid or unknown column names raise when the model is registered.
Null semantics (SQLite): With the default local SQLite engine, UNIQUE treats NULL as distinct from other NULL values for multi-column constraints unless columns are NOT NULL. Ferro maps nullability from your types and defaults like other fields; optional composite columns can therefore allow multiple rows that differ only by NULL in a nullable column. Prefer NOT NULL on composite members when you need strict “at most one row per pair” semantics. Other databases can differ; consult your backend's documentation when you target PostgreSQL or another backend with different unique/null behavior.
Wire format: Declarations use nested tuples in Python; the schema JSON sent to the Rust engine uses nested lists (ferro_composite_uniques) because JSON has no tuple type.
Many-to-many join tables: When you use ManyToMany(...) without a custom through table, Ferro creates a default join table with two foreign-key columns. That table automatically gets a composite unique on those two columns so the same link cannot be stored twice. If you already have duplicate rows in such a table, adding this constraint in a migration may require a data cleanup step first.
See also Schema management / migrations for how composite uniques appear in Alembic metadata.
Composite (non-unique) indexes¶
For multi-column non-unique indexes — useful for read-path optimization on common filter combinations like (tenant_id, role) or (user_id, created_at) — declare a typing.ClassVar named __ferro_composite_indexes__ as a tuple of tuples of column names. Validation rules mirror __ferro_composite_uniques__: each inner tuple must contain at least two columns, columns must exist on the model, and order is preserved (it matters for leftmost-prefix optimization). For single-column indexes, use Field(index=True).
from typing import ClassVar
from datetime import datetime
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
Index naming: idx_<table>_<col1>_<col2>..., capped at 63 characters with an _idx suffix on truncation (parallels _uq for composite uniques).
Out of scope: Partial indexes (WHERE ...), expression indexes (e.g., ON lower(email)), and included columns (INCLUDE (...)) remain hand-edits to generated migrations.
Overlap with composite uniques: Declaring the same ordered column tuple in both __ferro_composite_uniques__ and __ferro_composite_indexes__ emits a UserWarning at class-definition time and drops the redundant index entry (the unique constraint already provides an underlying index). Reordered tuples (("a","b") unique + ("b","a") index) are kept as distinct, since they serve different leftmost-prefix queries.
Default many-to-many reverse-direction index¶
By default, ManyToMany(...) injects a reverse-direction composite index on its synthesized join table — so queries from either side hit an index. To opt out (e.g., write-heavy join tables where the extra index cost is unwanted), pass reverse_index=False:
class Actor(Model):
movies: Relation[list["Movie"]] = ManyToMany(
related_name="actors",
reverse_index=False,
)
The kwarg lives on the forward ManyToMany(...) declaration; passing it to BackRef() raises TypeError.
Indexes:
# Pydantic-style (preferred in docs)
created_at: datetime = Field(index=True)
status: str = Field(index=True)
Pydantic Validation¶
With the assignment or Annotated[..., Field(...)] pattern, you can combine Ferro's database constraints with Pydantic's validation options in a single Field() call:
from ferro import Field, Model
class User(Model):
username: str = Field(
unique=True, # Ferro: database constraint
min_length=3, # Pydantic: validation
max_length=50,
description="Public handle"
)
age: int = Field(ge=0, le=150)
email: str = Field(
unique=True,
pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$'
)
All Pydantic Field parameters work as expected. See Pydantic's Field documentation for the complete list.
Model Configuration¶
Since Ferro models are Pydantic models, you can use the model_config attribute to control validation and serialization behaviors:
from pydantic import ConfigDict
from ferro import Model
class Product(Model):
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
extra='forbid'
)
sku: str
name: str
Internal Mechanics¶
Ferro uses a custom ModelMetaclass to bridge Python and Rust:
- Schema Capture: When you define a class, the metaclass inspects its fields and constraints.
- Rust Registration: The schema is serialized to a JSON-AST (including Ferro-specific keys such as
ferro_composite_uniqueswhen declared) and passed to the Rust core'sMODEL_REGISTRY. - Table Generation: When
auto_migrate=Trueis used orcreate_tables()is called, the Rust engine generates the appropriate SQLCREATE TABLEstatements.
This architecture allows Ferro to leverage Rust's performance for SQL generation and row hydration while maintaining a pure Python interface.
See Also¶
- Relationships - Foreign keys, one-to-many, many-to-many
- Queries - Fetching and filtering data
- Mutations - Creating, updating, and deleting records
- Identity Map - Understanding instance caching