NHibernate Future Queries: Batching Reads Without the Ceremony
NHibernate Future Queries batch multiple reads into a single database round trip. This post shows the API, a concrete dashboard example, driver behavior differences, and the limits you'll hit with stateless sessions, parameter caps, and all-or-nothing error handling.
19 Dec 2025, 16:53 UTC

The Round-Trip Tax
You're loading a dashboard. The view needs the current user, their recent orders, notification count, and a few reference lists. In a typical NHibernate application, that's four separate session.Query<T>() calls — four round trips to the database. On a local network that's negligible; over a cloud VPC with 2 ms latency, it adds up. Multiply by concurrent users and you're burning connection pool slots waiting for TCP acknowledgments.
NHibernate has had a built-in answer since 2010: Future Queries. They let you register multiple queries on the same ISession, then execute them all in a single database round trip when you first enumerate any result. No external libraries, no async/await restructuring, no changes to your mapping strategy.
How the Batching Actually Works
When you call .Future<T>() (Criteria/QueryOver) or .ToFuture() (LINQ), NHibernate doesn't hit the database. It stores the query in a list attached to the ISession. The moment you touch the first future — foreach, .ToList(), or accessing FutureValue.Value — NHibernate builds a multi-statement command and sends it over the existing connection.
The driver determines the wire format:
- SQL Server (Microsoft.Data.SqlClient): wraps statements in
sp_executesqlwith multiple result sets. - PostgreSQL (Npgsql) and MySQL (MySqlConnector): sends semicolon-separated statements in one packet; the driver exposes
NextResult()to advance through result sets.
Results materialize in registration order. The first future yields its IList<T>, the second yields its own, and so on. FutureValue<T> returns a single entity or null/default(T).
Worked Example: Dashboard Load in One Round Trip
Assume a typical e-commerce domain. The old way:
var user = session.Get<User>(userId);
var orders = session.Query<Order>().Where(o => o.UserId == userId).Take(5).ToList();
var notifications = session.Query<Notification>().Count(n => n.UserId == userId && !n.IsRead);
var categories = session.Query<Category>().OrderBy(c => c.Name).ToList();
Four round trips. With futures:
using NHibernate.Linq; // required for ToFuture extensions
var userFuture = session.Query<User>().Where(u => u.Id == userId).ToFutureValue();
var ordersFuture = session.Query<Order>()
.Where(o => o.UserId == userId)
.OrderByDescending(o => o.CreatedAt)
.Take(5)
.ToFuture();
var notificationsFuture = session.Query<Notification>()
.Where(n => n.UserId == userId && !n.IsRead)
.ToFutureValue();
var categoriesFuture = session.Query<Category>()
.OrderBy(c => c.Name)
.ToFuture();
// Single round trip happens here
var user = userFuture.Value;
var orders = ordersFuture.ToList(); // enumerates, triggers batch
var notificationCount = notificationsFuture.Value;
var categories = categoriesFuture.ToList();
Enable show_sql in your configuration and you'll see one batch in the log. In SQL Server Profiler or pg_stat_activity, you'll observe a single sp_executesql call or one multi-statement packet.
Where It Pays Off — and Where It Doesn't
Good fits
- Read-heavy screens that aggregate unrelated entities (dashboards, search results with facets).
- Latency-bound environments: cross-region DB, containerized apps with sidecar proxies.
- Existing NHibernate codebases where introducing Dapper or raw SQL for just this scenario feels like overkill.
Limitations to respect
- Stateless sessions don't support futures.
IStatelessSessionthrowsNotSupportedException. Use regularISessionfor batched reads; keep stateless sessions for bulk inserts/updates. - Driver limits. SQL Server caps parameters per batch at 2,100. Fifty paginated queries each with a few parameters can hit that ceiling. Profile with your actual query count.
- All-or-nothing failure. If any statement in the batch errors, the whole batch aborts. You get a single
ADOExceptionwrapping the provider error — no partial results, no per-query isolation unless you spin up separate sessions. - Connection held until enumeration finishes. Long-running
foreachover a large future keeps the connection checked out. Stream results or materialize quickly (.ToList()) to return the connection to the pool. - Custom
IResultTransformermust be serializable. NHibernate re-instantiates transformers during deferred execution; non-serializable ones break the batch.
Verification Checklist Before You Ship
- Create a minimal console app referencing NHibernate 5.4+, point at your target DB (SQL Server or PostgreSQL).
- Enable
show_sqland run two.ToFuture()queries on the same session. - Confirm a single round trip in your DB's profiler or
pg_stat_activity. - Inject a syntax error in one query (e.g., invalid column) and verify the exception wraps the provider error with no partial data returned.
- Load-test with your actual query count and parameter volume to ensure you stay under driver limits.
Closing Thought
Future Queries aren't a silver bullet — they're a focused tool for a specific problem: reducing round-trip latency when you already know the queries you need. They work within NHibernate's transaction and first-level cache semantics, require zero mapping changes, and have been stable for over a decade. Next time you see a dashboard making five sequential .ToList() calls, register them as futures, touch the first result, and watch the profiler. The latency drop is often immediate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.