An object graph loaded from a database exposes related objects as ordinary fields, but reading one of those fields can fire a query, or fail outright. Compare how object-relational mappers in different languages handle that leak.
answer
- field read that is secretly I/O
- silent / loud / impossible
- Hibernate LazyInitializationException + N+1
- SQLAlchemy raiseload, Rails strict_loading
- Ecto NotLoaded then Repo.preload
basics
~20 sThree doctrines: silent (Hibernate proxies and Rails associations query on field access, or throw once detached), loud (SQLAlchemy's raiseload, Rails strict_loading), or impossible (Elixir's Ecto returns a NotLoaded struct until you preload). Transparency is what makes the leak silent.
solid answer
~50 sThe leak is that a field read is secretly I/O whose cost depends on invisible session state. - **Hibernate (Java/JPA)**: a proxy field read inside a session issues a query per element (N+1); outside one it throws LazyInitializationException. Same expression, three behaviours. - **ActiveRecord (Ruby)**: always silent, never throws — Rails 6.1 added `strict_loading` to convert the implicit load into an exception. Price: opt-in, so old code stays silent. - **SQLAlchemy (Python)**: mirrors Hibernate's DetachedInstanceError, but `raiseload()` / `lazy="raise"` lets you declare "this must be eager", turning an accidental lazy load into a test failure. - **Ecto (Elixir)**: no lazy loading at all — an unloaded association is `%Ecto.Association.NotLoaded{}` and you call `Repo.preload`. The leak cannot occur; the price is that every read path must state its preloads. - **EF Core (C#)**: lazy loading off by default, and since 3.0 unsupported LINQ throws instead of evaluating client-side — same doctrine, fail loudly.
code
elixir · 5 linespost = Repo.get(Post, 1)
post.comments # %Ecto.Association.NotLoaded{} - no query, no crash
post = Repo.preload(post, :comments)
post.comments # a list; the query happened where you wrote preloadgo deeper
Know that reading a related field can trigger a query, and that doing it in a loop causes the N+1 pattern.
Contrast the failure modes: silent extra queries versus an exception once the session is gone, and name a tool switch that makes the implicit load loud.
Argue where the fetch plan belongs (use case, not mapping annotation), name at least two ecosystems that fail loudly or forbid laziness, and describe query-count assertions as the diagnostic.
Treat it as a general API principle: an operation whose cost is invisible at the call site cannot be reasoned about compositionally; choose per system whether you buy convenience or enforceability, and make the choice uniform.
## The abstraction and its promise An object-relational mapper promises that a row and its related rows are just objects: `order.customer.name` is field access. That promise is the value proposition — domain code that does not mention SQL — and it is exactly what leaks, because the mapper must decide *when* the related data is fetched. Eager fetching loads everything up front and wastes work. Lazy fetching defers, which means a plain field read may issue a query, may block on the network, may join a transaction, or may fail because the session that could have issued the query is gone. The syntax gives the reader no clue which. ## The three doctrines **Silent transparency.** Hibernate returns a proxy or a lazy collection; touching it inside an open session issues SQL. Iterate a hundred parents touching one association each and you get the N+1 pattern: one query for the parents, one per parent. Touch the same field after the session closes and you get LazyInitializationException — an error whose cause is not in the failing line but in a transaction boundary elsewhere. Ruby's ActiveRecord takes silence further: there is no detached state to fail on, so N+1 is a purely performance-visible bug that a passing test suite will never notice. **Loud failure.** The second school keeps laziness but makes the implicit load an error you opt into. SQLAlchemy's `raiseload()` (and `lazy="raise"` on a relationship) raises when code touches an association the query did not load, so a unit test catches the leak at development time instead of a dashboard catching it in production. Rails 6.1's `strict_loading` does the same for ActiveRecord, per record, per association, or application-wide for new code. Both are opt-in, which is the honest admission that a whole existing codebase depends on the silent behaviour. **Make it impossible.** Ecto removes lazy loading from the design. An association you did not load is a `%Ecto.Association.NotLoaded{}` value; reading it yields that struct rather than data, and there is no hidden identity map or session to make a query happen behind your back. Fetching is `Repo.preload(post, :comments)` or an explicit join in the query. There is no N+1 you can write by accident, because no field read performs I/O. The price is real: generic traversal code cannot walk an unknown graph, every read path enumerates its preloads, and a refactor that needs one more association must change the query, not just the render. **Fail rather than degrade.** EF Core belongs to the same family from the other direction: lazy loading was left out of EF Core 1.0 and returned only as an opt-in proxies package, and EF Core 3.0 stopped silently evaluating unsupported LINQ predicates in memory — previously a query that could not translate to SQL would quietly pull the table into the client and filter there. Both changes trade convenience for a loud failure at the boundary. ## How to recognise the leak The signature is a mismatch between what the code says and what the database sees: a page that reads like ten field accesses producing four hundred queries; an exception whose stack ends in a getter or a template; a method that works in a test with an open transaction and fails behind a controller. Query counting is the diagnostic — assert the number of statements a use case issues, not just its result. Ecto users get this free because the count is written in the code. ## Designing around it Decide the fetch plan at the use-case boundary, where you know what will be rendered, not in the mapping annotations, which have to guess for every caller at once. Keep entities' lazy state inside the transaction that owns them and hand out a projection (DTO, view struct, read model) across the boundary, so nothing outside can trigger I/O. Where the tool offers a loud mode, turn it on for new code (`raiseload`, `strict_loading`) and let tests fail. And take the doctrine seriously as an API lesson: making a costly operation look like a cheap one is the leak; the fix is not a smarter cache but a call site that admits it is doing work.
- Ecto forbids lazy loading entirely. What does an application lose by that, and how do teams cope?It loses generic traversal: a renderer handed an arbitrary struct cannot walk to related data on demand, so every read path must know its own preloads up front, and adding a field to a view often means editing the query too. Teams cope by defining query functions per use case that state the full fetch plan, which is the same discipline other ecosystems recommend but rarely enforce.
- Why does a lazy-loading failure so often surface in a template or serializer rather than in the data layer?Because the field read is the trigger, and the last code to read fields is the rendering layer, often after the transaction that could satisfy the read has closed. The stack trace therefore points at the victim, not the cause — the fetch plan chosen much earlier. Handing projections rather than entities across the boundary makes the failure impossible to reach that layer.
saying these in an interview costs you the question
- Calling N+1 a database problem rather than an abstraction-boundary decision about when to fetch
- Proposing eager-load-everything as the fix, which trades many small queries for one huge cartesian result
- Saying open-session-in-view solves it — it hides the exception while leaving query counts unbounded and the transaction open for the whole request
- Assuming a passing test suite proves absence of the leak when nothing counts queries