Implementing Efficient Pagination in NHibernate with IQueryable
Learn how to implement database-level pagination in NHibernate using IQueryable to prevent memory bloat and reduce network latency in large datasets.
17 May 2026, 02:32 UTC

The Problem: Memory Bloat and Latency in Large Datasets
Loading a complete dataset into application memory to display a small subset of records causes two primary failures: excessive RAM consumption on the application server and high network latency between the database and the app. When using NHibernate, failing to constrain the result set at the database level forces the framework to hydrate every entity into the Session cache, leading to OutOfMemoryException errors as the table grows.
The Smallest Suitable Design
The most efficient way to handle result limiting in NHibernate is by leveraging the IQueryable interface combined with the Skip() and Take() LINQ extension methods. This approach ensures that the filtering happens within the database engine via the SQL dialect, rather than pulling the data into C# memory for filtering.
To implement this, your repository or service layer must maintain the query as an IQueryable<T> until the pagination parameters are applied. Only after Skip() and Take() are called should the query be materialized (e.g., via ToList() or FirstOrDefault()).
Trust and Data Boundaries
Pagination parameters—specifically the page index and page size—usually originate from user input (API query strings or UI controls). These must be validated before being passed to the query to prevent denial-of-service (DoS) attacks via extremely large Take() values.
- Page Size Limit: Hard-code a maximum allowable page size (e.g., 100 records) to prevent a user from requesting 1,000,000 rows in a single call.
- Non-Negative Constraints: Ensure
SkipandTakevalues are never negative, as this will trigger a runtime exception in the LINQ provider.
Concrete Implementation Example
The following example demonstrates a paginated fetch for a Product entity. This assumes NHibernate 5.x+ and a configured SQL Server or PostgreSQL dialect.
// Run this within a service layer with access to an ISession
public async Task<List<Product>> GetPagedProducts(int pageNumber, int pageSize)
{
// 1. Validate inputs to protect database resources
int validatedPageSize = Math.Min(pageSize, 100);
int validatedSkip = (pageNumber - 1) * validatedPageSize;
// 2. Build the IQueryable
// IMPORTANT: OrderBy is mandatory for deterministic pagination
var query = _session.Query<Product>()
.OrderBy(p => p.CreatedDate)
.Skip(validatedSkip)
.Take(validatedPageSize);
// 3. Materialize the query
return await query.ToListAsync();
}
Operational Checks and Verification
To verify that pagination is occurring at the database level and not in-memory, you must inspect the generated SQL. If the SQL contains SELECT * FROM ... without a TOP, LIMIT, or OFFSET clause, the pagination is failing and occurring in the application layer.
Verification Steps:
- Enable SQL logging (e.g., via
log4netorNHibernate.Profiling). - Execute the paginated request.
- Confirm the presence of
OFFSET x ROWS FETCH NEXT y ROWS ONLY(SQL Server) orLIMIT y OFFSET x(PostgreSQL). - Compare the count of the returned list against the
pageSizevariable.
Failure Modes and Limitations
The Cartesian Product Trap
Applying Skip() and Take() to queries involving FetchMany or Join operations on one-to-many relationships can produce incorrect results. Because the database returns multiple rows for a single parent entity (one for each child), NHibernate may "count" the child rows toward the Take() limit, resulting in fewer parent entities than requested.
Deep Paging Performance
As the Skip value increases (e.g., skipping 100,000 rows to get to page 1,000), performance degrades. The database must still scan through the preceding rows to find the starting point. For extremely large datasets, consider "Keyset Pagination" (filtering by the last ID of the previous page) instead of Skip().
Conditions for Design Change
You should move away from Skip/Take pagination if any of the following occur:
- Requirement for Real-time Accuracy: If the dataset changes rapidly, users may see duplicate items across pages. This requires a cursor-based approach.
- Complex Joins: If you must fetch multiple collections per entity, move to a two-step fetch: first fetch the paginated IDs, then fetch the full entities using those IDs.
- Extreme Scale: If the
OFFSETvalue consistently exceeds 10,000, the latency will become unacceptable, necessitating a search-index (like Elasticsearch) or keyset pagination.
Rollback: Since this operation is a read-only query, there is no state change to roll back. To revert the logic, remove the Skip() and Take() calls to return the full dataset.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.