DEV Community

Cover image for SQLAlchemy One-to-Many Relationships
kai wen ng
kai wen ng

Posted on

SQLAlchemy One-to-Many Relationships

Suppose we have:

  • One Project
  • Many Property So the relationship is:
Project 1 ────────< Property
Enter fullscreen mode Exit fullscreen mode

The many side (Property) owns the foreign key pointing to the primary key of the one side (Project).

1. Foreign Key

For example:

class Project(Base):
    __tablename__ = "project"

    id = mapped_column(primary_key=True)


class Property(Base):
    __tablename__ = "property"

    id = mapped_column(primary_key=True)
    project_id = mapped_column(ForeignKey("project.id"))
Enter fullscreen mode Exit fullscreen mode

The database structure is conceptually:

project
-------
id (PK)
1
2
3


property
--------
id (PK)
project_id (FK)
101   1
102   1
103   1
104   2
Enter fullscreen mode Exit fullscreen mode

Here:

  • Project 1 has properties 101, 102, and 103
  • Project 2 has property 104

So:

Project 1 → many Property
Property 101 → one Project
Enter fullscreen mode Exit fullscreen mode

The foreign key is usually named something like:

project_id
Enter fullscreen mode Exit fullscreen mode

because it refers to project.id.

2. relationship()

The ForeignKey establishes the database-level relationship.
The relationship() establishes the ORM-level relationship.

For example:

class Project(Base):
    __tablename__ = "project"

    id = mapped_column(primary_key=True)

    properties = relationship(
        "Property",
        back_populates="project"
    )


class Property(Base):
    __tablename__ = "property"

    id = mapped_column(primary_key=True)
    project_id = mapped_column(ForeignKey("project.id"))

    project = relationship(
        "Project",
        back_populates="properties"
    )
Enter fullscreen mode Exit fullscreen mode

Now SQLAlchemy lets you navigate the relationship through Python attributes:

project.properties
Enter fullscreen mode Exit fullscreen mode

returns:

[Property(...), Property(...), Property(...)]
Enter fullscreen mode Exit fullscreen mode

while:

property.project
Enter fullscreen mode Exit fullscreen mode

returns:

Project(...)
Enter fullscreen mode Exit fullscreen mode

So the type relationship is:

Project.properties → list[Property]
Property.project   → Project
Enter fullscreen mode Exit fullscreen mode

back_populates

back_populates tells SQLAlchemy that these two relationships are two sides of the same relationship.

Project.properties
        
Property.project
Enter fullscreen mode Exit fullscreen mode

Because of this, SQLAlchemy can keep the in-memory object graph synchronised.

For example:

project = Project()
property = Property()

project.properties.append(property)
Enter fullscreen mode Exit fullscreen mode

and SQLAlchemy understands:

project.properties
        
property.project = project
        
property.project_id = project.id
Enter fullscreen mode Exit fullscreen mode

So when the project gets its primary key and the session flushes, SQLAlchemy can insert the corresponding project_id into property.

Important distinction

back_populates does not create the foreign key.
These have separate responsibilities:

ForeignKey     → database relationship
relationship() → ORM relationship
back_populates → links the two ORM relationship attributes
Enter fullscreen mode Exit fullscreen mode

3. Lazy Loading

By default, SQLAlchemy relationships are generally loaded lazily.
Suppose:

project = session.get(Project, 1)
Enter fullscreen mode Exit fullscreen mode

SQLAlchemy initially retrieves the project:

SELECT *
FROM project
WHERE id = 1;
Enter fullscreen mode Exit fullscreen mode

The properties have not necessarily been loaded yet.
When you do:

project.properties
Enter fullscreen mode Exit fullscreen mode

SQLAlchemy may then issue another query:

SELECT *
FROM property
WHERE project_id = 1;
Enter fullscreen mode Exit fullscreen mode

So:

Load Project
     ↓
Access project.properties
     ↓
Load Properties
Enter fullscreen mode Exit fullscreen mode

This is useful because you don't retrieve related data unnecessarily.
However, repeatedly doing this can cause the N+1 query problem.
For example:

projects = session.query(Project).all()

for project in projects:
    print(project.properties)
Enter fullscreen mode Exit fullscreen mode

Conceptually this can result in:

1 query → load all projects
N queries → load properties for each project
Enter fullscreen mode Exit fullscreen mode

So:

1 + N queries
Enter fullscreen mode Exit fullscreen mode

4. Eager Loading

Eager loading tells SQLAlchemy to retrieve the related objects as part of the initial loading process.
For example:

from sqlalchemy.orm import selectinload

projects = session.scalars(
    select(Project).options(
        selectinload(Project.properties)
    )
).all()
Enter fullscreen mode Exit fullscreen mode

SQLAlchemy can then load the projects and their properties using additional planned queries, typically something like:

SELECT *
FROM project;
Enter fullscreen mode Exit fullscreen mode

followed by:

SELECT *
FROM property
WHERE project_id IN (...);
Enter fullscreen mode Exit fullscreen mode

So instead of:

1 + N queries
Enter fullscreen mode Exit fullscreen mode

you can often get:

2 queries
Enter fullscreen mode Exit fullscreen mode

The main difference are:

Lazy loading  → related data loaded when accessed
Eager loading → related data loaded as part of the intended fetch operation
Enter fullscreen mode Exit fullscreen mode

Memory trade-off

Eager loading can increase memory usage because more ORM objects are materialised immediately.
However, the performance trade-off is more nuanced than simply:

Lazy   = more queries
Eager  = fewer queries
Enter fullscreen mode Exit fullscreen mode

The best strategy depends on:

  • number of related rows
  • query frequency
  • result-set size
  • whether relationships are actually needed
  • type of eager loading (joinedload, selectinload, etc.)

5. Cascade

This part of your notes needs the most correction.
Cascade controls what ORM operations on a parent are propagated to related objects.
For example:

properties = relationship(
    "Property",
    back_populates="project",
    cascade="all, delete-orphan"
)
Enter fullscreen mode Exit fullscreen mode

delete-orphan adds another rule:
A child that is no longer associated with its parent is considered an orphan and will be deleted.

all is effectively a shorthand for several cascade behaviours, including:

save-update
merge
refresh-expire
expunge
delete
Enter fullscreen mode Exit fullscreen mode

More on that at: https://docs.sqlalchemy.org/en/21/orm/cascades.html

SQLAlchemy's default save-update cascade generally causes related transient objects to be pulled into the session when they are associated through the relationship.

For example:

project = Project()

property = Property()

project.properties.append(property)

session.add(project)
Enter fullscreen mode Exit fullscreen mode

Because of the relationship cascade, the property can also become part of the session.
Then:

session.commit()
Enter fullscreen mode Exit fullscreen mode

can persist both objects.
Conceptually:

session.add(project)
       ↓
Project tracked
       ↓
Property associated through relationship
       ↓
Property also tracked
       ↓
INSERT Project
INSERT Property
Enter fullscreen mode Exit fullscreen mode

Overall...

A useful way to remember the four concepts is:
| Concept | Responsibility |
| ---------------- | -------------------------------------------- |
| ForeignKey | Defines the relationship in the database |
| relationship() | Defines the relationship in the ORM |
| back_populates | Connects the two ORM sides |
| cascade | Defines how certain ORM operations propagate |
| Lazy loading | Load relationship when accessed |
| Eager loading | Load relationship as part of the query |

One additional distinction worth remembering is that ORM cascade and database-level ON DELETE CASCADE are different mechanisms. SQLAlchemy can delete child objects itself, while the database can also be configured to perform cascading deletes via the foreign key.

So the overall structure is:

                 DATABASE
                     │
            ForeignKey(project_id)
                     │
                     ▼
Project 1 ─────────< Property
   │                    │
   │ relationship()     │ relationship()
   │                    │
   ▼                    ▼
properties            project
   │                    │
   └──── back_populates┘
            │
            ▼
         cascade
            │
            ▼
   controls ORM operation
       propagation
Enter fullscreen mode Exit fullscreen mode

Useful Reading & References:
https://learn.miguelgrinberg.com/product/sqlalchemy2

Top comments (0)