←All posts

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.

ORMDesign PatternsPythonDatabaseArchitecture

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:

ComponentResponsibilityPoEAA Pattern
Metadata / Schema DefinitionDescribe the mapping between tables and classesMetadata Mapping
SQL Expression Tree / Query BuilderBuild SQL statements from Python expressionsQuery Object
Identity MapMap the same row to the same object instanceIdentity Map
Unit of WorkTrack changes and batch-write them to the DBUnit of Work
Relationship LoadingLazy or eager loading of related objectsLazy 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 executed

Active 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 UPDATE

Data 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.

AspectActive RecordData Mapper
Domain object responsibilityData + DB operationsData only
Table-to-class relationshipOne-to-oneFreely mappable
Learning curveLowHigh
TestabilityDB coupling tends to leak inDomain logic easily unit-tested
Representative ORMsDjango ORM, Rails AR, EloquentSQLAlchemy, 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 objects

Object 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._map

4.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 reflected

In 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 = 1

Two 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 Comparison

Save 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 dirty
Approach 2: Descriptor-based Interception

Use 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 executed

In 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 reverse

Chapter 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-one

6.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 descriptor
user = 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 executed

6.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 queries

With 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-based

The 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_id
Approach 2: Subquery (IN clause)
def 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/OneToOne

6.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.

StrategyQuery countMemory usageBest for
Lazy loading1 + N (worst case)MinimalWhen related objects are never accessed
JOIN eager loading1High (duplicate rows)One-to-one relations, small datasets
Subquery eager loading2MediumOne-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 issued

Chapter 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 ImplementationSQLAlchemy Equivalent
Table, ColumnTable, Column (Core), mapped_column (ORM)
Registryregistry, MetaData
ModelMeta (metaclass)DeclarativeBase, DeclarativeMeta
Expression, BinaryOpClauseElement, BinaryExpression (Core)
ColumnExpr (operator overloads)PropComparator, InstrumentedAttribute
QueryQuery (Legacy) / select() (2.0 style)
IdentityMapSession.identity_map (IdentityMap class)
UnitOfWorkSession._flush() via UOWTransaction
SnapshotTrackerInstanceState._committed_state
TrackedAttribute (descriptor)InstrumentedAttribute + AttributeEvents
SessionSession
Topological sortDependency 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 ImplementationDjango ORM Equivalent
Table, ColumnOptions (_meta), Field
ModelMetaModelBase (metaclass)
Expression, BinaryOpQ objects, F objects, Lookup
QueryQuerySet (lazy evaluation chain)
IdentityMapNone (Django has no built-in Identity Map)
UnitOfWorkNone (save() immediately issues SQL)
Lazy loadingDefault ForeignKey behavior
Eager loadingselect_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 ImplementationEF Core Equivalent
RegistryModelBuilder, IModel
Expression, BinaryOpExpression Tree (LINQ)
QueryIQueryable<T> (LINQ to Entities)
IdentityMapDbContext Change Tracker
UnitOfWorkDbContext.SaveChanges()
Change trackingChangeTracker (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
Solutions:
  1. Explicitly specify eager loading — SQLAlchemy's joinedload() / selectinload(), Django's select_related() / prefetch_related()
  2. 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
  3. 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 processing

In 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.

ComponentCore Idea
MetadataDeclarative definition via metaclass + registry
SQL Expression TreeOperator overloads → AST nodes → SQL strings
Identity MapDictionary keyed by primary key guarantees identity
Unit of WorkAccumulate changes in memory, flush all at once
RelationshipsDescriptors for lazy loading / JOIN or IN for eager loading
SessionUnified 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