Data AccessGENERALFRAMEWORK-SPECIFICLANGUAGE-SPECIFIC

What an ORM Actually Does

Object method to generated SQL to database, plus the identity map, unit of work and lazy proxies that decide when statements are issued.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

When I call a method on an object, what SQL runs, and when?

The requirement

An engineer writes user.orders.length and a query executes. Before anything about ORM performance can be discussed sensibly, that has to stop being magic.

The obvious build

The ORM maps tables to classes. Reading a property reads a column; saving an object writes a row. It is a thin translation layer.

Why it breaks

Reading a property issues a SELECT. Nothing in the syntax distinguishes an in-memory field access from a network round trip (The N+1 Query Problem).

How it breaks in production
  • Reading a property issues a SELECT. Nothing in the syntax distinguishes an in-memory field access from a network round trip (The N+1 Query Problem).
  • Assigning to a field issues nothing at all, until a flush or commit happens somewhere else entirely — often in middleware you did not write.
  • The same object loaded twice in one request is the same instance, so a mutation in one code path is visible in another, which is either a feature or a very confusing bug.
  • A serialization step that walks every property of an entity triggers every lazy relation on it, turning one response into dozens of queries (Schema Leakage).
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • Mapping: a class is bound to a table, its properties to columns, its relations to foreign keys. This part really is translation.
  • Session / unit of work: the ORM holds a per-request workspace of loaded objects. Changes are recorded in memory and turned into INSERT/UPDATE/DELETE statements later, at flush time.
  • Identity map: within one session, one row means one object. Loading user 42 twice returns the same instance, so equality and mutation behave as you expect within a request and not across requests.
  • Dirty checking: at flush, the ORM compares each loaded object against the snapshot it took at load and writes only what changed. This is why a save() can produce no SQL at all.
  • Lazy relations: a relation is a proxy. Touching it emits a SELECT on first access. That is where the property-access-as-network-call surprise comes from (Eager Loading and Batching).
  • Flush ordering: statements are ordered by dependency (parents before children) and batched where the driver allows, then wrapped in whatever transaction is open (Transactions from Application Code).

From method call to statement

The path from an object operation to bytes on a socket has fixed stages. Naming them lets you answer, for any line of ORM code, "does this touch the network, and when".

What happens between `user.name = x` and a row changing
  1. 1
    Attribute set

    Records the new value on the in-memory instance.

    fails by Nothing happens yet — engineers read this line as a write.

  2. 2
    Session tracks it

    The instance is marked dirty in the unit of work.

    fails by Object not attached to a session: the change is silently local and never persists.

  3. 3
    Flush triggered

    By an explicit call, by a query needing consistent state, or by commit.

    fails by An autoflush before a read issues writes at a moment you did not choose.

  4. 4
    Dirty check

    Compares loaded snapshot with current state, produces the changed column set.

    fails by A normalising getter makes an unchanged field look changed, writing columns you never touched.

  5. 5
    SQL generation + ordering

    Builds parameterized statements and orders them by dependency.

    fails by Insert order surprises with self-referencing or circular relations.

  6. 6
    Driver execution

    Sends statement and parameters on a pooled connection.

    fails by No pool connection available — the flush waits, silently (Connection Pools).

  7. 7
    Commit

    Ends the transaction; the change becomes durable and visible to others.

    fails by A rollback after a successful flush: the statements ran and none of them count.

The line that is a network call

FRAMEWORK-SPECIFICSQLAlchemy: the assignment is deferred to flush. In Django the equivalent user.name = 'Ada'; user.save() issues the UPDATE at save(), and by default writes every column rather than the changed one unless update_fields is passed.

Lazy loading is the mechanism worth internalising, because it makes an ordinary-looking expression into a round trip. The relation is not a list; it is a proxy that will fetch a list the first time anyone asks it anything — including len(), iteration, truthiness or a serializer walking properties.

Reading the SQL log next to the code is what makes this stick. The Python below is SQLAlchemy; the shape is identical in Hibernate and ActiveRecord.

One attribute access, one SELECT
1user = session.get(User, 42)
2# SELECT * FROM users WHERE id = 42
3
4print(user.email) # no SQL: already loaded as a column
5
6print(len(user.orders)) # SELECT * FROM orders WHERE user_id = 42
7 # the proxy resolves here, not at load
8
9user.name = 'Ada' # no SQL: recorded in the session
10session.commit() # UPDATE users SET name = 'Ada' WHERE id = 42
11 # then COMMIT

Three of the five lines look identical in cost and are not. user.email is memory, user.orders is a network round trip, user.name = ... is neither until commit.

The session is a transaction you did not open

Frameworks commonly bind one session to one request and commit it when the handler returns cleanly. That is a defensible default and it means every handler runs inside a transaction whose boundary you did not choose — including the slow external call in the middle of it (External Calls Inside a Transaction).

Knowing where the session opens and closes answers a surprising number of questions: why an object cannot be lazily loaded in a template, why a write vanished, why a connection was held for the duration of an HTTP call to a payment provider, and why a background job that reuses a request-scoped session behaves strangely.

Request-scoped session
session in contextdirty objectsno exceptionCOMMITRequest inMiddleware opens sessionHandler mutates objectsFlush: generated SQLCommit on clean returnPooled connection heldDatabase
UserLLMAgentToolDataDecisionHumanGuardrail

How to build it

Most important first.

  • Turn on SQL logging in development and leave it on. The single highest-value habit in this module is watching statements scroll past while you exercise an endpoint.
  • Know exactly where your framework opens and commits the session — per request, per handler, per service call — because that is the transaction boundary you inherited (Where the Transaction Boundary Goes).
  • Declare relations lazy by default and load them explicitly per use case, so fetching is a decision at the call site rather than a property of the class.
  • Never serialize an entity directly to a response. Map to a DTO with named fields, which also stops accidental relation traversal (Three Models, Not One).
  • Detach or expunge objects you intend to keep past the session, or accept that touching them later raises rather than querying.

What can go wrong

Failure modes
  • Lazy loading after the session closes: an exception at serialization time, in a code path far from the query that was actually missing.
  • An unflushed change lost because the request ended on a path that never committed, with no error anywhere.
  • Dirty checking writing a column you did not mean to change, because a getter normalised a value on read.
  • The identity map returning a stale object: another transaction updated the row, and this session keeps handing back what it loaded.
  • Cascade rules deleting more than intended, because the object graph reached further than the mental model did.
What can race
  • Two sessions load the same row, both modify different fields, both flush. Dirty checking writes only changed columns, which sometimes hides the conflict and sometimes loses one — depending on whether the ORM writes full rows or column subsets (Optimistic Concurrency).
  • The identity map guarantees consistency inside a session, not between sessions. Two concurrent requests hold two objects for the same row with no coordination.
Security
  • Binding a request body onto an entity assigns every matching column, including ones the caller should never control. Bind to an explicit input type and copy named fields (Mass Assignment and Over-Posting).
  • Generated SQL is parameterized; raw fragments passed to filter or ordering helpers are not. Any ORM method that accepts a SQL string is an injection site (SQL Injection).
  • Tenant scoping enforced by a base query or session filter is a good pattern and a fragile one — one query that bypasses the helper reads every tenant's rows (Tenant Isolation).
Misreads
  • "save() writes to the database." It records intent in the session. The write happens at flush, and the durability happens at commit.
  • "Accessing a property is free." A lazy relation is a SELECT. This is the single most expensive misunderstanding in the module.
  • "The ORM caches, so repeated loads are cheap." The identity map is scoped to one session. Across requests there is no cache unless you added one (Caching in Backends).
  • "Dirty checking means I do not need to think about writes." It means you cannot see them in the source. Those are different.

Operating it

How you see it in production
  • Statement log in development; query-count-per-request metric in production. The second is the one that catches regressions.
  • A trace with one span per statement makes the shape obvious: a fan of identical short spans is lazy loading, one long span is a slow query (The Comb: N+1 as a Visible Shape).
  • Flush timing in the log tells you where the transaction really committed, which is often not where you assumed.
What changes at 10x and 100x
  • Session size matters: an ORM that holds ten thousand objects for dirty checking spends real CPU and memory on the comparison. Bulk operations should bypass the session entirely.
  • At higher throughput, the cost that grows is round trips, not mapping. Every lazy relation is a queue slot on the pool (Connection Pools).
  • Long-running processes — workers, schedulers — need explicit session lifetimes, because a session that is never closed is both a memory leak and an idle transaction.
What this costs
  • The unit of work buys you batched, ordered writes and change tracking. It costs you a clear answer to "when does this statement run", which is exactly the question you need during an incident.
  • Lazy by default plus explicit loading is safer and more verbose: every use case must say what it needs, and forgetting means an extra query rather than an error.
  • DTOs everywhere cost mapping code. They buy a response shape that does not change when a column is renamed.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALSession, identity map, dirty checking and lazy proxies are the shared architecture of Hibernate, SQLAlchemy, Django's ORM, ActiveRecord and Entity Framework.
  • FRAMEWORK-SPECIFICDjango's ORM has no unit of work: save() issues an UPDATE immediately and there is no identity map across queries, so the "when does it flush" question does not arise the way it does in SQLAlchemy or Hibernate. Prisma is likewise statement-per-call. Do not carry Hibernate intuitions into them, or the reverse.
  • LANGUAGE-SPECIFICLazy loading is implemented by subclass proxies in Java and by attribute interception in Python and Ruby; in TypeScript there is no equivalent hook, which is why Prisma and Drizzle make relation loading an explicit argument instead.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — proxies, attribute interception and how a library makes a field access run code.