If you have a business requirement to implement a pagination in user interface, you could use OFFSET/FETCH or ROW_NUMBER() to retrieve rows from SQL Server. But when you need to show the total number of rows that can be retrieved from database, it becomes harder.
You can use two queries – one for counting the total number, second for extracting the page, but this is not a good idea. Why? First, you’ll have negative impact on performance because you need 2 queries, second, you can get different results cause data could change between these two queries.
Luckily, there are some solutions to do this in one query. Because SQL Server could not return rows and assign a value in one query, we need to put the total number as an additional column into a resultset.
1. Using COUNT(*) OVER()
|
DECLARE @PageSize INT = 10, @PageNum INT = 5, @TotalRows INT; SELECT [name], object_id, TotalRows = COUNT(*) OVER() FROM sys.all_objects --WHERE [name] LIKE 'fn_%' ORDER BY [name] OFFSET (@PageNum - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY; |
It’s a very simple query to write. As for performance, I’ll compare all solutions later.
2. Using CTE to count all records
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
WITH Data_CTE AS ( SELECT [name], object_id FROM sys.all_objects --WHERE [name] LIKE 'fn_%' ), Count_CTE AS ( SELECT COUNT(*) AS TotalRows FROM Data_CTE ) SELECT * FROM Data_CTE CROSS JOIN Count_CTE ORDER BY [name] OFFSET (@PageNum - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY; |
3. Extracting keys to temporary table
The idea is to extract primary keys to temporary table or CTE, get a page of rows, then join it with the original table. We need to use ROW_NUMBER() to assign ordinal number to every row in order to get a page. There are 2 ways to implement pagination.
3a. Extracting keys and filtering row numbers in WHERE
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
WITH Data_CTE AS ( SELECT object_id, ROW_NUMBER() OVER ( ORDER BY [name] ) AS row_num FROM sys.all_objects --WHERE [name] LIKE 'fn_%' ), Count_CTE AS ( SELECT COUNT(*) AS TotalRows FROM Data_CTE ) SELECT src.object_id, src.[name], Count_CTE.TotalRows FROM sys.all_objects src JOIN Data_CTE t ON src.object_id = t.object_id CROSS JOIN Count_CTE WHERE (@PageNum - 1) * @PageSize < t.row_num AND t.row_num <= @PageNum * @PageSize ORDER BY t.row_num; |
3b. Extracting keys and using OFFSET/FETCH
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
WITH Data_CTE AS ( SELECT object_id, ROW_NUMBER() OVER ( ORDER BY [name] ) AS row_num FROM sys.all_objects --WHERE [name] LIKE 'fn_%' ), Count_CTE AS ( SELECT COUNT(*) AS TotalRows FROM Data_CTE ) SELECT src.object_id, src.[name], Count_CTE.TotalRows FROM sys.all_objects src JOIN Data_CTE t ON src.object_id = t.object_id CROSS JOIN Count_CTE ORDER BY t.row_num OFFSET (@PageNum - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY; |
Now I’ll compare these queries for IO statistics and query optimizer costs.
|
|
1. Using COUNT(*) OVER() |
2. Using CTE to count all records |
3a. Extracting keys and filtering row numbers in WHERE |
3b. Extracting keys and using OFFSET/FETCH |
Select all rows |
IO statistics |
Table ‘Worktable’. Scan count 3, logical reads 4728… Table ‘sysschobjs’. Scan count 1, logical reads 25 |
Table ‘sysschobjs’. Scan count 2, logical reads 26 |
Table ‘sysschobjs’. Scan count 2, logical reads 46 |
Table ‘sysschobjs’. Scan count 2, logical reads 138 |
|
Query cost |
15% |
11% |
25% |
50% |
Select with WHERE clause |
IO statistics |
Table ‘Worktable’. Scan count 3, logical reads 169… Table ‘sysschobjs’. Scan count 1, logical reads 4 |
Table ‘sysschobjs’. Scan count 2, logical reads 8 |
Table ‘sysschobjs’. Scan count 2, logical reads 28 |
Table ‘Worktable’. Scan count 0, logical reads 0… Table ‘Workfile’. Scan count 0, logical reads 0… Table ‘sysschobjs’. Scan count 3, logical reads 31 |
|
Query cost |
4% |
6% |
35% |
55% |
From my viewpoint, the second solution is the best cause it needs the least number of pages to be read and it does not create a table in TempDB.