Relationships¶
Ferro provides a robust system for connecting models, supporting standard relational patterns with zero-boilerplate reverse lookups and automated join table management.
Overview¶
Relationships in Ferro are lazy — data is never fetched until you explicitly request it. This prevents N+1 query problems and gives you fine-grained control over when database hits occur.
API Styles¶
Like scalar field constraints (assignment vs Annotated[..., Field(...)]), relationship metadata can be declared in two equivalent styles:
- Helper-style (
BackRef(),ManyToMany(...)): Recommended relationship helpers - Field-style (
Field(back_ref=True),Field(many_to_many=True, ...)): Lower-levelField()syntax
Collection relationships are typed with Relation[list[T]], which reflects the lazy query-like object returned at runtime.
Lazy Loading Behavior¶
Forward relations (accessing a ForeignKey):
Reverse/M2M relations (accessing the "other side"):
# Returns a Query object — no database hit yet
query = author.posts
# Chain filters before executing
published_posts = await author.posts.where(Post.published == True).all()
One-to-Many¶
The most common relationship type: a ForeignKey on the "child" model and a reverse-relation field on the "parent" model.
erDiagram
AUTHOR ||--o{ POST : writes
AUTHOR {
int id
string name
}
POST {
int id
string title
int author_id
}
Helper-style (with BackRef())¶
from typing import Annotated
from ferro import Model, ForeignKey, BackRef, Relation
class Author(Model):
id: int
name: str
posts: Relation[list["Post"]] = BackRef()
class Post(Model):
id: int
title: str
author: Annotated[Author, ForeignKey(related_name="posts")]
Field-style (with Field(back_ref=True))¶
from ferro import Model, ForeignKey, Field, Relation
class Author(Model):
id: int
name: str
posts: Relation[list["Post"]] = Field(back_ref=True)
class Post(Model):
id: int
title: str
author: Annotated[Author, ForeignKey(related_name="posts")]
You can also use Annotated with Field: posts: Annotated[Relation[list["Post"]], Field(back_ref=True)]
Shadow Fields¶
For every ForeignKey field (e.g., author), Ferro automatically creates a shadow scalar column and a matching Pydantic field named {field}_id (e.g., author_id). It holds the related row’s primary key value. You can read or filter on it like any other column:
Typing: The shadow field’s Python type follows the related model’s primary key annotation, wrapped as optional (| None) for ORM defaults (the value starts as None until you set the relation or the ID). If the parent uses UUID for its PK, author_id is UUID | None; if the parent uses int | None, the shadow field matches that shape.
Forward references: When the FK target is only a string or ForwardRef (e.g., Annotated["Author", ForeignKey(...)]) because the parent class is not defined yet, Ferro may start with a broad fallback union for the shadow field until the target class exists. After resolve_relationships() runs—which connect() calls for you, or which you can call explicitly in tests once every model is registered—the shadow type is reconciled to the real PK type and Pydantic’s schema is rebuilt so validation and serialization match the resolved model graph.
# Read the stored FK value (same logical type as the parent's PK)
post_author_id = post.author_id
# Filter — use the same value type as Author.id (integer PK example)
recent_posts = await Post.where(Post.author_id == 123).all()
# With a UUID (or other non-int) primary key on Author, compare using that type
# recent_posts = await Post.where(Post.author_id == author.id).all()
Nullable relations such as Annotated[Author | None, ForeignKey(...)] are supported: the inner target type is normalized so metadata and shadow columns behave consistently.
Usage Examples¶
# Create with relationship
author = await Author.create(name="Jane Doe")
post = await Post.create(title="Hello World", author=author)
# Access forward relation
post_author = await post.author # Returns Author instance
# Access reverse relation (returns Query)
author_posts = await author.posts.all()
# Filter reverse relation
published = await author.posts.where(Post.published == True).all()
recent = await author.posts.order_by(Post.created_at, "desc").limit(10).all()
One-to-One¶
A strict 1:1 link created by adding unique=True to a ForeignKey.
erDiagram
USER ||--|| PROFILE : has
USER {
int id
string username
}
PROFILE {
int id
int user_id
string bio
}
Declaration¶
from typing import Annotated
from ferro import Model, ForeignKey, BackRef
class User(Model):
id: int
username: str
profile: "Profile" = BackRef() # Note: singular relationships do not use Relation
class Profile(Model):
id: int
bio: str
user: Annotated[User, ForeignKey(related_name="profile", unique=True)]
Behavior¶
One-to-one relationships have special behavior on the reverse side:
- Forward:
await profile.userreturns a singleUserobject - Reverse:
await user.profilereturns a singleProfileobject (orNone), not aQuery
Ferro automatically calls .first() on the reverse side, so you don't need to manually execute the query.
Usage Examples¶
# Create with relationship
user = await User.create(username="alice")
profile = await Profile.create(user=user, bio="Software engineer")
# Access either direction
user_profile = await user.profile # Returns Profile instance or None
profile_user = await profile.user # Returns User instance
Many-to-Many¶
Defined using ManyToMany(...). Ferro automatically manages the hidden join table required for this relationship.
erDiagram
STUDENT }o--o{ COURSE : enrolls
STUDENT {
int id
string name
}
COURSE {
int id
string title
}
Helper-style (with ManyToMany() / BackRef())¶
from ferro import Model, ManyToMany, BackRef, Relation
class Student(Model):
id: int
name: str
courses: Relation[list["Course"]] = ManyToMany(related_name="students")
class Course(Model):
id: int
title: str
students: Relation[list["Student"]] = BackRef()
Field-style (with Field(...))¶
from ferro import Model, Field, Relation
class Student(Model):
id: int
name: str
courses: Relation[list["Course"]] = Field(many_to_many=True, related_name="students")
class Course(Model):
id: int
title: str
students: Relation[list["Student"]] = Field(back_ref=True)
Join Table¶
The Rust engine automatically creates a join table (e.g., student_courses) when models are initialized. The table contains foreign keys to both sides of the relationship.
You do not need to define a "through" model manually unless you need custom fields on the join table (e.g., enrollment date, grade).
Relationship Mutators¶
Many-to-many relationships provide specialized methods for managing links:
.add(*instances)¶
Create new links in the join table:
# Add single course
await student.courses.add(math_101)
# Add multiple courses
await student.courses.add(math_101, physics_202, chemistry_301)
.remove(*instances)¶
Remove specific links:
# Remove single course
await student.courses.remove(math_101)
# Remove multiple courses
await student.courses.remove(math_101, physics_202)
.clear()¶
Remove all links for the current instance:
Usage Examples¶
# Create records
student = await Student.create(name="Alice")
math = await Course.create(title="Mathematics")
physics = await Course.create(title="Physics")
# Add relationships
await student.courses.add(math, physics)
# Query with filters
math_students = await math.students.where(Student.name.like("A%")).all()
# Access from either side
student_courses = await student.courses.all()
course_students = await math.students.all()
# Remove relationships
await student.courses.remove(physics)
await student.courses.clear()
Advanced Patterns¶
Self-Referential Relationships¶
You can create relationships where a model references itself:
class Employee(Model):
id: int
name: str
manager: Annotated["Employee", ForeignKey(related_name="reports")] | None = None
reports: Relation[list["Employee"]] = BackRef()
# Usage
manager = await Employee.create(name="Jane")
employee = await Employee.create(name="John", manager=manager)
# Access
employee_manager = await employee.manager
manager_reports = await manager.reports.all()
Cascade Behavior¶
Configure what happens when related objects are deleted:
# Cascade delete (default for most databases)
author: Annotated[Author, ForeignKey(related_name="posts", on_delete="CASCADE")]
# Set to NULL
author: Annotated[Author, ForeignKey(related_name="posts", on_delete="SET NULL")]
# Restrict deletion
author: Annotated[Author, ForeignKey(related_name="posts", on_delete="RESTRICT")]
Indexing FK columns¶
Postgres does not auto-index foreign-key columns, so tenant-scoped tables that filter by a FK on every read should declare the index on the model:
from typing import Annotated
from ferro import Model, ForeignKey, FerroField
class Org(Model):
id: Annotated[int, FerroField(primary_key=True)]
class Project(Model):
id: Annotated[int, FerroField(primary_key=True)]
org: Annotated[Org, ForeignKey(related_name="projects", index=True)]
index=True requests a non-unique index on the shadow *_id column. Both
the Alembic autogen bridge and the Rust runtime DDL emitter produce the same
index name, idx_project_org_id, so schemas generated by either path are
identical (this is the cross-emitter DDL parity invariant — see
AGENTS.md).
unique=True already creates an implicit unique index, so combining
unique=True with index=True is redundant: index=True is silently dropped
and a UserWarning is raised at class-definition time.
Postgres: index creation locks the table¶
When you add index=True to an existing model and run the resulting Alembic
migration on a large Postgres table, the default CREATE INDEX takes an
ACCESS EXCLUSIVE lock and blocks reads and writes until the index is
built. For production tables with millions of rows, this is rarely
acceptable.
The standard mitigation is CREATE INDEX CONCURRENTLY, which Alembic
exposes through op.create_index(..., postgresql_concurrently=True) inside
an autocommit block:
def upgrade():
with op.get_context().autocommit_block():
op.create_index(
"idx_project_org_id",
"project",
["org_id"],
postgresql_concurrently=True,
)
Edit the auto-generated migration before running it. SQLite, MySQL, and new tables on any backend don't need this treatment.
See Also¶
- Models & Fields - Defining models and field types
- Queries - Filtering and fetching related data
- Mutations - Creating and updating with relationships