ORM Internals — Building an Object-Relational Mapper from Scratch in Python
Build a small ORM in Python — metadata definitions, SQL expression trees, Identity Map, Unit of Work, and lazy loading — to see how SQLAlchemy, Django ORM, and EF Core generate SQL and where their performance pitfalls come from.
Write a query such as SQLAlchemy's legacy session.query(User).filter(User.age > 20).all(), and the ORM generates SQL, fetches rows from the database, and turns them into Python objects. This can look like magic, but a few design patterns explain each step.
This article builds an ORM (Object-Relational Mapping) from scratch in Python, one component at a time, following the patterns catalogued in Martin Fowler's Patterns of Enterprise Application Architecture (PoEAA). Each component is then mapped to its counterpart in SQLAlchemy, Django ORM, and Entity Framework Core, so you can see the design decisions behind those ORMs.
The Big Picture
An ORM maps between an object-oriented domain model and a relational database. The two represent data differently: objects follow references to related objects, while tables relate rows through foreign keys. This mismatch is known as the object-relational impedance mismatch, and bridging it is the underlying problem ORMs solve.
The ORM layer consists of five components, each corresponding to a PoEAA pattern:
| Component | Responsibility | PoEAA Pattern |
|---|---|---|
| Metadata / Schema Definition | Describe the mapping between tables and classes | Metadata Mapping |
| SQL Expression Tree / Query Builder | Build SQL statements from Python expressions | Query Object |
| Identity Map | Map the same row to the same object instance | Identity Map |
| Unit of Work | Track changes and batch-write them to the DB | Unit of Work |
| Relationship Loading | Lazy or eager loading of related objects | Lazy Load |
Chapter 1: Active Record vs Data Mapper
ORM designs fall broadly into two architectures, Active Record and Data Mapper. They differ in whether the domain object itself knows how to persist its data.
Active Record
Active Record represents a database row as an object that carries CRUD methods itself.
class User:
"""Active Record pattern: the object knows how to persist itself"""
def __init__(self, name: str, email: str, id: int | None = None):
self.id = id
self.name = name
self.email = email
def save(self):
"""Persist the object's state to the database"""
if self.id is None:
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
(self.name, self.email),
)
self.id = cursor.lastrowid
else:
cursor.execute(
"UPDATE users SET name = ?, email = ? WHERE id = ?",
(self.name, self.email, self.id),
)
def delete(self):
"""Delete the object from the database"""
cursor.execute("DELETE FROM users WHERE id = ?", (self.id,))
@classmethod
def find(cls, id: int) -> "User":
"""Fetch a row by primary key and return it as an object"""
row = cursor.execute(
"SELECT id, name, email FROM users WHERE id = ?", (id,)
).fetchone()
return cls(name=row[1], email=row[2], id=row[0])# Usage: the object directly executes DB operations
user = User(name="Alice", email="alice@example.com")
user.save() # INSERT is executed
user.name = "Bob"
user.save() # UPDATE is executedActive Record is simple: each table maps to one class, and each object carries its own data access logic, so the pattern is easy to learn and quick to write. Ruby on Rails, Django ORM, and Laravel Eloquent adopt it.
The domain model, however, is tightly coupled to the database schema (table columns map directly to object attributes), which can constrain the design of large applications with complex business logic.
Data Mapper
Data Mapper interposes an independent mapper layer between domain objects and the database. Domain objects know nothing about persistence.
class User:
"""Data Mapper pattern: the object knows nothing about the DB"""
def __init__(self, name: str, email: str, id: int | None = None):
self.id = id
self.name = name
self.email = email
class UserMapper:
"""Mediates between User objects and the database"""
def __init__(self, connection):
self.conn = connection
def find(self, id: int) -> User:
row = self.conn.execute(
"SELECT id, name, email FROM users WHERE id = ?", (id,)
).fetchone()
return User(name=row[1], email=row[2], id=row[0])
def insert(self, user: User):
cursor = self.conn.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
(user.name, user.email),
)
user.id = cursor.lastrowid
def update(self, user: User):
self.conn.execute(
"UPDATE users SET name = ?, email = ? WHERE id = ?",
(user.name, user.email, user.id),
)# Usage: the mapper mediates DB operations
mapper = UserMapper(connection)
user = User(name="Alice", email="alice@example.com")
mapper.insert(user) # mapper executes the INSERT
user.name = "Bob"
mapper.update(user) # mapper executes the UPDATEData Mapper separates persistence from the domain model. Because the domain model does not depend on the database schema, table structure and object structure can evolve independently. SQLAlchemy (with imperative mapping), Hibernate, and Entity Framework Core are built on this pattern.
Comparing the Two
The learning-curve and testability rows compare the two patterns with each other, based on whether database operations live in the object itself or in a separate mapper.
| Aspect | Active Record | Data Mapper |
|---|---|---|
| Domain object responsibility | Data + DB operations | Data only |
| Table-to-class relationship | One-to-one | Freely mappable |
| Learning curve | Low | High |
| Testability | DB coupling tends to leak in | Domain logic easily unit-tested |
| Representative ORMs | Django ORM, Rails AR, Eloquent | SQLAlchemy, Hibernate, EF Core |
The rest of this article builds on Data Mapper, which lets us cover all of an ORM's internal components.
Chapter 2: Metadata and Schema Definition
An ORM starts from metadata: a description of which table and columns each Python class corresponds to.
2.1 Column and Table Definitions
The Column and Table classes hold this metadata. Table can also generate its own CREATE TABLE statement.
from dataclasses import dataclass, field
@dataclass
class Column:
"""Represents a table column"""
name: str
column_type: str # "INTEGER", "TEXT", "REAL", etc.
primary_key: bool = False
nullable: bool = True
default: object = None
def __set_name__(self, owner, name):
"""Descriptor protocol: record the attribute name when defined on a class"""
if not self.name:
self.name = name
@dataclass
class Table:
"""Represents table metadata"""
name: str
columns: list[Column] = field(default_factory=list)
_column_map: dict[str, Column] = field(default_factory=dict, repr=False)
def add_column(self, column: Column):
self.columns.append(column)
self._column_map[column.name] = column
@property
def primary_key(self) -> Column | None:
for col in self.columns:
if col.primary_key:
return col
return None
def create_table_sql(self) -> str:
"""Generate a CREATE TABLE statement"""
col_defs = []
for col in self.columns:
parts = [col.name, col.column_type]
if col.primary_key:
parts.append("PRIMARY KEY")
if not col.nullable and not col.primary_key:
parts.append("NOT NULL")
col_defs.append(" ".join(parts))
return f"CREATE TABLE IF NOT EXISTS {self.name} ({', '.join(col_defs)})"2.2 Mapping Registry
A registry records which table metadata belongs to each Python class, in the role of SQLAlchemy's registry or MetaData.
class Registry:
"""Manages class-to-table mappings"""
def __init__(self):
self._mappings: dict[type, Table] = {}
def map_class(self, cls: type, table: Table):
"""Register a class-to-table mapping"""
self._mappings[cls] = table
def get_table(self, cls: type) -> Table:
"""Retrieve the table for a given class"""
return self._mappings[cls]
def __contains__(self, cls: type) -> bool:
return cls in self._mappings# Usage: register a class-to-table mapping
registry = Registry()
users_table = Table(name="users")
users_table.add_column(Column("id", "INTEGER", primary_key=True))
users_table.add_column(Column("name", "TEXT", nullable=False))
users_table.add_column(Column("email", "TEXT", nullable=False))
class User:
def __init__(self, name: str, email: str, id: int | None = None):
self.id = id
self.name = name
self.email = email
registry.map_class(User, users_table)Registering each class explicitly like this is close to SQLAlchemy's imperative mapping (called "classical" mapping before version 1.4). Declarative mapping is the usual style in SQLAlchemy, but since version 1.4 both styles build their mappings from a registry object that records the mapped classes (SQLAlchemy Documentation — ORM Mapped Class Overview).
2.3 Declarative Mapping (Automatic Registration via Metaclass)
Calling map_class for every model is tedious. A metaclass can register the mapping when the class is defined, so models can be written declaratively.
_global_registry = Registry()
class ModelMeta(type):
"""Metaclass: auto-registers table mappings at class definition time"""
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
# Skip the base class (Model itself)
if name == "Model":
return cls
# Determine table name from __tablename__
table_name = namespace.get("__tablename__", name.lower() + "s")
table = Table(name=table_name)
# Collect Column instances
for attr_name, attr_value in namespace.items():
if isinstance(attr_value, Column):
if not attr_value.name:
attr_value.name = attr_name
table.add_column(attr_value)
_global_registry.map_class(cls, table)
return cls
class Model(metaclass=ModelMeta):
"""Base class for all models"""
pass# Declarative model definition
class User(Model):
__tablename__ = "users"
id = Column("id", "INTEGER", primary_key=True)
name = Column("name", "TEXT", nullable=False)
email = Column("email", "TEXT", nullable=False)
# The metaclass automatically registers the mapping to the users table
table = _global_registry.get_table(User)
print(table.create_table_sql())
# => CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL)SQLAlchemy's DeclarativeBase and Django ORM's models.Model work in essentially the same way: they use a metaclass (or __init_subclass__, available since Python 3.6) to collect metadata when a class is defined.
Chapter 3: SQL Expression Trees and the Query Builder
An ORM translates Python expressions into SQL. Writing User.age > 20 returns a Python object that eventually renders as the SQL fragment WHERE age > 20.
3.1 Expression Node Definitions (AST)
The ORM represents a SQL expression as a tree, an abstract syntax tree (AST). The diagram shows the tree for age > 20 AND status = 'active'.
Each node class implements to_sql(), which returns a SQL fragment together with its bind parameters.
from abc import ABC, abstractmethod
class Expression(ABC):
"""Abstract base class for SQL expressions"""
@abstractmethod
def to_sql(self) -> tuple[str, list]:
"""Return a (SQL string, parameters) tuple"""
...
def __and__(self, other: "Expression") -> "BinaryOp":
return BinaryOp("AND", self, other)
def __or__(self, other: "Expression") -> "BinaryOp":
return BinaryOp("OR", self, other)
def __invert__(self) -> "UnaryOp":
return UnaryOp("NOT", self)
class ColumnExpr(Expression):
"""Expression representing a column reference"""
def __init__(self, table_name: str, column_name: str):
self.table_name = table_name
self.column_name = column_name
def to_sql(self) -> tuple[str, list]:
return f"{self.table_name}.{self.column_name}", []
# Operator overloads for comparisons
def __eq__(self, other) -> "BinaryOp": # type: ignore[override]
return BinaryOp("=", self, _to_expr(other))
def __ne__(self, other) -> "BinaryOp": # type: ignore[override]
return BinaryOp("!=", self, _to_expr(other))
def __gt__(self, other) -> "BinaryOp":
return BinaryOp(">", self, _to_expr(other))
def __ge__(self, other) -> "BinaryOp":
return BinaryOp(">=", self, _to_expr(other))
def __lt__(self, other) -> "BinaryOp":
return BinaryOp("<", self, _to_expr(other))
def __le__(self, other) -> "BinaryOp":
return BinaryOp("<=", self, _to_expr(other))
def in_(self, values: list) -> "InOp":
return InOp(self, values)
def like(self, pattern: str) -> "BinaryOp":
return BinaryOp("LIKE", self, Literal(pattern))
class Literal(Expression):
"""Expression representing a literal value"""
def __init__(self, value):
self.value = value
def to_sql(self) -> tuple[str, list]:
return "?", [self.value]
class BinaryOp(Expression):
"""Expression representing a binary operation"""
def __init__(self, op: str, left: Expression, right: Expression):
self.op = op
self.left = left
self.right = right
def to_sql(self) -> tuple[str, list]:
left_sql, left_params = self.left.to_sql()
right_sql, right_params = self.right.to_sql()
return f"({left_sql} {self.op} {right_sql})", left_params + right_params
class UnaryOp(Expression):
"""Expression representing a unary operation"""
def __init__(self, op: str, operand: Expression):
self.op = op
self.operand = operand
def to_sql(self) -> tuple[str, list]:
sql, params = self.operand.to_sql()
return f"({self.op} {sql})", params
class InOp(Expression):
"""Expression representing an IN operation"""
def __init__(self, column: ColumnExpr, values: list):
self.column = column
self.values = values
def to_sql(self) -> tuple[str, list]:
col_sql, col_params = self.column.to_sql()
placeholders = ", ".join("?" for _ in self.values)
return f"{col_sql} IN ({placeholders})", col_params + list(self.values)
def _to_expr(value) -> Expression:
"""Helper: convert a Python value to an Expression"""
if isinstance(value, Expression):
return value
return Literal(value)3.2 How Operator Overloading Works
A comparison in Python normally evaluates to True or False, but a class can define its own comparison methods (__eq__, __gt__, and so on). ColumnExpr defines them to return SQL expression tree nodes instead of booleans.
# Create ColumnExpr instances
age = ColumnExpr("users", "age")
status = ColumnExpr("users", "status")
# Python comparison operators return SQL expression tree nodes
expr = (age > 20) & (status == "active")
# Convert to SQL
sql, params = expr.to_sql()
print(sql) # => ((users.age > ?) AND (users.status = ?))
print(params) # => [20, 'active']Because age is a ColumnExpr, age > 20 calls __gt__, which returns BinaryOp(">", ColumnExpr("users", "age"), Literal(20)). Calling to_sql() on the root walks the tree and produces the SQL string.
SQLAlchemy implements this with the PropComparator class and InstrumentedAttribute descriptors. Accessing an attribute on a model class, such as User.age, returns an object equivalent to ColumnExpr through the descriptor, and its operator overloads build the SQL expression tree.
3.3 The Query Class
The Query class collects WHERE conditions, ordering, and paging, then assembles a SELECT statement from them.
class Query:
"""Builder for SELECT queries"""
def __init__(self, table: Table, model_class: type):
self.table = table
self.model_class = model_class
self._where: Expression | None = None
self._order_by: list[tuple[str, str]] = []
self._limit: int | None = None
self._offset: int | None = None
def filter(self, *conditions: Expression) -> "Query":
"""Add WHERE conditions (AND-joined)"""
for condition in conditions:
if self._where is None:
self._where = condition
else:
self._where = self._where & condition
return self
def order_by(self, column_name: str, direction: str = "ASC") -> "Query":
"""Add an ORDER BY clause"""
self._order_by.append((column_name, direction))
return self
def limit(self, n: int) -> "Query":
self._limit = n
return self
def offset(self, n: int) -> "Query":
self._offset = n
return self
def build_sql(self) -> tuple[str, list]:
"""Generate the SQL string and parameters"""
columns = ", ".join(col.name for col in self.table.columns)
sql = f"SELECT {columns} FROM {self.table.name}"
params: list = []
if self._where is not None:
where_sql, where_params = self._where.to_sql()
sql += f" WHERE {where_sql}"
params.extend(where_params)
if self._order_by:
order_parts = [f"{col} {direction}" for col, direction in self._order_by]
sql += f" ORDER BY {', '.join(order_parts)}"
if self._limit is not None:
sql += f" LIMIT {self._limit}"
if self._offset is not None:
sql += f" OFFSET {self._offset}"
return sql, params# Usage
age = ColumnExpr("users", "age")
status = ColumnExpr("users", "status")
query = (
Query(users_table, User)
.filter(age > 20, status == "active")
.order_by("name")
.limit(10)
)
sql, params = query.build_sql()
print(sql)
# => SELECT id, name, email FROM users WHERE ((users.age > ?) AND (users.status = ?)) ORDER BY name ASC LIMIT 10
print(params)
# => [20, 'active']SQLAlchemy, Django ORM (QuerySet), and Entity Framework Core (LINQ) all use this method-chaining style. In those ORMs, each method returns a new copy of the query (immutable query construction) rather than modifying it in place as the simplified Query above does, and evaluation is deferred until the SQL is generated and executed.
3.4 Parameter Binding (SQL Injection Prevention)
The implementation above never interpolates comparison values into the SQL string. It writes a placeholder (?) in the SQL and passes the value separately as a parameter.
# ❌ DANGEROUS: SQL injection vulnerability
sql = f"SELECT * FROM users WHERE name = '{user_input}'"
# ✅ SAFE: parameterized query
sql = "SELECT * FROM users WHERE name = ?"
params = [user_input]
cursor.execute(sql, params)ORMs use parameterized queries internally, and they are a key defense against SQL injection. In our expression tree, every comparison value becomes a Literal node, so it always ends up in the parameter list instead of the SQL string.
Chapter 4: Identity Map — Same Row, Same Object
Martin Fowler's PoEAA defines the Identity Map as a pattern that "Ensures that each object gets loaded only once by keeping every loaded object in a map. Looks up objects using the map when referring to them." In an ORM, the map is keyed by primary key, so the same primary key always returns the same object instance.
4.1 Why Do We Need an Identity Map?
Without an Identity Map, loading the same row twice creates two objects:
# Without Identity Map
user1 = mapper.find(1) # SELECT ... WHERE id = 1 -> User(name="Alice")
user2 = mapper.find(1) # SELECT ... WHERE id = 1 -> User(name="Alice") (new query)
user1.name = "Bob"
print(user2.name) # "Alice" — a different object, so the change is not reflected!
print(user1 is user2) # False — same row, different objectsObject identity breaks: two independent objects represent the same database row, so a change made through one is invisible through the other. Each lookup also issues a fresh SELECT, even for a row that is already in memory.
4.2 Identity Map Implementation
class IdentityMap:
"""Tracks objects by primary key"""
def __init__(self):
self._map: dict[tuple[type, object], object] = {}
def get(self, cls: type, pk: object) -> object | None:
"""Retrieve an object from the cache"""
return self._map.get((cls, pk))
def put(self, cls: type, pk: object, instance: object):
"""Register an object in the cache"""
self._map[(cls, pk)] = instance
def remove(self, cls: type, pk: object):
"""Remove an object from the cache"""
self._map.pop((cls, pk), None)
def clear(self):
"""Clear all cached objects"""
self._map.clear()
def __contains__(self, key: tuple[type, object]) -> bool:
return key in self._map4.3 Mapper with Identity Map
class UserMapper:
"""Data Mapper with built-in Identity Map"""
def __init__(self, connection, identity_map: IdentityMap):
self.conn = connection
self.identity_map = identity_map
def find(self, id: int) -> User:
# 1. Check the Identity Map first
cached = self.identity_map.get(User, id)
if cached is not None:
return cached
# 2. If not cached, fetch from DB
row = self.conn.execute(
"SELECT id, name, email FROM users WHERE id = ?", (id,)
).fetchone()
if row is None:
raise ValueError(f"User with id={id} not found")
user = User(name=row[1], email=row[2], id=row[0])
# 3. Register in the Identity Map
self.identity_map.put(User, id, user)
return user# With Identity Map
identity_map = IdentityMap()
mapper = UserMapper(connection, identity_map)
user1 = mapper.find(1) # SELECT from DB (first time)
user2 = mapper.find(1) # Hit from Identity Map (no SQL)
print(user1 is user2) # True — the same object!
user1.name = "Bob"
print(user2.name) # "Bob" — since it's the same object, the change is automatically reflectedIn SQLAlchemy, the Session object holds an IdentityMap internally. Calling session.get(User, 1) first checks the Identity Map and returns the cached object without issuing SQL if found. Closing the session (session.close()) or expunging all objects (session.expunge_all()) clears the Identity Map. session.expire_all(), by contrast, only marks attributes as stale; the objects stay in the Identity Map.
Chapter 5: Unit of Work — Change Tracking and Batch Writes
The Unit of Work is defined by Martin Fowler as a pattern that "maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems."
5.1 Why Do We Need a Unit of Work?
In a naive implementation without a Unit of Work, every attribute change immediately issues SQL:
user.name = "Bob" # UPDATE users SET name = 'Bob' WHERE id = 1
user.email = "b@x.com" # UPDATE users SET email = 'b@x.com' WHERE id = 1Two attribute changes produce two statements where a single UPDATE would do. Applications may also need changes to several objects to be committed in one database transaction.
A Unit of Work therefore records object changes in memory and issues SQL only at an explicit "flush."
5.2 Object State Transitions
An object managed by a Unit of Work moves through these states:
- Transient: A new object not yet added to the session
- Pending: Added via
add()but not yet INSERTed into the DB - Persistent: Exists in the DB and is being tracked by the session
- Dirty: An attribute has been modified but not yet reflected in the DB
- Deleted: Marked for deletion but not yet DELETEd from the DB
- Detached: Formerly associated with a session but no longer tracked
SQLAlchemy's InstanceState tracks a similar lifecycle. Its documented states are transient, pending, persistent, deleted, and detached; a modified object remains persistent, and the session records it as dirty.
5.3 Dirty Tracking Implementation
An ORM can detect attribute changes in two ways.
Approach 1: Snapshot ComparisonSave attribute values at load time and compare against current values at flush time.
class SnapshotTracker:
"""Snapshot-based change tracking"""
def __init__(self):
self._snapshots: dict[int, dict[str, object]] = {}
def take_snapshot(self, obj: object, columns: list[str]):
"""Save a snapshot of the current attribute values"""
snapshot = {col: getattr(obj, col) for col in columns}
self._snapshots[id(obj)] = snapshot
def get_dirty_attributes(self, obj: object, columns: list[str]) -> dict[str, object]:
"""Detect and return changed attributes"""
snapshot = self._snapshots.get(id(obj), {})
dirty = {}
for col in columns:
current = getattr(obj, col)
if col not in snapshot or snapshot[col] != current:
dirty[col] = current
return dirtyUse Python descriptors (__set__ / __get__) to intercept attribute assignments.
class TrackedAttribute:
"""Descriptor that detects attribute writes"""
def __init__(self, name: str):
self.name = name
self.attr_name = f"_tracked_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self # Return the descriptor itself when accessed from the class
return getattr(obj, self.attr_name, None)
def __set__(self, obj, value):
old_value = getattr(obj, self.attr_name, _UNSET)
setattr(obj, self.attr_name, value)
# Notify the Unit of Work that a value has changed
if old_value is not _UNSET and old_value != value:
_notify_dirty(obj, self.name, old_value, value)
_UNSET = object() # Sentinel value
def _notify_dirty(obj, attr_name, old_value, new_value):
"""Change notification (connected to the UnitOfWork below)"""
if hasattr(obj, "_unit_of_work") and obj._unit_of_work is not None:
obj._unit_of_work.mark_dirty(obj)SQLAlchemy uses the latter approach. Its InstrumentedAttribute descriptor replaces (instruments) each attribute on the class. When an attribute is assigned to, the descriptor notifies the InstanceState (the state-management object attached to each instance), which adds the object to the session's dirty set.
5.4 Unit of Work Implementation
class UnitOfWork:
"""Tracks changes and batch-writes them to the database"""
def __init__(self, connection, registry: Registry):
self.conn = connection
self.registry = registry
self.identity_map = IdentityMap()
self._new: list[object] = [] # INSERT targets
self._dirty: set[object] = set() # UPDATE targets
self._deleted: list[object] = [] # DELETE targets
self._snapshot_tracker = SnapshotTracker()
def register_new(self, obj: object):
"""Register a new object (INSERT target)"""
self._new.append(obj)
def mark_dirty(self, obj: object):
"""Mark a changed object (UPDATE target)"""
self._dirty.add(obj)
def register_deleted(self, obj: object):
"""Register an object for deletion"""
self._deleted.append(obj)
def commit(self):
"""Write all tracked changes to the database"""
try:
self._do_inserts()
self._do_updates()
self._do_deletes()
self.conn.commit()
# Clear after commit
self._new.clear()
self._dirty.clear()
self._deleted.clear()
except Exception:
self.conn.rollback()
raise
def _do_inserts(self):
for obj in self._new:
table = self.registry.get_table(type(obj))
columns = [c for c in table.columns if not c.primary_key]
col_names = ", ".join(c.name for c in columns)
placeholders = ", ".join("?" for _ in columns)
values = [getattr(obj, c.name) for c in columns]
cursor = self.conn.execute(
f"INSERT INTO {table.name} ({col_names}) VALUES ({placeholders})",
values,
)
# Set the auto-generated PK
pk_col = table.primary_key
if pk_col:
setattr(obj, pk_col.name, cursor.lastrowid)
# Register in the Identity Map
self.identity_map.put(type(obj), cursor.lastrowid, obj)
# Save a snapshot
col_names_list = [c.name for c in table.columns]
self._snapshot_tracker.take_snapshot(obj, col_names_list)
def _do_updates(self):
for obj in self._dirty:
table = self.registry.get_table(type(obj))
col_names_list = [c.name for c in table.columns if not c.primary_key]
dirty_attrs = self._snapshot_tracker.get_dirty_attributes(obj, col_names_list)
if not dirty_attrs:
continue
set_clause = ", ".join(f"{col} = ?" for col in dirty_attrs)
values = list(dirty_attrs.values())
pk_col = table.primary_key
pk_value = getattr(obj, pk_col.name)
values.append(pk_value)
self.conn.execute(
f"UPDATE {table.name} SET {set_clause} WHERE {pk_col.name} = ?",
values,
)
# Update the snapshot
self._snapshot_tracker.take_snapshot(obj, [c.name for c in table.columns])
def _do_deletes(self):
for obj in self._deleted:
table = self.registry.get_table(type(obj))
pk_col = table.primary_key
pk_value = getattr(obj, pk_col.name)
self.conn.execute(
f"DELETE FROM {table.name} WHERE {pk_col.name} = ?",
(pk_value,),
)
self.identity_map.remove(type(obj), pk_value)5.5 Usage Example
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute(users_table.create_table_sql())
uow = UnitOfWork(conn, _global_registry)
# INSERT: register new objects
alice = User(name="Alice", email="alice@example.com")
bob = User(name="Bob", email="bob@example.com")
uow.register_new(alice)
uow.register_new(bob)
# No SQL has been issued yet
uow.commit() # Two INSERTs + COMMIT are executed here
# UPDATE: change an attribute and mark dirty
alice.name = "Alice Smith"
uow.mark_dirty(alice)
uow.commit() # UPDATE users SET name = ? WHERE id = ? is executed
# DELETE: register for deletion
uow.register_deleted(bob)
uow.commit() # DELETE FROM users WHERE id = ? is executedIn SQLAlchemy, session.add(obj) followed by session.commit() corresponds to register_new followed by commit. commit() calls flush(), which issues SQL for the new, modified, and deleted objects — the same three groups that _new, _dirty, and _deleted hold here.
5.6 Topological Sort for Write Ordering
In real ORMs, foreign key constraints dictate write ordering. For example, if the posts table references users, INSERTs must execute in users → posts order, and DELETEs in posts → users order.
SQLAlchemy's Unit of Work constructs a directed graph of foreign key dependencies between tables and uses a topological sort to determine the correct INSERT/DELETE execution order.
from collections import defaultdict
def topological_sort(dependencies: dict[str, list[str]]) -> list[str]:
"""Topologically sort a dependency graph
Args:
dependencies: table name -> list of dependency table names
e.g. {"posts": ["users"]} means posts depends on users
Returns:
Execution order respecting dependencies
"""
in_degree: dict[str, int] = defaultdict(int)
graph: dict[str, list[str]] = defaultdict(list)
all_nodes: set[str] = set()
for node, deps in dependencies.items():
all_nodes.add(node)
for dep in deps:
all_nodes.add(dep)
graph[dep].append(node)
in_degree[node] += 1
# Start with nodes that have zero in-degree (Kahn's algorithm)
queue = [n for n in all_nodes if in_degree[n] == 0]
result = []
while queue:
node = queue.pop(0)
result.append(node)
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
if len(result) != len(all_nodes):
raise ValueError("Circular dependency detected")
return result# Example: users -> posts -> comments dependency chain
deps = {
"posts": ["users"], # posts depends on users (FK: user_id)
"comments": ["posts"], # comments depends on posts (FK: post_id)
"users": [], # users is independent
}
order = topological_sort(deps)
print(order) # => ['users', 'posts', 'comments']
# INSERTs execute in this order; DELETEs in reverseChapter 6: Relationship Loading
ORMs can build object graphs automatically from relationships between tables, such as a user and that user's posts. This is convenient, but the way related rows are loaded can cause serious performance problems.
6.1 Defining Relationships
from enum import Enum
class LoadStrategy(Enum):
LAZY = "lazy" # Issue a SELECT when accessed
EAGER = "eager" # Load with a JOIN/subquery alongside the parent
@dataclass
class Relationship:
"""Defines a relationship between tables"""
target_class: type
foreign_key: str # FK column name on the child table
back_populates: str | None = None
load_strategy: LoadStrategy = LoadStrategy.LAZY
many: bool = True # True: one-to-many, False: many-to-one6.2 Lazy Loading
Lazy loading waits to load related objects until the code first accesses them. A Python descriptor can implement this.
class LazyLoader:
"""Descriptor that implements lazy loading"""
def __init__(self, relationship: Relationship, mapper_factory):
self.relationship = relationship
self.mapper_factory = mapper_factory
self.attr_name = f"_lazy_{id(self)}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Load from DB if not yet loaded
if not hasattr(obj, self.attr_name):
loaded = self._load(obj)
setattr(obj, self.attr_name, loaded)
return getattr(obj, self.attr_name)
def _load(self, obj):
"""Load related objects from the database"""
mapper = self.mapper_factory()
pk = getattr(obj, "id")
fk = self.relationship.foreign_key
if self.relationship.many:
# One-to-many: search the child table by FK
rows = mapper.conn.execute(
f"SELECT * FROM {mapper.table.name} WHERE {fk} = ?",
(pk,),
).fetchall()
return [mapper._row_to_object(row) for row in rows]
else:
# Many-to-one: look up the parent table by the FK value
fk_value = getattr(obj, fk)
return mapper.find(fk_value)class User:
def __init__(self, name: str, email: str, id: int | None = None):
self.id = id
self.name = name
self.email = email
# The posts attribute is defined as a LazyLoader descriptoruser = mapper.find(1)
# At this point, posts are NOT loaded (no SQL issued)
print(user.posts)
# NOW: SELECT * FROM posts WHERE user_id = 1 is executed6.3 The N+1 Problem
The biggest drawback of lazy loading is the N+1 problem: one query loads N parent objects, and accessing a relationship on each of them issues N more.
from sqlalchemy import select
# Fetch all users: 1 SELECT (SQLAlchemy 2.x style)
users = session.scalars(select(User)).all() # SELECT * FROM users (1 query)
# Access each user's posts: N SELECTs
for user in users:
print(user.posts)
# SELECT * FROM posts WHERE user_id = 1 (1 query)
# SELECT * FROM posts WHERE user_id = 2 (1 query)
# SELECT * FROM posts WHERE user_id = 3 (1 query)
# ... N queries
# Total: 1 + N queriesWith 100 users, that is 101 SQL queries. Because the query count grows with the number of users, this becomes a major performance bottleneck.
6.4 Eager Loading
Eager loading solves the N+1 problem by loading the related objects together with their parents. There are two main approaches.
Approach 1: JOIN-basedThe examples below assume parent_table, child_table, and fk_column come from trusted ORM metadata. SQL bind parameters protect values, not SQL identifiers, so real ORMs quote and validate identifiers instead of accepting them from user input.
def eager_load_join(connection, parent_table: str, child_table: str,
fk_column: str) -> list[tuple]:
"""Fetch parent and children in a single JOIN"""
sql = f"""
SELECT p.*, c.*
FROM {parent_table} p
LEFT JOIN {child_table} c ON p.id = c.{fk_column}
"""
return connection.execute(sql).fetchall()-- Fetch all data in 1 query
SELECT users.*, posts.*
FROM users
LEFT JOIN posts ON users.id = posts.user_iddef eager_load_subquery(connection, parent_ids: list[int],
child_table: str, fk_column: str) -> list[tuple]:
"""Batch-fetch related objects with an IN clause"""
placeholders = ", ".join("?" for _ in parent_ids)
sql = f"SELECT * FROM {child_table} WHERE {fk_column} IN ({placeholders})"
return connection.execute(sql, parent_ids).fetchall()-- Fetch all data in 2 queries
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);SQLAlchemy and Django ORM let you request eager loading per query:
from sqlalchemy import select
from sqlalchemy.orm import joinedload, selectinload
# SQLAlchemy
users = session.scalars(select(User).options(joinedload(User.posts))).all()
# or
users = session.scalars(select(User).options(selectinload(User.posts))).all()
# Django ORM
users = User.objects.prefetch_related("posts").all()
# or
users = User.objects.select_related("profile").all() # For ForeignKey/OneToOne6.5 Choosing the Right Loading Strategy
The query counts are for loading N parent objects; memory usage ranks the three strategies relative to each other.
| Strategy | Query count | Memory usage | Best for |
|---|---|---|---|
| Lazy loading | 1 + N (worst case) | Minimal | When related objects are never accessed |
| JOIN eager loading | 1 | High (duplicate rows) | One-to-one relations, small datasets |
| Subquery eager loading | 2 | Medium | One-to-many relations, large datasets |
The right strategy depends on whether the code accesses the related objects, on the kind of relationship, and on the amount of data, so neither "always eager load" nor "always lazy load" works as a rule. Profiling and the query log show which relationships are actually accessed and how many queries they cost.
Chapter 7: Session — Putting It All Together
The Session class below combines the Identity Map, change tracking, and the query builder behind one interface, in the role of SQLAlchemy's Session. Unlike the UnitOfWork in Chapter 5, its flush() compares the objects in the Identity Map with their snapshots, so attribute changes are written without a call to mark_dirty().
class Session:
"""ORM front-end: integrates Identity Map + Unit of Work"""
def __init__(self, connection, registry: Registry):
self.conn = connection
self.registry = registry
self.identity_map = IdentityMap()
self._new: list[object] = []
self._dirty: set[object] = set()
self._deleted: list[object] = []
self._snapshot_tracker = SnapshotTracker()
def add(self, obj: object):
"""Add a new object to the session (INSERT target)"""
self._new.append(obj)
def delete(self, obj: object):
"""Mark an object for deletion"""
self._deleted.append(obj)
def get(self, cls: type, pk: object) -> object | None:
"""Fetch an object by primary key (Identity Map first)"""
cached = self.identity_map.get(cls, pk)
if cached is not None:
return cached
table = self.registry.get_table(cls)
pk_col = table.primary_key
columns = ", ".join(c.name for c in table.columns)
row = self.conn.execute(
f"SELECT {columns} FROM {table.name} WHERE {pk_col.name} = ?",
(pk,),
).fetchone()
if row is None:
return None
instance = self._row_to_object(cls, table, row)
self.identity_map.put(cls, pk, instance)
col_names = [c.name for c in table.columns]
self._snapshot_tracker.take_snapshot(instance, col_names)
return instance
def query(self, cls: type) -> Query:
"""Return a query builder"""
table = self.registry.get_table(cls)
return Query(table, cls)
def flush(self):
"""Issue pending changes as SQL (without committing)"""
self._do_inserts()
self._detect_dirty()
self._do_updates()
self._do_deletes()
def commit(self):
"""Commit all changes to the database"""
self.flush()
self.conn.commit()
self._new.clear()
self._dirty.clear()
self._deleted.clear()
def rollback(self):
"""Discard changes and roll back"""
self.conn.rollback()
self.identity_map.clear()
self._new.clear()
self._dirty.clear()
self._deleted.clear()
def close(self):
"""Clean up the session"""
self.identity_map.clear()
# --- Internal methods ---
def _row_to_object(self, cls: type, table: Table, row: tuple) -> object:
"""Convert a DB row to an object"""
kwargs = {}
for i, col in enumerate(table.columns):
kwargs[col.name] = row[i]
return cls(**kwargs)
def _detect_dirty(self):
"""Detect changes in objects within the Identity Map"""
for (cls, pk), obj in list(self.identity_map._map.items()):
table = self.registry.get_table(cls)
col_names = [c.name for c in table.columns if not c.primary_key]
dirty = self._snapshot_tracker.get_dirty_attributes(obj, col_names)
if dirty:
self._dirty.add(obj)
def _do_inserts(self):
for obj in self._new:
table = self.registry.get_table(type(obj))
columns = [c for c in table.columns if not c.primary_key]
col_names = ", ".join(c.name for c in columns)
placeholders = ", ".join("?" for _ in columns)
values = [getattr(obj, c.name) for c in columns]
cursor = self.conn.execute(
f"INSERT INTO {table.name} ({col_names}) VALUES ({placeholders})",
values,
)
pk_col = table.primary_key
if pk_col:
setattr(obj, pk_col.name, cursor.lastrowid)
self.identity_map.put(type(obj), cursor.lastrowid, obj)
all_cols = [c.name for c in table.columns]
self._snapshot_tracker.take_snapshot(obj, all_cols)
def _do_updates(self):
for obj in self._dirty:
table = self.registry.get_table(type(obj))
col_names = [c.name for c in table.columns if not c.primary_key]
dirty = self._snapshot_tracker.get_dirty_attributes(obj, col_names)
if not dirty:
continue
set_clause = ", ".join(f"{col} = ?" for col in dirty)
values = list(dirty.values())
pk_col = table.primary_key
values.append(getattr(obj, pk_col.name))
self.conn.execute(
f"UPDATE {table.name} SET {set_clause} WHERE {pk_col.name} = ?",
values,
)
self._snapshot_tracker.take_snapshot(obj, [c.name for c in table.columns])
def _do_deletes(self):
for obj in self._deleted:
table = self.registry.get_table(type(obj))
pk_col = table.primary_key
pk_value = getattr(obj, pk_col.name)
self.conn.execute(
f"DELETE FROM {table.name} WHERE {pk_col.name} = ?",
(pk_value,),
)
self.identity_map.remove(type(obj), pk_value)Usage
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute(users_table.create_table_sql())
session = Session(conn, _global_registry)
# Create
alice = User(name="Alice", email="alice@example.com", id=None)
session.add(alice)
session.commit()
print(alice.id) # => 1 (auto-generated PK)
# Read (via Identity Map)
user = session.get(User, 1)
print(user is alice) # => True
# Update (change tracking)
alice.name = "Alice Smith"
session.commit() # Only one UPDATE statement is issued
# Delete
session.delete(alice)
session.commit() # DELETE statement is issuedChapter 8: Mapping to Real-World ORMs
The tables below map each component from the previous chapters to its counterpart in SQLAlchemy, Django ORM, and EF Core.
SQLAlchemy
SQLAlchemy is the most widely used Python ORM, built on the Data Mapper pattern.
| Our Implementation | SQLAlchemy Equivalent |
|---|---|
Table, Column | Table, Column (Core), mapped_column (ORM) |
Registry | registry, MetaData |
ModelMeta (metaclass) | DeclarativeBase, DeclarativeMeta |
Expression, BinaryOp | ClauseElement, BinaryExpression (Core) |
ColumnExpr (operator overloads) | PropComparator, InstrumentedAttribute |
Query | Query (Legacy) / select() (2.0 style) |
IdentityMap | Session.identity_map (IdentityMap class) |
UnitOfWork | Session._flush() via UOWTransaction |
SnapshotTracker | InstanceState._committed_state |
TrackedAttribute (descriptor) | InstrumentedAttribute + AttributeEvents |
Session | Session |
| Topological sort | Dependency sort in unitofwork.py |
SQLAlchemy 2.0 changed the recommended query syntax from session.query(User) to session.execute(select(User)) (SQLAlchemy 2.0 - Major Migration Guide). The SQL expression tree belongs to the shared Core layer, and the ORM layer adds the Identity Map and Unit of Work on top of it.
Django ORM
Django ORM adopts the Active Record pattern. SQL construction and execution, however, are handled by objects separate from the model, such as QuerySet, and this part of its design resembles a Data Mapper.
| Our Implementation | Django ORM Equivalent |
|---|---|
Table, Column | Options (_meta), Field |
ModelMeta | ModelBase (metaclass) |
Expression, BinaryOp | Q objects, F objects, Lookup |
Query | QuerySet (lazy evaluation chain) |
IdentityMap | None (Django has no built-in Identity Map) |
UnitOfWork | None (save() immediately issues SQL) |
| Lazy loading | Default ForeignKey behavior |
| Eager loading | select_related(), prefetch_related() |
Django ORM has neither an Identity Map nor a Unit of Work: calling Model.save() issues SQL immediately. This is an Active Record design choice that keeps the ORM simple and gives up batched flushes like SQLAlchemy's.
Django's QuerySet is evaluated lazily: chaining .filter().exclude().order_by() does not issue SQL. SQL is only executed when data is actually needed (iteration, len(), slicing, etc.).
Entity Framework Core
Entity Framework Core (EF Core) is a .NET ORM built on the Data Mapper pattern.
| Our Implementation | EF Core Equivalent |
|---|---|
Registry | ModelBuilder, IModel |
Expression, BinaryOp | Expression Tree (LINQ) |
Query | IQueryable<T> (LINQ to Entities) |
IdentityMap | DbContext Change Tracker |
UnitOfWork | DbContext.SaveChanges() |
| Change tracking | ChangeTracker (snapshot-based) |
EF Core uses C#'s LINQ (Language Integrated Query). Because DbSet<T> queries are IQueryable<T>, LINQ lambdas are captured as expression trees and EF Core translates that expression representation to SQL. The principle is the same as building SQL expressions with operator overloading in Python, except that the C# language itself builds the tree.
// C# + EF Core: LINQ generates Expression Trees, EF Core translates them to SQL
var users = context.Users
.Where(u => u.Age > 20 && u.Status == "active")
.OrderBy(u => u.Name)
.ToList();Chapter 9: Performance and Pitfalls
9.1 Detecting and Preventing the N+1 Problem
The N+1 problem is the best-known ORM performance issue. In a SQL log it appears as the same query repeated with different parameter values.
Detection:# SQLAlchemy: enable SQL logging
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
# Django: django-debug-toolbar or django-silk
# EF Core: Microsoft.Extensions.Logging- Explicitly specify eager loading — SQLAlchemy's
joinedload()/selectinload(), Django'sselect_related()/prefetch_related() - Fetch only the needed columns in one joined query —
session.execute(select(User.name, Post.title).join(User.posts))returns the rows in a single query, and because it builds no ORM objects, no lazy load can fire - Fall back to raw SQL — Write SQL directly for complex queries that don't map well to ORM abstractions
9.2 Identity Map and Memory Leaks
In long-lived sessions, the Identity Map accumulates objects and can exhaust memory.
from sqlalchemy import select
# Solution 1: Manage sessions with proper scoping
with Session(conn, registry) as session:
# Use a session per request, auto-cleanup on exit
...
# Solution 2: Stream processing for large datasets
for user in session.scalars(select(User).execution_options(yield_per=1000)):
process(user)
# yield_per fetches rows in batches instead of materializing the full result at once
session.expunge_all() # Remove all objects from the Identity Map after processingIn SQLAlchemy web applications, the common pattern is to use scoped_session to manage a session per request and call session.close() at request end to clear the Identity Map.
9.3 Dirty Tracking Overhead
Descriptor-based change tracking intercepts every attribute assignment, which can add overhead when an application handles large numbers of objects.
# Solution: bypass ORM mapping and query at the Core level
# SQLAlchemy Core — returns Row objects (no change tracking)
from sqlalchemy import select as core_select
rows = connection.execute(core_select(users_table)).fetchall()Core-level connection.execute() returns Row objects (plain tuple-like objects), so it skips ORM change tracking and the Identity Map entirely. This approach is worth considering for batch updates and for reading large amounts of data.
Summary
The ORM built in this article consists of five core components, which the Session combines into one front-end.
| Component | Core Idea |
|---|---|
| Metadata | Declarative definition via metaclass + registry |
| SQL Expression Tree | Operator overloads → AST nodes → SQL strings |
| Identity Map | Dictionary keyed by primary key guarantees identity |
| Unit of Work | Accumulate changes in memory, flush all at once |
| Relationships | Descriptors for lazy loading / JOIN or IN for eager loading |
| Session | Unified API integrating all of the above |
What looks like magic is a composition of well-known design patterns: an ORM builds on either Data Mapper or Active Record and adds patterns such as Identity Map and Unit of Work. Knowing how these patterns behave and where they fall short lets you predict the SQL your ORM generates and avoid pitfalls such as the N+1 problem and memory leaks.
References
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — Identity Map, Unit of Work, Data Mapper, Active Record
- SQLAlchemy Documentation — Session Basics
- SQLAlchemy Documentation — ORM Mapped Class Overview
- SQLAlchemy 2.0 - Major Migration Guide
- SQLAlchemy Documentation — Relationship Loading Techniques
- Django Documentation — Making queries
- Entity Framework Core Documentation — How EF Core Change Tracking Works