LINQ Pagination in C#: Using Skip and Take
Pagination is a fundamental technique for breaking large datasets
into manageable chunks, improving both application performance and user
experience. In C# LINQ, pagination is primarily achieved using the
Skip and Take partitioning operators. This
article explains how these methods function, the standard mathematical
formula used to calculate pages, the importance of deterministic
sorting, and how these operators translate into optimized SQL queries
when interacting with databases via Entity Framework.
The Mechanics of Skip and Take
The Skip and Take operators work
sequentially to isolate a specific window of elements from a
collection:
Skip(int count): Bypasses a specified number of elements from the beginning of a sequence and returns the remaining elements. Ifcountis less than or equal to zero, no elements are skipped. Ifcountexceeds the total number of items, an empty sequence is returned.Take(int count): Returns a specified number of contiguous elements from the start of a sequence. Ifcountis less than or equal to zero, an empty sequence is returned. If the sequence contains fewer items than requested, it returns all available items without throwing an exception.
The Standard Pagination Formula
To implement page-based navigation, you need two parameters: the page number (typically 1-based for users) and the page size (the number of items per page).
The formula to calculate the offset is:
offset = (pageNumber - 1) * pageSize
Here is a practical C# implementation:
public static IEnumerable<T> GetPage<T>(IEnumerable<T> source, int pageNumber, int pageSize)
{
if (pageNumber < 1) pageNumber = 1;
if (pageSize < 1) pageSize = 10;
return source
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize);
}If requesting Page 3 with a Page Size of 10:
Skip((3 - 1) * 10)skips the first 20 records.Take(10)retrieves records 21 through 30.
The Necessity of Ordering
Pagination must always be paired with an explicit ordering clause,
such as OrderBy or OrderByDescending. Without
sorting, collections—especially those retrieved from relational
databases—do not guarantee a deterministic order. Without an ordered
query, repeating the same pagination query might return overlapping or
missing records across pages.
var pagedData = dbContext.Products
.OrderBy(p => p.Id)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();In-Memory vs. Database Execution
The behavior of Skip and Take differs
significantly depending on the underlying LINQ provider:
- LINQ to Objects
(
IEnumerable<T>): The entire dataset is loaded into memory.Skipenumerates through the items one by one until it reaches the offset, andTakeyields the next requested elements. - LINQ to Entities
(
IQueryable<T>): Entity Framework translatesSkipandTakedirectly into database commands. In SQL Server, for example, they translate toOFFSET ... ROWS FETCH NEXT ... ROWS ONLY. This ensures only the requested slice of data is transferred over the network from the database server, keeping memory consumption low.
Performance Considerations
While Skip and Take (offset-based
pagination) work well for small to medium-sized datasets, performance
can degrade on very large databases when the page number becomes
extremely high. Because the database engine must still scan and discard
the skipped rows, queries with large offsets (e.g., skipping 500,000
rows) can become slow. For massive datasets, keyset pagination
(filtering by WHERE Id > lastSeenId followed by
Take) is often preferred over offset pagination.