ORMs & Data-Access Patterns
An object-relational mapper (ORM) translates between an application’s object model — a Customer holding a list of Orders — and a relational schema of flat tables joined by keys. It maps classes to tables and rows to objects, generates SQL from method calls or a query API, and tracks changes to loaded objects so it can write them back. This page covers the structural mismatch ORMs bridge, the two main ORM architectures, loading strategies and the N+1 problem, transactions and concurrency control through an ORM, and when to bypass the ORM for SQL. Python examples use SQLAlchemy 2.x-style APIs and Django; JavaScript/TypeScript examples use Prisma, Drizzle, and Sequelize.
What an ORM Provides
# Without an ORM: SQL strings and hand-written hydration
rows = conn.execute(
"SELECT id, name, email FROM customers WHERE country = %s", ("US",)
).fetchall()
customers = [Customer(id=r[0], name=r[1], email=r[2]) for r in rows]
# With an ORM (SQLAlchemy 2.x): describe the mapping once, query in the host language
customers = session.scalars(
select(Customer).where(Customer.country == "US")
).all()
| Benefit | Cost |
|---|---|
| Productivity — CRUD, relationships, and pagination become method calls | The SQL actually executed is hidden, and can be far more than expected |
| Parameter binding by default, which closes the common SQL-injection routes | Raw-SQL escape hatches reintroduce the risk if misused |
| Change tracking — mutate objects, the ORM computes the writes | Tracked objects consume memory; long sessions go stale |
| Portability across PostgreSQL, MySQL, SQLite, SQL Server | Lowest-common-denominator features unless you opt into dialect extensions |
| Typed models that integrate with IDEs and type checkers | A second schema definition that must stay in sync with the database (migrations) |
The rest of this page is about getting the benefits without paying the hidden costs.
The Object-Relational Impedance Mismatch
The object and relational models were designed under different assumptions. Each gap requires an explicit mapping decision:
| Object world | Relational world | Consequence |
|---|---|---|
Object identity (a is b) |
Primary-key equality | Two queries could return two objects for one row; an identity map (one object per key per session) prevents it |
| References | Foreign keys + joins | Following order.customer may silently execute a query |
Collections (list, set) |
Rows in a child table | Loading is a separate SELECT or a join; mutation becomes inserts and deletes |
| Inheritance, polymorphism | No subtyping | Must be emulated (below) |
| Encapsulation | Public columns | The schema exposes structure the class hides |
| Object graphs of any shape | Normalized tables | Saving one aggregate may touch many tables in one transaction |
| Behavior | Data only | Rules enforced in methods are invisible to other clients; enforce invariants with constraints too |
Mapping inheritance
Suppose Employee has subtypes Manager and Engineer. There are three standard mappings, supported in some form by SQLAlchemy, Hibernate/JPA, Entity Framework Core, and Django (via abstract base classes and multi-table inheritance):
| Strategy | Tables | Reads | Trade-off |
|---|---|---|---|
| Single-table (STI) | One table, all columns, a discriminator | No joins | Subtype columns must be nullable, so NOT NULL cannot be enforced per subtype |
| Joined-table (class-table) | Base table + one per subtype, sharing the key | Join per subtype read | Normalized and constrainable; polymorphic queries join several tables |
| Concrete-table | One independent table per concrete class | “All employees” needs a UNION
|
No shared key space; foreign keys to “any employee” are impossible |
# SQLAlchemy 2.x single-table inheritance
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Employee(Base):
__tablename__ = "employees"
id: Mapped[int] = mapped_column(primary_key=True)
type: Mapped[str] = mapped_column(String(20)) # discriminator
name: Mapped[str] = mapped_column(String(100))
__mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}
class Manager(Employee):
# Nullable in the shared table, since engineers have no value here
reports_to: Mapped[int | None] = mapped_column(ForeignKey("employees.id"))
__mapper_args__ = {"polymorphic_identity": "manager"}
Single-table is usually the default when subtypes differ by a few columns; joined-table suits subtypes with many distinct, required attributes. The underlying trade-off is the normalization-versus-join-cost tension from Data Modeling & Normalization.
ORM Architectures
Martin Fowler’s Patterns of Enterprise Application Architecture (2002) named the two dominant designs:
flowchart LR
subgraph AR["Active Record"]
direction TB
U1["user = User.find(1)<br/>user.name = 'Ann'<br/>user.save()"] --> DB1[(Database)]
end
subgraph DM["Data Mapper + Unit of Work"]
direction TB
O["Plain objects<br/>(no persistence code)"] <--> S["Session / DbContext<br/>identity map, change tracking"]
S -->|"flush / commit"| DB2[(Database)]
end
-
Active Record — each model instance wraps a row and persists itself (
user.save()). Simple and discoverable, but couples domain objects to the database, and each save is typically its own write. - Data Mapper — a separate session, entity manager, or context loads plain objects, tracks changes to them, and writes them back as a unit of work. More concepts, but a cleaner domain model and batched, correctly ordered writes.
- Query-builder-first tools sit alongside these: they expose a typed, composable SQL API and return plain data, with little or no change tracking.
| Tool | Language | Style | Notable traits |
|---|---|---|---|
| SQLAlchemy | Python | Data Mapper (ORM) over a SQL expression layer (Core) | Unified select() API in 2.x; full SQL expressiveness; asyncio support |
| Django ORM | Python | Active Record | Integrated migrations and admin; lazy QuerySets; composite primary keys since 5.2 |
| Hibernate / Jakarta Persistence (JPA) | Java | Data Mapper | Persistence context, dirty checking, JPQL/HQL, second-level cache |
| Entity Framework Core | C# / .NET | Data Mapper (DbContext unit of work) |
LINQ queries; change tracker; compiled models |
| Prisma | TypeScript | Schema-first generated client | Declarative schema file; fully typed client; relations loaded only on request. Prisma 7 (late 2025) replaced the Rust query engine with a TypeScript query compiler |
| Drizzle | TypeScript | Query builder + relational query API | Schema in TypeScript; SQL-shaped API; no runtime engine |
| Sequelize | JavaScript / TypeScript | Active Record | Long-established Node ORM; promise-based |
The same filter in several of them:
# SQLAlchemy 2.x
session.scalars(select(Order).where(Order.total > 1000)).all()
# Django
Order.objects.filter(total__gt=1000)
// Prisma
await prisma.order.findMany({ where: { total: { gt: 1000 } } });
// Drizzle
await db.select().from(orders).where(gt(orders.total, 1000));
// Sequelize
await Order.findAll({ where: { total: { [Op.gt]: 1000 } } });
All emit essentially SELECT ... FROM orders WHERE total > $1. They differ in what comes back (tracked entities, model instances, or plain objects) and in what happens when you touch a relationship on the result.
Sessions and the Unit of Work
In a Data Mapper ORM the session (SQLAlchemy Session, JPA EntityManager, EF Core DbContext) is the unit of work. It holds an identity map of loaded objects, remembers their original state, and at flush time compares current to original state (dirty checking) and emits the minimal set of INSERT, UPDATE, and DELETE statements, ordered to satisfy foreign keys. Commit flushes and then commits the database transaction.
SQLAlchemy’s object lifecycle makes this concrete:
stateDiagram-v2
[*] --> Transient: Order()
Transient --> Pending: session.add()
Pending --> Persistent: flush (INSERT)
Persistent --> Persistent: attribute change, flush (UPDATE)
Persistent --> Deleted: session.delete(), flush
Deleted --> Detached: commit
Persistent --> Detached: session.close() / expunge()
Detached --> Persistent: session.merge() / add()
with Session(engine) as session:
order = session.get(Order, 42) # SELECT; object is now persistent
order.status = "shipped" # no SQL yet, just a tracked change
session.commit() # flush: UPDATE orders SET status=... WHERE id=42; COMMIT
Practical rules:
- Scope a session to one unit of work — typically one web request or one job — and close it. Long-lived sessions accumulate objects, hold stale data, and keep transactions open.
- Understand expiry. By default SQLAlchemy expires objects on commit, so the next attribute access reloads them; Hibernate and EF Core keep tracked state until the context is cleared.
-
Detached objects cannot lazy-load. Accessing an unloaded relationship after the session closes raises
DetachedInstanceError(SQLAlchemy) orLazyInitializationException(Hibernate). Load what you need before closing. -
Use read-only paths for read-only work. Change tracking costs memory and CPU. EF Core’s
AsNoTracking(), Hibernate’s read-only queries orStatelessSession, and selecting plain columns instead of entities all avoid it.
Loading Strategies and the N+1 Problem
The N+1 problem
The most common ORM performance bug appears when code loads N parent objects and then touches a lazily loaded relationship on each one inside a loop:
customers = session.scalars(select(Customer)).all() # 1 query
for c in customers:
print(c.name, len(c.orders)) # each c.orders triggers its own SELECT: N queries
With 1,000 customers this issues 1,001 queries. Each is fast, but round trips dominate: at 1 ms each the loop spends a second waiting on the network. The code is correct and fast with ten rows of test data, which is why the problem usually surfaces only in production.
sequenceDiagram
participant App
participant DB
Note over App,DB: Lazy loading (N+1)
App->>DB: SELECT * FROM customers
loop for each of N customers
App->>DB: SELECT * FROM orders WHERE customer_id = ?
end
Note over App,DB: selectinload / prefetch_related (2 queries)
App->>DB: SELECT * FROM customers
App->>DB: SELECT * FROM orders WHERE customer_id IN (...)
Eager loading
Eager loading fetches related rows up front, so the number of queries no longer depends on N. There are three mechanisms:
| Strategy | SQL | Queries | Best for | Watch out for |
|---|---|---|---|---|
Join (joinedload, select_related, EF Include) |
LEFT JOIN in the parent query |
1 | Many-to-one and one-to-one | To-many joins repeat each parent once per child (“cartesian explosion”); two to-many joins multiply |
Select-IN (selectinload, prefetch_related) |
Second query with WHERE fk IN (...)
|
1 per relationship | One-to-many and many-to-many | Very large key lists are batched automatically |
| Subquery / lateral / JSON aggregation | Correlated subquery or LATERAL join building nested JSON |
1 | Tree-shaped API responses | Engine-specific; used internally by Prisma and Drizzle on some databases |
from sqlalchemy.orm import selectinload, joinedload
# Collections: select-IN loading, 2 queries total
customers = session.scalars(
select(Customer).options(selectinload(Customer.orders))
).all()
# Many-to-one: join loading, 1 query
orders = session.scalars(
select(Order).options(joinedload(Order.customer))
).all()
# Django
from django.db.models import Prefetch
Customer.objects.prefetch_related("orders") # to-many: 2 queries
Order.objects.select_related("customer") # to-one: 1 JOIN
Customer.objects.prefetch_related(
Prefetch("orders", queryset=Order.objects.filter(status="open"))
) # filtered prefetch
// Prisma: relations are never lazy-loaded; ask for them explicitly
await prisma.customer.findMany({ include: { orders: true } });
// Drizzle relational queries
await db.query.customers.findMany({ with: { orders: true } });
// Sequelize: `separate: true` switches a to-many include from JOIN to a second query
await Customer.findAll({ include: [{ model: Order, separate: true }] });
Because Prisma and Drizzle only load relations you request, they cannot produce N+1 through attribute access — but a loop that issues its own query per item still can.
Preventing and detecting N+1
The most reliable defense is to make accidental lazy loads fail loudly:
# SQLAlchemy: raise instead of silently lazy-loading
from sqlalchemy.orm import relationship, raiseload, selectinload
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
orders: Mapped[list["Order"]] = relationship(lazy="raise")
# ...or per query: forbid any relationship not explicitly loaded
session.scalars(select(Customer).options(selectinload(Customer.orders), raiseload("*")))
Under SQLAlchemy’s asyncio extension implicit lazy loading is not possible at all (it raises MissingGreenlet), which forces explicit eager loading in async code.
Detection tools:
-
Log the SQL —
create_engine(url, echo=True)in SQLAlchemy; Django’sconnection.querieswithDEBUG=True, or django-debug-toolbar; Hibernate’s statistics ororg.hibernate.SQLlogging; EF Core’sLogTo. -
Assert query counts in tests — Django’s
assertNumQueries, or an SQLAlchemy event listener countingbefore_cursor_executecalls — so an N+1 regression fails CI. -
Watch production — APM tools and
pg_stat_statements(see Operations & Monitoring) show one cheap query with an enormous call count, the N+1 signature.
Transactions and Concurrency
A transaction groups operations so they commit or roll back together. ORMs provide scoped transaction blocks that commit on success and roll back on an exception:
# SQLAlchemy: session.begin() commits at block exit, rolls back on exception
with Session(engine) as session, session.begin():
a = session.get(Account, 1)
b = session.get(Account, 2)
a.balance -= 100
b.balance += 100
# Django
from django.db import transaction
from django.db.models import F
with transaction.atomic():
Account.objects.filter(pk=1).update(balance=F("balance") - 100)
Account.objects.filter(pk=2).update(balance=F("balance") + 100)
// Prisma interactive transaction
await prisma.$transaction(async (tx) => {
await tx.account.update({ where: { id: 1 }, data: { balance: { decrement: 100 } } });
await tx.account.update({ where: { id: 2 }, data: { balance: { increment: 100 } } });
});
Note the Django version: F("balance") - 100 is computed by the database (SET balance = balance - 100), so concurrent transfers cannot overwrite each other. Loading a value into Python, changing it, and saving it back is a read-modify-write race unless something prevents it — pessimistic or optimistic locking.
Pessimistic locking
Lock rows when reading them, for short critical sections within one request:
# SQLAlchemy: SELECT ... FOR UPDATE
acct = session.scalars(
select(Account).where(Account.id == 1).with_for_update()
).one()
Django’s equivalent is select_for_update(); JPA uses LockModeType.PESSIMISTIC_WRITE. For job queues, with_for_update(skip_locked=True) lets several workers claim different rows without blocking each other.
Optimistic locking
Holding locks across user think-time does not scale. Optimistic locking adds a version column; every UPDATE includes the version the application read and increments it:
UPDATE orders
SET status = 'shipped', version = version + 1
WHERE id = 42 AND version = 7; -- 7 = the version read earlier
If another transaction updated the row first, the WHERE matches zero rows and the ORM raises an error — StaleDataError in SQLAlchemy (version_id_col), OptimisticLockException in JPA (@Version), DbUpdateConcurrencyException in EF Core — which the application handles by reloading and retrying or reporting a conflict. Django has no built-in version column; use a conditional filter(pk=..., version=v).update(...) and check the returned row count.
Conflicts are rare when writes spread over many rows and frequent on hot rows. If $W$ writers each update one of $N$ equally likely rows at the same moment, the probability that a given writer collides with at least one other is
\[P_{\text{conflict}} = 1 - \left(1 - \frac{1}{N}\right)^{W-1} \approx \frac{W-1}{N} \quad \text{for } W \ll N\]A high conflict rate is a signal to change the model (split the hot row, use a counter table, or apply database-side increments) rather than to add retries.
When to Drop to SQL
ORMs handle routine work well and complex or bulk work poorly. Use SQL — through the ORM’s expression language or parameterized raw queries — for:
-
Bulk writes. Updating 100,000 rows as objects means loading them all and issuing statements in batches; one set-based statement does it in a single round trip.
from sqlalchemy import update, insert # One UPDATE, no objects loaded session.execute( update(Product).where(Product.discontinued.is_(True)).values(active=False) ) # Bulk INSERT from dictionaries (batched multi-row VALUES) session.execute(insert(Product), [{"sku": "A1", "price": 10}, {"sku": "A2", "price": 12}])Django’s
QuerySet.update(),bulk_create(), andbulk_update()serve the same purpose; note that they bypasssave()and model signals. -
Analytical queries — window functions,
GROUPING SETS/ROLLUP, recursive CTEs — that map poorly onto objects. -
Vendor features —
INSERT ... ON CONFLICTupserts,jsonboperators, full-text search,pgvectorsimilarity,LATERALjoins. Many are reachable through dialect-specific constructs (SQLAlchemy’spostgresql.insert(...).on_conflict_do_update()) before resorting to raw strings. -
Hot paths where profiling and
EXPLAIN ANALYZE(see Indexing & Query Execution) show the generated SQL is the problem.
Raw SQL must still bind parameters — never interpolate input into the string:
from sqlalchemy import text
rows = session.execute(
text("SELECT id, total FROM orders WHERE total > :min ORDER BY total DESC"),
{"min": 1000},
).all()
// Prisma: the tagged template binds ${} values as parameters.
const orders = await prisma.$queryRaw`SELECT * FROM "Order" WHERE total > ${minTotal}`;
// $queryRawUnsafe(string) does NOT - avoid it with any user input.
A healthy codebase uses a spectrum of abstraction levels, choosing the highest level that serves each query well:
flowchart LR
A["ORM entities<br/>routine CRUD"] --> B["Query builder / expression language<br/>dynamic filters, projections"]
B --> C["Parameterized raw SQL<br/>vendor features, tuned hot paths"]
C --> D["Database functions / views<br/>logic shared by many clients"]
Step down deliberately, with a profile or query plan justifying the move. For dynamic filtering, the query builder beats string concatenation on both safety and readability:
stmt = select(Product)
if max_price is not None:
stmt = stmt.where(Product.price <= max_price)
if category:
stmt = stmt.where(Product.category == category)
stmt = stmt.order_by(Product.name).limit(50)
Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| N+1 queries | Query count grows with result size | Eager-load; make lazy loads raise |
| Join-loading collections | Duplicated parent rows; huge result sets | Select-IN loading for to-many |
| Counting by loading |
len(query.all()) is slow and memory-hungry |
select(func.count())..., .count()
|
| Over-fetching | Every query reads every column, including large text/JSON | Project needed columns (load_only, .only(), select: {...}) |
| Row-by-row writes | Thousands of single-row UPDATEs |
Set-based update() / bulk insert |
| Read-modify-write races | Lost updates under concurrency | Database-side expressions, SELECT ... FOR UPDATE, or version columns |
| Lazy load after session close |
DetachedInstanceError, LazyInitializationException
|
Load before closing; shorter, request-scoped sessions |
| Long-lived sessions | Stale data, memory growth, open transactions blocking VACUUM | One session per unit of work |
| Model and schema drift | Runtime errors after deploys | Generate migrations from models and review them (Schema Evolution) |
Unbounded pagination with OFFSET
|
Deep pages get slower | Keyset (cursor) pagination on an indexed column |
| Fighting the ORM | Convoluted chains to express one query | Write that query in SQL |
See Also
- Data Modeling & Normalization — the schema the ORM maps onto
-
Indexing & Query Execution — reading
EXPLAINfor ORM-generated SQL -
Transactions & Concurrency — isolation levels and locking behind
commit() - Schema Evolution & Migrations — Alembic and other migration tools
-
Operations & Monitoring — connection pooling and
pg_stat_statements - Database Design hub