Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

So far, our database contained a single table (users); however
real applications almost always involve multiple tables that are related to each other !

Examples:

This is where relationships between tables come into play.


Why relationships matter

Without relationships, all data would live in a single table, leading to:

Relational databases solve this by splitting data into multiple tables and linking them together using foreign keys.

A foreign key is simply a column that references the primary key of another table.


→ One-to-many relationships

The most common relationship is one-to-many.

Example:
👉 One User can have many Posts, but each Post belongs to exactly one User.


Database view

Conceptually, this looks like:

The user_id column is what links a post to its author.


Defining a relationship in SQLModel

With SQLModel, we explicitly describe both:

  1. the foreign key column, and

  2. the Python relationship.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from sqlmodel import SQLModel, Field, Relationship

class User(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str

    posts: list["Post"] = Relationship(back_populates="user")


class Post(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    content: str

    user_id: int = Field(foreign_key="user.id")
    user: User = Relationship(back_populates="posts")

Key ideas:

This allows you to navigate relationships naturally in Python:

post.user.name
user.posts

Many-to-one vs one-to-many

Note that:

In our example:

SQLModel (and SQLAlchemy) require you to define both sides explicitly if you want bidirectional access.


→ Many-to-many relationships

Some relationships are many-to-many.

Example:
👉 A student can attend many courses, and a course can have many students.

Relational databases handle this using an association table (also called a junction table).


Association table concept

Instead of linking students directly to courses, we introduce a third table:

Each row in enrollments represents one association (one student in one course).


SQLModel approach

In SQLModel, the association table is usually modeled explicitly:

1
2
3
class Enrollment(SQLModel, table=True):
    student_id: int = Field(foreign_key="student.id", primary_key=True)
    course_id: int = Field(foreign_key="course.id", primary_key=True)

Then referenced from both sides:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Student(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str

    courses: list["Course"] = Relationship(
        back_populates="students",
        link_model=Enrollment
    )


class Course(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str

    students: list[Student] = Relationship(
        back_populates="courses",
        link_model=Enrollment
    )

This may look verbose, but it gives you:


By default, relationships are lazy-loaded:

This is efficient, but it has consequences in APIs:

In practice, APIs often:


Relationships and API design

Relationships strongly influence how you design your API:

There is no single correct answer — it depends on:

A common rule of thumb:

Store relationships in the database,
but expose them explicitly and deliberately in the API.


→ A lighter approach (no Relationship)

The Relationship() approach is powerful, but there is a simpler alternative.

When you just need to query data across tables, you can write explicit select() queries — no Relationship attributes, no back_populates.

This is closer to raw SQL, and often more readable for API code.

We’ll use a chat-app model as a running example:


The models (no Relationship)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from sqlmodel import SQLModel, Field

class User(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str

class Room(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str

class Message(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    content: str
    user_id: int = Field(foreign_key="user.id")
    room_id: int = Field(foreign_key="room.id")

class Subscription(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    user_id: int = Field(foreign_key="user.id")
    room_id: int = Field(foreign_key="room.id")

Foreign keys are declared, but there are no Relationship() attributes — we navigate the data entirely through queries.


Fetch by primary key

The simplest lookup — get one row by its id:

room = session.get(Room, room_id)

Equivalent to SELECT * FROM room WHERE id = room_id.
Returns None if not found.


Filtered queries

Use .where() to filter rows:

# get all subscriptions for a given user
subs = session.exec(
    select(Subscription).where(Subscription.user_id == user_id)
).all()

# get one user by name (.first() returns None if no match)
user = session.exec(
    select(User).where(User.name == username)
).first()

.all() returns a list; .first() returns the first match or None.


Joining two tables in a select

To retrieve data from two tables at once, pass both models to select() and use .where() to express the join condition:

1
2
3
4
5
6
messages = session.exec(
    select(Message, User)
    .where(Message.room_id == room_id)
    .where(Message.user_id == User.id)   # the JOIN condition
    .order_by(Message.created_at)
).all()

Each result is a (Message, User) tuple, so you can pull fields from both:

result = [
    {**dict(msg), "username": user.name}
    for msg, user in messages
]

This is the SQLModel equivalent of:

SELECT message.*, user.*
FROM message JOIN user ON message.user_id = user.id
WHERE message.room_id = :room_id
ORDER BY message.created_at

Working with a junction table directly

For a many-to-many (here: users ↔ rooms via Subscription), you can query the junction table without any Relationship attribute:

# is this user already subscribed?
existing = session.exec(
    select(Subscription)
    .where(Subscription.user_id == user_id)
    .where(Subscription.room_id == room_id)
).first()

# subscribe (if not already)
if not existing:
    session.add(Subscription(user_id=user_id, room_id=room_id))
    session.commit()

# unsubscribe
if existing:
    session.delete(existing)
    session.commit()

# get all room IDs a user is subscribed to
room_ids = [
    s.room_id for s in session.exec(
        select(Subscription).where(Subscription.user_id == user_id)
    ).all()
]

ORM style vs query style — comparison

ORM style (Relationship)Query style (explicit select)
navigationuser.posts, post.usersession.exec(select(...).where(...))
joinautomatic (lazy load)explicit .where(A.fk == B.id)
many-to-manynavigate via student.courses / course.studentsdirect queries on junction table
verbosityless in Python codemore explicit, closer to SQL
transparencymagic behind the scenesevery query is visible

Both approaches are valid — pick the one that matches your team’s comfort with SQL and the complexity of your data access patterns.

For simple APIs, the query style is often easier to reason about: you always know exactly what SQL is being executed.


Key takeaways