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. If count is less than or equal to zero, no elements are skipped. If count exceeds 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. If count is 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. Skip enumerates through the items one by one until it reaches the offset, and Take yields the next requested elements.
  • LINQ to Entities (IQueryable<T>): Entity Framework translates Skip and Take directly into database commands. In SQL Server, for example, they translate to OFFSET ... 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.