Updated on

Pagination in an ASP.NET Core Web API means the client asks for a slice of a collection, ?pageNumber=2&pageSize=10, and the API answers with those ten records plus the numbers needed to ask for the next ten.

Two things have to be built. A parameters class binds the query string and clamps the page size so nobody can ask for everything; a repository method turns the page number into a Skip and a Take before the query reaches the database.

The metadata is the part that is easy to skip and hard to do without. A page of ten records tells the client nothing about whether there are eleven or eleven million, so the total count and the page count travel back in a response header alongside the records.

This article picks up where POST, PUT, and DELETE in ASP.NET Core Web API leaves off, and turns that API’s collection endpoint into a paged one.

To download the source code for the video, visit our Patreon page (YouTube Patron tier).

NOTE: Some degree of previous knowledge is needed to follow this article. It relies heavily on the ASP.NET Core Web API series on Code Maze, so if you are unsure how to set up the database or how the underlying architecture works, we strongly suggest you go through the series.


VIDEO: Paging in ASP.NET Core Web API With Onion Architecture.


What Is Paging in a REST API?

Paging returns one slice of a collection instead of all of it. The client asks for a page number and a page size, and the API answers with that many records plus enough information to ask for the next slice.

Two numbers do the work. pageNumber says which slice and pageSize says how big, and the API turns them into a Skip and a Take before the query ever reaches the database.

The API, not the client, sets the limits. A missing pageSize falls back to a default, and a pageSize of ten thousand is clamped to a maximum the server chooses. Without that clamp the parameter is just a slower way to ask for everything.

Paging is also where a response stops being only data. A page of records says nothing about how many records exist or whether another page follows, so that metadata has to travel back alongside them.

What Goes Wrong Without Paging?

Before we make any changes to the source code, let’s inspect how it looks right now, and how you would probably begin with any project.

In our case, we have the OwnerController which does all the necessary actions on the Owner entity.

One particular action that stands out, and that we need to change is the GetOwners() action:

[HttpGet]
public IActionResult GetOwners()
{
    var owners = _repository.Owner.GetOwners();

    _logger.LogInfo("Returned all owners from database.");

    return Ok(owners);
}

Which calls GetOwners() from OwnerRepository:

public IEnumerable<Owner> GetOwners() =>
    FindAll()
        .OrderBy(o => o.Name);

The FindAll() method is just a method from a base repository class that returns the whole set of owners.

public IQueryable<T> FindAll() =>
    RepositoryContext.Set<T>()
        .AsNoTracking();

As you can see it’s a straightforward action, meant to return all the owners from the database ordered by name.

And it does just that.

But, in our case, that’s just a few account owners (five). What if there were thousands or even millions of people in the database (you wish, but still, imagine another kind of entity)? And then add to that, a few thousand API consumers.

We would end up with a very long query that returns A LOT of data.

The best-case scenario would be that you started with a small number of owners that increased slowly over time so you can notice the slow decline in performance. Other scenarios are far less benign for your application and machines (imagine hosting it in the cloud and not having proper caching in place).

So, having that in mind, let’s modify this method to support paging.

How Do We Implement Paging in ASP.NET Core Web API?

Paging in ASP.NET Core Web API takes three pieces: a parameters class the query string binds to, a repository method that applies Skip and Take, and a response that carries the page metadata.

Declare a parameters class with PageNumber and PageSize, clamp PageSize to a maximum, and bind it on the action with [FromQuery]. One base class then serves every controller through inheritance.

In the repository, order the query first and then apply Skip((PageNumber - 1) * PageSize) and Take(PageSize). Ordering is not optional. Without an OrderBy the database is free to return matching rows in whatever order its plan produces, so the contents of page two are not guaranteed to be consistent with page one.

Serialize the page metadata into a response header and return the records as the body. The body stays a plain JSON array, which is what a client expects a collection endpoint to give it.

What we want to achieve is something like this: https://localhost:5001/api/owners?pageNumber=2&pageSize=2. This should return the second set of two owners from our database.

We also want to constrain our API not to return all the owners even if someone calls https://localhost:5001/api/owners.

Let’s start by changing the controller:

[HttpGet]
public async Task<ActionResult<IEnumerable<Owner>>> GetOwners([FromQuery] OwnerParameters ownerParameters)
{
    var owners = await _repository.Owner.GetOwners(ownerParameters);

    _logger.LogInfo($"Returned {owners.Count()} owners from database.");

    return Ok(owners);
}

A few things to take note:

  • We’re calling the GetOwners method from the OwnerRepository, which doesn’t exist yet, but we’ll implement it soon
  • We’re using [FromQuery] to point out that we’ll be using query parameters to define which page and how many owners we are requesting
  • OwnerParameters class is the container for the actual parameters

We also need to create the OwnerParameters class since we are passing it as an argument to our controller. Let’s create it in the Models folder of the Entities project:

public class OwnerParameters
{
    const int MaxPageSize = 50;

    private int _pageNumber = 1;
    private int _pageSize = 10;

    public int PageNumber
    {
        get => _pageNumber;
        set => _pageNumber = Math.Max(value, 1);
    }

    public int PageSize
    {
        get => _pageSize;
        set => _pageSize = Math.Clamp(value, 1, MaxPageSize);
    }
}

The MaxPageSize constant restricts our API to a maximum of 50 owners per page. If the caller sends nothing, PageNumber is 1 and PageSize is 10.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

The clamping matters more than it looks. Math.Clamp(value, 1, MaxPageSize) keeps ?pageSize=10000 from becoming a slower way of asking for everything, and it also rejects ?pageSize=0, which would otherwise return an empty page forever. Math.Max(value, 1) does the same job at the other end: without it, ?pageNumber=0 produces Skip(-10), and SQL Server refuses a negative OFFSET outright, so the request fails with a 500 rather than an empty result.

Now, let’s implement the most important part, the repository logic.

We need to extend the GetOwners() method in the IOwnerRepository interface and the OwnerRepository class:

public interface IOwnerRepository : IRepositoryBase<Owner>
{
    Task<IEnumerable<Owner>> GetOwners(OwnerParameters ownerParameters);
    Owner GetOwnerById(Guid ownerId);
    void CreateOwner(Owner owner);
    void UpdateOwner(Owner dbOwner, Owner owner);
    void DeleteOwner(Owner owner);
}

And the logic:

public async Task<IEnumerable<Owner>> GetOwners(OwnerParameters ownerParameters) =>
    await FindAll()
        .OrderBy(o => o.Name)
        .Skip((ownerParameters.PageNumber - 1) * ownerParameters.PageSize)
        .Take(ownerParameters.PageSize)
        .ToListAsync();

Ok, the easiest way to explain this is by example.

Say we need to get the results for the third page of our website, counting 20 as the number of results we want. That would mean we want to skip the first ((3 – 1) * 20) = 40 results, and then take the next 20 and return them to the caller.

One more thing. You could ask why we call the FindAll() method before applying the paging parameters to its result. FindAll() returns an IQueryable<Owner>, so nothing has been fetched from the database at that point and the OrderBy, Skip and Take all compose into the single statement the database eventually runs. We come back to that in the last section of this article, and the Ultimate ASP.NET Core Web API course works the same pattern through a larger API.

Does that make sense?

Testing the Solution

Now, in our database we only have a few owners, so let’s try something like this:

https://localhost:5001/api/owners?pageNumber=2&pageSize=2

This should return the next subset of owners:

[
    {
        "id": "66774006-2371-4d5b-8518-2177bcf3f73e",
        "name": "Nick Somion",
        "dateOfBirth": "1998-12-15T00:00:00",
        "address": "North sunny address 102"
    },
    {
        "id": "a3c1880c-674c-4d18-8f91-5d3608a2c937",
        "name": "Sam Query",
        "dateOfBirth": "1990-04-22T00:00:00",
        "address": "91 Western Roads"
    }
]

If that’s what you got, you’re on the right track.

Now, what can we do to improve this solution?

How Do We Return Paging Metadata to the Client?

Paging metadata travels in the X-Pagination response header as a small JSON object. The body stays a plain array of records, so a client that ignores paging entirely still gets exactly what it asked for.

PagedList<T> is where the numbers come from. It inherits List<T>, adds CurrentPage, PageSize, TotalCount and TotalPages, and derives HasNext and HasPrevious from those.

Those extra properties never reach the body. A type that inherits List<T> serializes as a JSON array, and an array has nowhere to put the parent object’s properties, so the header is not decoration here, it is the only place the metadata survives.

One more call makes the header readable from a browser. X-Pagination is not a CORS-safelisted response header, so the CORS policy has to name it in WithExposedHeaders, or client-side JavaScript sees nothing at all.

Implementing the PagedList Class

We don’t want our skip/take logic implemented inside our repository:

public class PagedList<T> : List<T>
{
    public int CurrentPage { get; private set; }
    public int TotalPages { get; private set; }
    public int PageSize { get; private set; }
    public int TotalCount { get; private set; }

    public bool HasPrevious => CurrentPage > 1;
    public bool HasNext => CurrentPage < TotalPages;

    public PagedList(List<T> items, int count, int pageNumber, int pageSize)
    {
        TotalCount = count;
        PageSize = pageSize;
        CurrentPage = pageNumber;
        TotalPages = (int)Math.Ceiling(count / (double)pageSize);

        AddRange(items);
    }

    public static async Task<PagedList<T>> ToPagedListAsync(IQueryable<T> source, int pageNumber, int pageSize)
    {
        var count = await source.CountAsync();
        var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();

        return new PagedList<T>(items, count, pageNumber, pageSize);
    }
}

As you can see, we’ve transferred the skip/take logic to the static method inside the PagedList class. We’ve added a few more properties, that will come in handy as metadata for our response.

HasPrevious is true if CurrentPage is larger than 1, and HasNext is calculated if CurrentPage is smaller than the number of total pages. TotalPages is calculated by dividing the number of items by the page size and then rounding it to the larger number since a page needs to exist even if there is one item on it.

ToPagedListAsync takes an IQueryable<T> and awaits CountAsync() and ToListAsync(), so neither round trip blocks a request thread. Both of those are EF Core extension methods on IQueryable<T>, which is exactly why the whole series keeps paging on the query rather than on a materialized list.

Now that we’ve cleared that out, let’s change our OwnerRepository and OwnerController accordingly.

First, we need to change our repo (don’t forget to change the interface too):

public Task<PagedList<Owner>> GetOwners(OwnerParameters ownerParameters)
{
    var owners = FindAll();

    var sortedOwners = owners.OrderBy(o => o.Name);

    return PagedList<Owner>.ToPagedListAsync(sortedOwners,
        ownerParameters.PageNumber,
        ownerParameters.PageSize);
}

And then the controller:

[HttpGet]
public async Task<ActionResult<IEnumerable<Owner>>> GetOwners([FromQuery] OwnerParameters ownerParameters)
{
    var owners = await _repository.Owner.GetOwners(ownerParameters);

    var metadata = new
    {
        owners.TotalCount,
        owners.PageSize,
        owners.CurrentPage,
        owners.TotalPages,
        owners.HasNext,
        owners.HasPrevious
    };

    Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata);

    _logger.LogInfo($"Returned {owners.TotalCount} owners from database.");

    return Ok(owners);
}

Two details in that one line are worth naming. We assign through the indexer rather than calling Response.Headers.Add(...), because Add throws an ArgumentException when the key is already present, and the ASP.NET Core analyzer flags it as ASP0019 at build time; the indexer replaces instead. And we serialize with System.Text.Json, the platform default, which writes the property names in PascalCase unless we change that globally. Our guide on configuring System.Text.Json globally covers the options that would alter it, and adding custom headers to an ASP.NET Core response covers the other ways a header reaches the client.

Now, if we send the same request as we did earlier https://localhost:5001/api/owners?pageNumber=2&pageSize=2, we get the same result:

[
    {
        "id": "f98e4d74-0f68-4aac-89fd-047f1aaca6b6",
        "name": "Martin Miller",
        "dateOfBirth": "1983-05-21T00:00:00",
        "address": "3 Edgar Buildings"
    },
    {
        "id": "66774006-2371-4d5b-8518-2177bcf3f73e",
        "name": "Nick Somion",
        "dateOfBirth": "1998-12-15T00:00:00",
        "address": "North sunny address 102"
    }
]

But now we have some additional useful information in X-Pagination response header:

postman response headers paging

Response headers

As you can see, all of our metadata is here. We can use this information when building any kind of frontend pagination functionality. You can play around with different requests to see how it works in other scenarios.

FieldTypeWhat it isWhat a client does with it
TotalCountintegerRecords matching the query, ignoring pagingShows “1–10 of 4,317”
PageSizeintegerRecords actually returned on this pageConfirms the server honoured, or clamped, the request
CurrentPageintegerThe 1-based page number returnedHighlights the active page in a pager
TotalPagesintegerTotalCount divided by PageSize, rounded upSizes the pager and finds the last page
HasNextbooleanCurrentPage < TotalPagesEnables or disables the “next” control
HasPreviousbooleanCurrentPage > 1Enables or disables the “previous” control

One thing has to be switched on before a browser can read any of that. X-Pagination is not one of the seven CORS-safelisted response headers, so cross-origin JavaScript cannot see it unless the server names it explicitly. AllowAnyHeader() does not cover this: it governs request headers. The response side needs its own call:

public static void ConfigureCors(this IServiceCollection services) =>
    services.AddCors(options =>
        options.AddPolicy("CorsPolicy",
            builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001")
               .AllowAnyMethod()
               .AllowAnyHeader()
               .AllowCredentials()
               .WithExposedHeaders("X-Pagination")));

Without that last line the header still travels, and fetch() in the browser still cannot read it.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

There is one more thing we can do to make our solution even more generic. We have the OwnerParameters class, but what if we want to use it in our AccountController? Parameters that we send to the Account controller might be different. Maybe not for paging, but we’ll send a bunch of different parameters later on and we need to separate the parameter classes.

Let’s see how to improve it.

Creating a Parent Parameters Class

First, let’s create an abstract class QueryStringParameters. We’ll use this class to implement mutually used functionalities for every parameter class we will implement. And since we have OwnerController and AccountController, which means we need to create OwnerParameters and AccountParameters classes.

Let’s start by defining QueryStringParameters class inside the Models folder of the Entities project:

public abstract class QueryStringParameters
{
    const int MaxPageSize = 50;

    private int _pageNumber = 1;
    private int _pageSize = 10;

    public int PageNumber
    {
        get => _pageNumber;
        set => _pageNumber = Math.Max(value, 1);
    }

    public int PageSize
    {
        get => _pageSize;
        set => _pageSize = Math.Clamp(value, 1, MaxPageSize);
    }
}

We’ve also moved our paging logic inside the class since it will be valid for any entity we might want to return through the repository.

Now, we need to create AccountParameters class, and then inherit the QueryStringParameters class in both the OwnerParameters and the AccountParameters classes.

Remove the logic from OwnerParameters and inherit QueryStringParameters:

public class OwnerParameters : QueryStringParameters
{
}

And create AccountParameters class inside the Models folder too:

public class AccountParameters : QueryStringParameters
{
}

Now, these classes look a bit empty, but soon we’ll be populating them with other useful parameters and we’ll see what the real benefit is. For now, it’s important that we have a way to send a different set of parameters for AccountController and OwnerController.

Now we can do something like this too, inside our AccountController:

[HttpGet]
public async Task<ActionResult<IEnumerable<Account>>> GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters)
{
    var accounts = await _repository.Account.GetAccountsByOwner(ownerId, parameters);

    var metadata = new
    {
        accounts.TotalCount,
        accounts.PageSize,
        accounts.CurrentPage,
        accounts.TotalPages,
        accounts.HasNext,
        accounts.HasPrevious
    };

    Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata);

    _logger.LogInfo($"Returned {accounts.TotalCount} accounts from database.");

    return Ok(accounts);
}

Due to the inheritance of the paging parameters through the QueryStringParameters class, we get the same behavior.

Does Skip and Take Page in the Database or in Memory?

In the database. FindAll() returns an IQueryable<T>, so Skip and Take are added to an expression tree and translated into the SQL rather than applied to a list that has already been loaded.

A paged endpoint therefore runs two statements: a COUNT for the total, and a select with an offset and a limit for the page itself. Neither one reads the whole table.

The cost that does grow is the offset. A database asked to skip a million rows still walks past them before it returns anything, so page one is fast and page fifty thousand is not, however small the page size.

Keyset paging avoids that walk. Instead of an offset the client sends the last value it saw, and the query becomes a range the index can seek straight to. It cannot jump to an arbitrary page number, which is the trade.

The type is what decides this, which is why the difference between IEnumerable and IQueryable is worth knowing before writing a repository. Ask EF Core what it intends to run and it says so plainly:

DECLARE @p int = 10;

SELECT [o].[Id], [o].[Age], [o].[City], [o].[Name]
FROM [Owners] AS [o]
ORDER BY [o].[Name]
OFFSET @p ROWS FETCH NEXT @p ROWS ONLY

That is the whole page, expressed as one statement, with the ordering the repository supplied. Nothing is materialized in our process first.

The offset is the part that bites at depth. On a 200,000 row table with an index on the ordered column, returning ten rows from the front took 3 logical reads; returning ten rows after an offset of 190,000 took 828 logical reads for the same ten rows, because the plan scans the index forward and discards everything it passes. Page size never changes, and the work does.

Keyset paging is the escape hatch when that becomes real: the client sends back the last name or id it saw, the query filters on it, and the index seeks rather than scans. The price is that there is no “jump to page 4,000” any more, only “next” and “previous”, which is a fair trade for a feed and a bad one for a table with a pager.

The other honest answer is not to build it. Letting a library handle paging, sorting and filtering together is a reasonable choice when the endpoint is a plain list over a plain entity, and hand-rolling only pays once the rules stop being plain.

Both of these, the offset cost and the keyset alternative, are covered end to end in the Ultimate ASP.NET Core Web API course, which builds this API out past the point where paging stops being the interesting problem.

Conclusion

Paging is a useful and important concept in building any API out there. Without it, our application would slow down considerably or just drop dead.

The solution we’ve implemented is not perfect, far from it, but you got the point. We’ve isolated different parts of the paging mechanism and we can go even further and make it more generic. But you can do it as an exercise and implement it in your project. You can also find one front-end application of paging in our Angular Material paging article.

In this article we’ve covered:

  • The easiest way to implement pagination in ASP.NET Core Web API
  • Tested the solution in a real-world scenario
  • Improved that solution by introducing the PagedList class and separated our parameters for different controllers
  • Answered where Skip and Take actually run, and what the offset costs at depth

Hope you liked this article and you’ve learned something new or useful from it. In the next article, we’re going to cover filtering, and after that sorting the same endpoint.

Tested with .NET 10.0.10 and EF Core 10.0.11.