Skip to main content

How to Add Paging to an EF Core Query with Skip and Take

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

A list endpoint shouldn't return every row. Skip and Take give you one page at a time, which EF Core turns into MySQL's LIMIT and OFFSET. Getting it right takes three more things: a sort order rows can't escape from, the total count, and a limit on the page size. This guide builds all of it.

Prerequisites​

  • A .NET C# project with EF Core. The examples use EF Core 10, Oracle's MySql.EntityFrameworkCore, MySQL 9.7, and a products table with eight rows. SQL logging is on, as in How to See the SQL That EF Core Sends.

Step 1: Skip and Take​

For page page, with size rows per page, skip the pages before it:

A temporary endpoint
app.MapGet("/demo/page", async (int page, int size, InventoryDbContext db) =>
Results.Ok(await db.Products
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.Skip((page - 1) * size)
.Take(size)
.Select(p => p.Sku)
.ToListAsync()));
Response (?page=1&size=3)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[
"CBL-HDMI-2M",
"ADP-HDMI-VGA",
"CBL-LTNG-1M"
]
Response (?page=2&size=3)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[
"CBL-USBC-1M",
"CBL-USBC-2M",
"CHG-USBC-20W"
]
Response (?page=3&size=3)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[
"CHG-USBC-65W",
"ADP-USBC-USBA"
]
API log (page 1)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p1='?' (DbType = Int32), @p='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT `p`.`Sku`
FROM `products` AS `p`
ORDER BY `p`.`Name`, `p`.`Id`
LIMIT @p1 OFFSET @p

A terminal with three responses for pages 1, 2 and 3 with three, three and two SKUs, and the SQL with ORDER BY Name, Id and LIMIT with OFFSET.

Eight products over three pages, each exactly once.

Step 2: Sort First, and End with Something Unique​

The order isn't decoration. Without OrderBy, EF Core logs a warning, and pages aren't reliable. EF Core's pagination page adds a second rule: "always make sure that your ordering is fully unique." If two products have the same name, ThenBy(p => p.Id) decides between them, so they can't swap places between requests.

Step 3: Return the Total​

A client needs the total to show "page 2 of 3". Count the filtered query before paging:

src/Dewiride.Inventory.Api/Features/Products/ProductEndpoints.cs (excerpt)
var totalCount = await products.CountAsync(cancellationToken);

var items = await sorted
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(ProductResponse.FromProduct)
.ToListAsync(cancellationToken);

return TypedResults.Ok(new PagedResponse<ProductResponse>(items, query.Page, query.PageSize, totalCount));
src/Dewiride.Inventory.Api/Common/PagedResponse.cs
namespace Dewiride.Inventory.Api.Common;

/// <summary>One page of a longer list, and how long the whole list is.</summary>
public sealed record PagedResponse<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount);

GET /api/v1/products?pageSize=3 sends two statements, the count and the page:

API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM `products` AS `p`
WHERE NOT (`p`.`IsArchived`)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (4ms) [Parameters=[@p1='?' (DbType = Int32), @p='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT `p0`.`Id`, `p0`.`Sku`, `p0`.`Name`, `p0`.`Description`, `p0`.`CategoryId`, `c`.`Name`, `p0`.`UnitPrice`, `p0`.`QuantityOnHand`, `p0`.`ReorderLevel`, `p0`.`IsArchived`, `p0`.`ArchivedAtUtc`, `p0`.`CreatedAtUtc`, `p0`.`UpdatedAtUtc`
FROM (
SELECT `p`.`Id`, `p`.`ArchivedAtUtc`, `p`.`CategoryId`, `p`.`CreatedAtUtc`, `p`.`Description`, `p`.`IsArchived`, `p`.`Name`, `p`.`QuantityOnHand`, `p`.`ReorderLevel`, `p`.`Sku`, `p`.`UnitPrice`, `p`.`UpdatedAtUtc`
FROM `products` AS `p`
WHERE NOT (`p`.`IsArchived`)
ORDER BY `p`.`Name`, `p`.`Id`
LIMIT @p1 OFFSET @p
) AS `p0`
INNER JOIN `categories` AS `c` ON `p0`.`CategoryId` = `c`.`Id`
ORDER BY `p0`.`Name`, `p0`.`Id`

The page itself runs in an inner query, so LIMIT picks the rows before the join to categories.

Step 4: Limit What Clients Can Ask For​

Without limits, a client can ask for a million rows per page. Validation handles that, in a record bound with [AsParameters]:

src/Dewiride.Inventory.Api/Features/Products/ProductContracts.cs (excerpt)
[property: Range(1, 1_000_000)] int Page = 1,
[property: Range(1, 100)] int PageSize = 20
Response (?pageSize=1000)
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"PageSize": [
"The field PageSize must be between 1 and 100."
]
},
"traceId": "00-b1086d0926e761e13fef4085263c14a9-6074188c3f312f05-00"
}

The maximum on Page also keeps (Page - 1) * PageSize inside what an int can hold. A page past the end is just empty, with the real total:

Response (?page=99)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
"items": [],
"page": 99,
"pageSize": 20,
"totalCount": 8
}

Common Mistakes​

MistakeWhat happensDo this instead
Skip and Take with no OrderByAn EF Core warning, and unreliable pagesSort first
Sorting by a column with repeats onlyRows that tie can move between pagesAdd .ThenBy(p => p.Id)
Skip(page * size) with pages starting at 1Page 1 skips the first page's rows(page - 1) * size
Counting after TakeThe count is the page sizeCount before paging
ToListAsync before SkipEvery row loads, and C# pages themKeep it a query
No maximum page sizeA client can load the whole tableLimit it with [Range]

FAQ​

Does Skip get slow on deep pages?​

Yes. The pagination page explains: "The database must still process the first 20 entries, even if they aren't returned to the application". For very long lists, it recommends keyset paging, which filters on the last value seen, such as WHERE Id > @last.

What if rows change between pages?​

Offset paging can then skip or repeat a row: if one is removed, the rest shift up. For most admin lists, that's acceptable. Keyset paging avoids it.

Conclusion​

Sort with a unique last column, then Skip((page - 1) * size).Take(size), and count the filtered query for the total. Limit the page size with validation. In MySQL, that becomes ORDER BY … LIMIT … OFFSET, and every row appears exactly once across the pages.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.