Choosing SQLAlchemy Relationship Loaders to Eliminate N+1 Queries
Learn how lazy, joined, subquery, and selectin loaders affect query count and performance, with a concrete example and practical verification steps.
10 Apr 2026, 20:02 UTC

The Problem: Hidden Extra Queries in a Simple List View
Imagine a Flask endpoint that returns a list of authors and, for each author, the titles of their books. The Author model has a books relationship defined as:
class Author(Base):
__tablename__ = 'authors'
id = Column(Integer, primary_key=True)
name = Column(String)
books = relationship('Book', back_populates='author')
class Book(Base):
__tablename__ = 'books'
id = Column(Integer, primary_key=True)
title = Column(String)
author_id = Column(Integer, ForeignKey('authors.id'))
author = relationship('Author', back_populates='books')
A naïve implementation might look like:
@app.route('/authors')
def list_authors():
authors = session.query(Author).all()
return [{'id': a.id, 'name': a.name, 'books': [b.title for b in a.books]} for a in authors]
When the template accesses a.books for each author, SQLAlchemy issues a separate SELECT for every author – the classic N+1 problem. With 10,000 authors you’d see 10,001 queries.
Thesis: Match the Loader to the Access Pattern
SQLAlchemy provides four main loader strategies that control when related rows are fetched:
- Lazy (default): loads the relationship on first access via a separate SELECT.
- Joinedload: adds a JOIN to the parent query, retrieving parent and child rows in one result set.
- Subqueryload: runs a second query that uses an IN clause with the parent primary keys.
- Selectinload: similar to subqueryload but often more efficient because it avoids duplicate rows in the IN list for many‑to‑many or large one‑to‑many collections.
The goal is to pick the loader that minimizes round‑trips without inflating memory or causing cartesian duplication.
Worked Example: Comparing Loaders on a One‑to‑Many Set
We’ll use a small script that creates 100 authors, each with 10 books, then measures how many SQL statements are emitted for each loader. The script runs against a local SQLite database; you need only read/write access to the file.
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship, joinedload, subqueryload, selectinload
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.event import listen
Base = declarative_base()
class Author(Base):
__tablename__ = 'authors'
id = Column(Integer, primary_key=True)
name = Column(String)
books = relationship('Book', back_populates='author')
class Book(Base):
__tablename__ = 'books'
id = Column(Integer, primary_key=True)
title = Column(String)
author_id = Column(Integer, ForeignKey('authors.id'))
author = relationship('Author', back_populates='books')
engine = create_engine('sqlite:///authors.db', echo=False)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# populate data
for i in range(100):
a = Author(name=f'Author {i}')
a.books = [Book(title=f'Book {i}-{j}') for j in range(10)]
session.add(a)
session.commit()
# helper to count queries
query_count = 0
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
global query_count
query_count += 1
listen(engine, 'after_cursor_execute', after_cursor_execute)
def run_loader(loader):
global query_count
query_count = 0
authors = session.query(Author).options(loader(Author.books)).all()
# trigger loading
for a in authors:
_ = a.books
return query_count
print('Lazy:', run_loader(lambda rel: None)) # lazy uses default
print('Joinedload:', run_loader(joinedload))
print('Subqueryload:', run_loader(subqueryload))
print('Selectinload:', run_loader(selectinload))
Run the script in a terminal with python loaders.py. You will see output similar to:
Lazy: 101 Joinedload: 1 Subqueryload: 2 Selectinload: 2
This shows that lazy loading produced 101 statements (one for the authors list plus one per author), while joinedload collapsed everything into a single statement. Subqueryload and selectinload each used two statements: one for the parents and one for the children.
Trade‑offs and When to Choose Each Loader
Joinedload
Best when:
- The relationship is usually accessed together with the parent (e.g., displaying a list of authors with their books).
- The child collection is small or bounded, so the joined result set stays narrow.
Limitations:
- Can produce very wide rows if the child collection is large, increasing memory usage and slowing the database.
- When you add filters on the child table, the JOIN may cause duplicate parent rows unless you use
contains_eageror apply the filter before the join.
Subqueryload vs Selectinload
Both issue a separate SELECT, but selectinload tends to generate fewer duplicate primary keys in the IN clause, making it faster for large one‑to‑many or many‑to‑many relationships. Use them when:
- The child collection is large or unbounded.
- You cannot afford the row‑width penalty of a JOIN.
- You need to filter on the parent only; the child filter can be applied in the second query.
Limitation:
- Two queries are still required, so latency may be higher than a single joined query if the database round‑trip cost is significant.
Lazy Loading
Keep lazy loading for relationships that are rarely accessed or when you want to avoid loading data you might not need (e.g., a rarely‑used audit trail). It is the safest default but must be guarded against N+1 patterns in hot code paths.
Practical Verification: Spotting N+1 in Your Application
Add a simple event listener to count queries during a test request:
from sqlalchemy import event
query_counts = []
@event.listens_for(engine, "after_cursor_execute")
def receive_after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
query_counts.append(statement)
# in a test case
query_counts.clear()
response = client.get('/authors')
print(f"Total SQL statements: {len(query_counts)}")
# optionally inspect the first few statements for patterns
for stmt in query_counts[:5]:
print(stmt[:200])
If the count grows linearly with the number of parents, you have an N+1 issue and should apply an appropriate loader.
Actionable Closing
Start by profiling your most‑used endpoints with the query‑counter technique above. For each relationship that shows a linear increase, decide:
- If the collection is small and always needed → use
joinedload. - If the collection is large or you want to avoid wide rows → use
selectinload(orsubqueryloadif you prefer the classic IN approach). - If the data is truly optional → keep lazy loading but guard the access with a conditional or a separate endpoint.
Re‑run the verification after applying the loader; the query count should drop to a constant number (typically 1‑2) regardless of parent count. This simple loop gives you measurable confidence that you’ve eliminated the hidden N+1 cost without guessing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.