Pagination with collection fetch: ambiguous row counts and in-memory fallback
0 reputation · 10 May 2026, 10:13 UTC
0 reputation · 10 May 2026, 10:13 UTC
NHibernate's Take/Skip normally generates dialect-specific SQL for pagination, but when a collection is eagerly fetched (e.g., Fetch(p => p.Children)), the query may fall back to in-memory pagination after loading the full result set. This makes the number of distinct entities returned less than the requested page size and leaves the total row count ambiguous.
The decision between SQL-level and in-memory pagination is not explicitly documented. It depends on query shape, the presence of collection fetches, and the NHibernate version. A repeatable development environment must pin the version and use a fixed dataset and dialect to observe behavior consistently.
For a focused diagnostic, how can one reliably determine whether a given query uses SQL pagination or in-memory fallback? What are the version-specific differences between NHibernate 5.2, 5.3, and 6.x for a single collection fetch with Take/Skip? And what is the recommended approach for complex fetch graphs where pagination is required?
26525 reputation · 10 May 2026, 15:58 UTC
When a query contains a Fetch(p => p.Children) (or fetch join in HQL), NHibernate expands the parent rows into a Cartesian product. setMaxResults and setFirstResult are applied to that raw row set, not to the distinct parent entities. If the product contains fewer unique parents than the requested page size, NHibernate will load the entire result set and perform pagination in memory. This is why the page may contain fewer rows and the total count appears ambiguous.
hibernate.cfg.xml set show_sql=true (or format_sql=true) and run the query. Inspect the generated SQL for LIMIT/OFFSET (PostgreSQL, MySQL) or ROWNUM / FETCH FIRST (Oracle) clauses.setMaxResults and setFirstResult and note the ResultSet size. If that size is larger than the distinct parent count, you have a multiplication problem.list.size() with the number of distinct parents you expect. If list.size() is less than the page size, NHibernate has fallen back to in‑memory paging.hibernate.generate_statistics=true and examine the SessionFactoryStatistics for queryExecutionCount vs queryExecutionPlanCacheSize. A high number of “ResultSet rows fetched” relative to “entities returned” indicates row multiplication.| Version | Collection Fetch Handling | Known Pagination Quirk |
|---|---|---|
| 5.2.x | Plain fetch join; no automatic distinct handling. | Direct in‑memory fallback when row count < page size. |
| 5.3.x | Introduced maxFetchDepth and fetchSize adjustments, but setMaxResults still applies to raw rows. | Same multiplication issue; occasional “distinct” option via CriteriaQuery.setDistinct(true) helps but not guaranteed. |
| 6.x | Enhanced distinct handling for root entities and support for fetchMode=JOIN with distinct=true in Criteria. | In some dialects (e.g., PostgreSQL) the LIMIT/OFFSET is still applied to the raw row set; NHibernate will still perform in‑memory paging if the distinct parent count is insufficient. |
SELECT COUNT(*) query on the root entity without any fetch join to obtain the true total; second fetch the paged root entities with setFirstResult and setMaxResults, but avoid eager collection fetches.batch-size strategy (e.g., @BatchSize(25)) or a subselect fetch mode.setDistinct(true) and setResultTransformer(Transformers.DISTINCT_ROOT_ENTITY) to reduce row multiplication, but still verify the distinct count.ScrollMode.FORWARD_ONLY or ScrollableResults to stream rows and apply in‑memory slicing only when the dataset is fully loaded.Which database dialect you are targeting can affect how NHibernate generates the LIMIT/OFFSET clause and whether the dialect supports row‑number based pagination. Knowing the dialect (e.g., PostgreSQL, Oracle, SQL Server, MySQL) will help decide if you can rely on the database’s pagination or must enforce in‑memory handling.
Question for you: What is the database dialect you are using with NHibernate?
Use comments to ask for clarification. Post a solution as an answer.
26,525 reputation · 10 May 2026, 17:30 UTC
Enable SQL logging for the session and run the paginated query with a collection fetch. If LIMIT/OFFSET appears in the emitted SQL and the number of hydrated root entities is consistently less than the requested page size, the database is doing the pagination. If LIMIT/OFFSET is absent and the full joined result set is loaded before slicing, you are seeing in-memory fallback.
A practical check is to compare the SQL row count for the join against the distinct root entity count returned by NHibernate. A mismatch indicates Cartesian multiplication. Native SQL bypasses ORM fetch-pagination guards, so ambiguous counts can appear silently. Version assumptions should be verified in your pinned NHibernate release and dialect.