Diagnosing NHibernate LazyInitializationException in Session-Per-Request Web Apps
LazyInitializationException means a proxy was touched after its ISession closed. Diagnose the lifetime-vs-access mismatch, then fix it with explicit fetches, DTO projections, or scoped initialization.
03 Sept 2026, 10:07 UTC

The recognizable condition
Your web request worked in the controller, then blew up during view rendering or JSON serialization with LazyInitializationException: could not initialize proxy - no Session (message text varies slightly by NHibernate version). The stack trace points at a property getter or collection enumeration, not at a database call. That is the signature: NHibernate handed you a proxy — a runtime-generated stand-in for a related entity or collection — and by the time something touched it, the ISession that was supposed to load the real data on demand was already closed or disposed.
The useful takeaway: this exception is almost never a bug in the property that throws. It is a mismatch between when the session ends and when the object graph is traversed. Fix the lifetime or the fetch plan, not the getter.
Symptom-to-cause table
| What you observe | Likely root cause |
|---|---|
| Exception during view rendering or serialization | Session-per-request scope ends before the view runs; lazy proxy accessed after close |
| Exception only on some requests, works in tests | Tests keep the session open longer (or use in-memory data); production scope is tighter |
| Works, but SQL log shows dozens of extra SELECTs | N+1: lazy collections initialized one-by-one inside an open session — a performance bug that becomes this exception once the session moves |
| Proxy-related null after mapping to a DTO | Entity graph returned where a shaped projection was needed; mapper touched a lazy property |
| Exception after adding second-level cache | Cached entity holds proxies whose session is gone; cache does not initialize associations |
Ordered checks
- Reproduce and read the stack trace. Find the exact property access that throws, then find where the session is disposed (end of request module,
usingblock, or container scope). Confirm the ordering: disposal before access. - Check
session.IsOpenat the access point. Temporarily log it just before the failing line. If it isfalse, you have confirmed a lifetime problem rather than a mapping problem. - Enable SQL logging. Set
show_sql=truein the configuration or attach an interceptor/logger. Run the request and note whether the expected SELECT for the association executes inside the session window. SELECTs appearing after the session closes (or not at all) pinpoint the lazy trigger. - Review the mapping. Look for
lazy="true"(the default for collections and many-to-one in most NHibernate versions) on the association in the stack trace, in hbm.xml, Fluent NHibernate, or mapping-by-code. - Inspect the query. Check HQL, Criteria, QueryOver, or LINQ for a fetch join (
Fetch/ThenFetchin QueryOver,join fetchin HQL). Absence of a fetch means the association was always going to be lazy. - Check transaction and flush boundaries. A session closed at transaction commit inside a service method will orphan proxies used later by the caller.
Fixes tied to findings
If the session closes before a legitimate read
Either extend the session lifetime to cover the read, or initialize what you need before it closes. Explicit initialization is the surgical option, run inside the session scope (requires no special permissions beyond normal data access):
// Inside the service method, while ISession is still open
NHibernateUtil.Initialize(order.Lines);
NHibernateUtil.Initialize(order.Customer);If IsOpen was true and the exception still appears, check that the entity was loaded by this session instance — entities passed across scopes keep a reference to their original session.
If the association should load with the parent
Replace the lazy trigger with an explicit fetch for that use case. With QueryOver:
var order = session.QueryOver<Order>()
.Where(o => o.Id == orderId)
.Fetch(o => o.Lines).Eager
.Fetch(o => o.Customer).Eager
.SingleOrDefault();Fetching is per-query, not per-mapping — this is the preferred fix because it documents intent at the call site. Avoid flipping the mapping to lazy="false" globally: blanket eager loading drags the whole graph into every query and can produce Cartesian products when multiple collections are fetched together, trading the exception for memory pressure.
If the caller only needs a few fields
Project to a DTO instead of returning entities. A select projection returns plain data with no proxies, so nothing can be uninitialized later:
var dto = session.QueryOver<Order>()
.Where(o => o.Id == orderId)
.Select(o => o.Id, o => o.Total, o => o.Customer.Name)
.List<object[]>();If lazy loading is intentional but chatty
Set batch-size on the collection mapping so NHibernate initializes several collections per round trip instead of one. This reduces N+1 without changing semantics, but the session must still be open at access time.
Escalation criteria
Stop patching individual properties and revisit the architecture when: (1) you have added more than two or three NHibernateUtil.Initialize calls to one code path — the use case wants an explicit fetch plan or DTO; (2) you are considering Open Session in View purely to silence the exception — it holds the database connection through view rendering and can exhaust the connection pool under load, so weigh it deliberately rather than as a quick fix; (3) behavior differs between environments after an upgrade — NHibernate 5.x changed proxy generation (from Castle DynamicProxy to its own generator) and LINQ provider behavior, so re-verify fetch semantics against your actual major version rather than assuming older guidance applies.
Verifying the fix
Re-run the failing request with SQL logging on. Success looks like: the association's SELECT executes inside the session window (for eager fetch, as a join in the original query), no SELECTs fire after session disposal, and the exception is gone. Then remove any temporary Initialize calls and confirm the proper fetch plan alone keeps the request green. A load test or at least a loop of representative requests is worthwhile if you changed fetching strategy, since eager joins shift cost from exception risk to query weight.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.