PythonSQLAlchemySQLitePostgreSQL

SQLAlchemy ORM Relationship Loading Techniques

A group of alchemists working with potions and ingredients in a laboratory, showing the relationships between different elements.

When working with SQLAlchemy ORM, understanding how relationships between tables are loaded is absolutely critical for application performance. The difference between a fast, responsive application and one that grinds to a halt under load often comes down to how effectively you manage relationship loading. In this article, we'll take a deep dive into the various relationship loading techniques that SQLAlchemy offers, seeing exactly how they work, when to use them, and how to avoid common pitfalls.

We'll start with the fundamentals of lazy and eager loading, move through the different loading strategies with hands-on examples, and finish with advanced techniques for fine-tuning your queries.

Lazy Loading vs. Eager Loading

Let's begin with the fundamental concepts of lazy loading and eager loading. These two approaches form the basis of how SQLAlchemy handles relationship loading.

How to lazy load relationships

Lazy loading is SQLAlchemy's default behavior for loading relationships. The core principle is simple yet powerful: related objects are not loaded from the database until you explicitly access them. This means when you retrieve an object, only the data for that object is fetched initially. The data for any related objects remains in the database until you actually try to access those relationships.

Let's set up some models to demonstrate this behavior:

from sqlalchemy import String, Integer, ForeignKey, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from typing import List, Optional

class Base(DeclarativeBase):
  pass

class Alchemist(Base):
  __tablename__ = 'alchemists'
  
  id: Mapped[int] = mapped_column(primary_key=True)
  name: Mapped[str] = mapped_column(String(50), nullable=False)
  age: Mapped[Optional[int]] = mapped_column(Integer)
  
  # By default, relationships are lazy loaded
  potions: Mapped[List["Potion"]] = relationship("Potion", back_populates="alchemist")

class Potion(Base):
  __tablename__ = 'potions'
  
  id: Mapped[int] = mapped_column(primary_key=True)
  name: Mapped[str] = mapped_column(String(50), nullable=False)
  potency: Mapped[Optional[int]] = mapped_column(Integer)
  alchemist_id: Mapped[Optional[int]] = mapped_column(ForeignKey('alchemists.id'))
  
  alchemist: Mapped["Alchemist"] = relationship("Alchemist", back_populates="potions")

Now let's see lazy loading in action:

from sqlalchemy.orm import Session

# Create an in-memory SQLite database
engine = create_engine("sqlite:///:memory:", echo=True)
Base.metadata.create_all(engine)

# Insert some sample data
with Session(engine) as session:
  # Create alchemists
  flamel = Alchemist(name="Nicolas Flamel", age=665)
  paracelsus = Alchemist(name="Paracelsus", age=49)
  
  # Create potions
  philosophers_stone = Potion(name="Philosopher's Stone", potency=10, alchemist=flamel)
  elixir_of_life = Potion(name="Elixir of Life", potency=9, alchemist=flamel)
  healing_potion = Potion(name="Healing Potion", potency=5, alchemist=paracelsus)
  
  session.add_all([flamel, paracelsus, philosophers_stone, elixir_of_life, healing_potion])
  session.commit()

with Session(engine) as session:
  # Turn off SQL echo to make the output clearer
  session.bind.echo = False
  
  print("Querying for an alchemist:")
  alchemist = session.query(Alchemist).filter_by(name="Nicolas Flamel").first()
  print(f"Retrieved: {alchemist.name}")
  
  print("No additional queries have been executed yet!")
  
  print("Now accessing the potions relationship:")
  print(f"{alchemist.name}'s potions:")
  # When we access the potions attribute, SQLAlchemy executes a new query
  for potion in alchemist.potions:
      print(f"  - {potion.name} (Potency: {potion.potency})")

When running this code, you'll notice that SQLAlchemy executes:

  • First query to get the alchemist
  • No more queries until we access alchemist.potions
  • A second query when we access alchemist.potions to fetch the related potions

It is important to point out that while this is the default functionality of relationships, it is not the only way, and you can actually configure the loading technique to use at the relationship level.

In the rare cases where you might have to fetch a relationship each time a parent entity is pulled from the database, you may use different lazy options on the relationship configuration to pull these collections "eagerly".

This is lazy loading in action. The advantages of this approach are:

  • We only load the data we actually need
  • Initial queries are faster as they fetch less data
  • Memory usage is more efficient

However, lazy loading has significant drawbacks, which leads us to our next topic.

The N+1 Problem

The N+1 problem is one of the most common performance issues with ORM frameworks. It occurs when you load a collection of N objects and then access a relationship on each one, causing N additional queries - one for each object. This can dramatically slow down your application as the dataset grows.

Let's see this problem in action:

# Create a new session
with Session(engine) as session:
  # Turn on SQL echo to see all queries
  session.bind.echo = True
  
  print("Demonstrating the N+1 problem:")
  
  # Initial query to get all alchemists - 1 query
  alchemists = session.query(Alchemist).all()
  print(f"Loaded {len(alchemists)} alchemists")
  
  # For each alchemist, we access their potions
  # This results in N additional queries, one for each alchemist
  print("Accessing potions for each alchemist:")
  for alchemist in alchemists:
      print(f"{alchemist.name}'s potions:")
      # Each access to potions triggers a new query!
      for potion in alchemist.potions:
          print(f"  - {potion.name}")

When you run this code with a small number of alchemists, it might not seem like a big deal. But imagine having hundreds or thousands of alchemists - that would mean hundreds or thousands of additional queries! This pattern quickly becomes a performance bottleneck.

The solution to the N+1 problem is to use eager loading techniques, which we'll explore next.

Eager Loading Techniques with Loader Options

Eager loading is the process of loading related objects at the same time as the main objects. SQLAlchemy provides several strategies for eager loading, each with its own strengths and ideal use cases.

Selecting IN Loading → selectinload()

selectinload() is one of the most efficient eager loading strategies for collections. It works by loading the primary objects first, then executing a second query with an IN clause to fetch all related objects at once.

Let's see how it addresses the N+1 problem:

from sqlalchemy.orm import selectinload
import time

# Create a new session
with Session(engine) as session:
  session.bind.echo = False  # Turn off SQL echo to focus on timing
  
  # Measure time with selectinload
  start_time = time.time()
  
  # Load all alchemists and their potions with selectinload
  alchemists = session.query(Alchemist).options(selectinload(Alchemist.potions)).all()
  
  # Access the potions (already loaded, no additional queries)
  for alchemist in alchemists:
      potion_count = len(alchemist.potions)
      
  selectin_loading_time = time.time() - start_time
  print(f"Time with selectinload: {selectin_loading_time:.4f} seconds")
  
  # Let's see what SQL is actually executed
  session.bind.echo = True
  print("SQL executed with selectinload:")
  
  # This will show us the two queries executed by selectinload
  alchemists = session.query(Alchemist).options(selectinload(Alchemist.potions)).all()

What's happening behind the scenes:

  • SQLAlchemy first executes a query to get all the alchemists
  • It collects all the alchemist IDs
  • It then executes a second query with a WHERE alchemist_id IN (...) clause to fetch all potions for these alchemists in a single query
  • It populates the potions attribute of each alchemist with the appropriate potions

This is far more efficient than executing a separate query for each alchemist. Let's also see how to use this with the newer select() API:

from sqlalchemy import select

# Create a new session
with Session(engine) as session:
  session.bind.echo = True
  print("Using selectinload with the select() API")
  
  # Use the newer select() API with selectinload
  stmt = select(Alchemist).options(selectinload(Alchemist.potions))
  alchemists = session.scalars(stmt).all()
  
  # Access the potions (already loaded, no additional queries)
  for alchemist in alchemists:
      print(f"{alchemist.name}'s potions:")
      for potion in alchemist.potions:
          print(f"  - {potion.name}")

selectinload() is particularly efficient for:

  • Loading one-to-many relationships
  • Scenarios with a large number of parent objects
  • Databases that optimize IN clauses well (which most modern databases do)

Joined Loading → joinedload()

joinedload() takes a different approach to eager loading. Instead of using two separate queries, it uses a SQL JOIN to load both the primary objects and related objects in a single query.

Let's see how it works:

from sqlalchemy.orm import joinedload

# Create a new session
with Session(engine) as session:
  session.bind.echo = True
  print("Using joinedload:")
  
  # Load all potions and their alchemists with joinedload
  stmt = select(Potion).options(joinedload(Potion.alchemist))
  result = session.execute(stmt)
  potions = result.scalars().all()
  
  # Access the alchemists (already loaded, no additional queries)
  print("Accessing alchemists from potions:")
  for potion in potions:
      print(f"{potion.name} was created by {potion.alchemist.name}")

The SQL generated by joinedload() includes a LEFT OUTER JOIN to fetch both the primary objects and related objects in a single query. This is very efficient for many-to-one relationships, but can have drawbacks for collections.

joinedload() is particularly efficient for:

  • Many-to-one and one-to-one relationships
  • Cases where you know you'll access most of the related objects
  • Scenarios with a relatively small number of related objects per primary object

However, for collections with many items, selectinload() is usually more efficient due to the duplication issue.

Subquery Loading → subqueryload()

subqueryload() is another eager loading strategy that uses a subquery to load related objects. It's similar to selectinload() but uses a different approach for constructing the secondary query.

from sqlalchemy.orm import subqueryload

# Create a new session
with Session(engine) as session:
  session.bind.echo = True
  print("Using subqueryload:")
  
  # Load alchemists and their potions with subqueryload
  stmt = select(Alchemist).options(subqueryload(Alchemist.potions))
  alchemists = session.scalars(stmt).all()
  
  # Access the potions (already loaded, no additional queries)
  for alchemist in alchemists:
      print(f"{alchemist.name}'s potions:")
      for potion in alchemist.potions:
          print(f"  - {potion.name}")

The key difference with subqueryload() is that it:

  • Executes a query to get the primary objects
  • Creates a subquery based on that result
  • Executes a second query using that subquery to load the related objects

This approach can be more efficient than joinedload() for collections but generally less efficient than selectinload() for most modern databases.

What kind of Loading to use and when?

Choosing the right loading strategy depends on your specific use case, the shape of your data, and your performance requirements. Here are some general guidelines:

For relationship loading:

Lazy Loading (lazyload())

  • When to use: For relationships that are rarely accessed or when you're not sure which loading strategy to use yet.
  • Pros: Simple, only loads data when needed, works well for development.
  • Cons: Can lead to the N+1 problem in production.
  • Example scenario: An alchemist's personal details that are only occasionally needed.

Select IN Loading (selectinload())

  • When to use: For collections (one-to-many, many-to-many) when you know you'll need the related objects.
  • Pros: Very efficient for collections, scales well with larger datasets, avoids the N+1 problem.
  • Cons: Requires two queries.
  • Example scenario: Loading all potions for multiple alchemists in a list view.

Joined Loading (joinedload())

  • When to use: For many-to-one and one-to-one relationships, or when you need to filter on the parent using the child.
  • Pros: Single query, efficient for many-to-one relationships.
  • Cons: Can produce large result sets with duplicated data for collections.
  • Example scenario: Loading the alchemist who created each potion in a potion list.

Subquery Loading (subqueryload())

  • When to use: Similar to Select IN but sometimes better for deeply nested collections.
  • Pros: Works well for nested collections, can be more efficient than joinedload for collections.
  • Cons: Generally not as efficient as selectinload for most databases.
  • Example scenario: Loading alchemists, their potions, and the ingredients for each potion.

When deciding on a loading strategy, it's important to consider your specific use case and data patterns. The best loading strategy for your application will depend on:

  1. The cardinality of your relationships (one-to-one, one-to-many, many-to-many)
  2. The size of your dataset
  3. How frequently you access related objects
  4. How much data you need at once

It's often useful to start with the default lazy loading during development and then optimize the loading strategies based on profiling your actual application usage.

Summary

In this article, we've explored the various relationship loading techniques available in SQLAlchemy. By understanding these techniques and knowing when to use each one, you can significantly improve the performance of your application.

The key takeaways are:

  • Lazy loading is simple but can lead to the N+1 problem
  • Eager loading techniques like selectinload() and joinedload() can solve the N+1 problem
  • Different relationships may require different loading strategies:
  • Use joinedload() for many-to-one relationships
  • Use selectinload() for collections (one-to-many, many-to-many)
  • Choose the right loading strategy based on your specific use case and data patterns

By mastering these techniques, you'll be able to write efficient and performant SQLAlchemy code that scales well as your data grows.

Author image
I'm Yonatan Vega
Owner & Creator, Fullstack.rocks
I've enjoyed writing code ever since I discovered it was an option. At a young age I started by writing code for MMORPG's private servers, and hosting my own private servers at home.

Today I'm writing and architecting software using many different technologies, and I'm always looking for the next thing to learn.