Skip to main content

Paging and Sorting

These methods cover the usual list endpoint: read a ?sort= parameter, add the optional filters, and return one page with its totals. Each one works on IQueryable<T>, so Entity Framework Core still runs the sort, the filter and the paging in SQL. ToPage and SortBy also work on an in-memory IEnumerable<T>.

The extensions live in System.Collections.Generic (the IEnumerable<T> overloads and the Page<T> type) and System.Linq (the IQueryable<T> overloads), so you do not need a using directive.

At a glance​

MethodWhat it does
ToPage (IEnumerable)Takes one page of an in-memory sequence and counts the total.
ToPage (IQueryable)Runs a COUNT query, then an OFFSET/FETCH query, and returns the page.
Page<T>One page of items plus PageNumber, PageSize, TotalCount, TotalPages, HasPrevious and HasNext.
SortBy(sort)Sorts by a text such as "lastName desc, -age". Any public comparable property is accepted.
SortBy(sort, allowedProperties)Same, but only the listed properties or paths are accepted. Use it for request input.
WhereIf (IQueryable)Adds a Where filter only when a condition is true, keeping the query translatable.

A list endpoint in one chain​

The three pieces are meant to be chained in this order: filter, sort, page. Sort before paging, or the pages are not stable.

OrdersController.cs
[HttpGet]
public ActionResult<Page<OrderDto>> List(string? status, string? sort, int page = 1, int pageSize = 20)
{
Page<Order> result = _db.Orders
.WhereIf(status is not null, order => order.Status == status)
.SortBy(sort ?? "-createdAt", "createdAt", "total", "customer.name")
.ToPage(page, pageSize);

return new Page<OrderDto>(
result.Items.Select(OrderDto.From).ToList(),
result.PageNumber,
result.PageSize,
result.TotalCount);
}

_db.Orders is a DbSet<Order>. It is both an IQueryable<Order> and an IEnumerable<Order>, and C# picks the IQueryable<T> overloads because they are more specific. Nothing is loaded until ToPage runs.

Paging​

ToPage on IEnumerable​

ToPage(pageNumber, pageSize) returns a Page<T> with the items of one page and the totals a client needs. Page numbers start at 1. Use it for data that is already in memory, such as a cached list or the result of another API call.

Paging an in-memory list
IEnumerable<int> orderIds = Enumerable.Range(1001, 25);

Page<int> first = orderIds.ToPage(pageNumber: 1, pageSize: 10);
// first.Items → [1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010]
// first.TotalCount → 25
// first.TotalPages → 3
// first.HasPrevious → false
// first.HasNext → true

Page<int> last = orderIds.ToPage(pageNumber: 3, pageSize: 10);
// last.Items → [1021, 1022, 1023, 1024, 1025]
// last.HasPrevious → true
// last.HasNext → false

A page past the end is not an error. It has no items but keeps the real totals, so a client can still show "25 results" and jump back.

Past the end
Page<int> beyond = Enumerable.Range(1001, 25).ToPage(pageNumber: 5, pageSize: 10);
// beyond.Items → []
// beyond.TotalCount → 25
// beyond.TotalPages → 3
// beyond.HasNext → false
  • The source must already be sorted. ToPage does not reorder anything.
  • A source that is not an IReadOnlyCollection<T> (a lazy Where, an iterator) is copied to a list once, to count it and take the page. Every item is read, not only the ones on the page.
  • A page number so large that (pageNumber - 1) * pageSize does not fit in an int (for example int.MaxValue) returns an empty page with the real total, not an overflow.
Exceptions

source is null → ArgumentNullException (ParamName "source"). pageNumber or pageSize below 1 → ArgumentOutOfRangeException with that parameter name. Validate query-string values before you call it, or map the exception to a 400 response.

ToPage on IQueryable​

The IQueryable<T> overload sends two queries through the query provider: a Count(), then Skip(...).Take(...). With Entity Framework Core that is one SELECT COUNT(*) and one OFFSET ... FETCH query. Rows outside the page are never loaded.

Paging an EF Core query
Page<Invoice> page = _db.Invoices
.Where(invoice => invoice.CustomerId == customerId)
.OrderByDescending(invoice => invoice.IssuedOn)
.ThenBy(invoice => invoice.Id)
.ToPage(pageNumber: 2, pageSize: 50);
SQL sent by EF Core 10 (SQL Server, column list shortened)
SELECT COUNT(*)
FROM [Invoices] AS [i]
WHERE [i].[CustomerId] = @customerId

SELECT [i].[Id], [i].[CustomerId], [i].[IssuedOn], ...
FROM [Invoices] AS [i]
WHERE [i].[CustomerId] = @customerId
ORDER BY [i].[IssuedOn] DESC, [i].[Id]
OFFSET @p ROWS FETCH NEXT @p ROWS ONLY

The offset and the page size are sent as parameters. On page 2 with a page size of 50 both values are 50, so EF Core 10 sends a single parameter @p = 50 for both. Other EF Core versions name the parameters differently.

When the requested page starts at or past the total, only the COUNT query runs, and the page has no items.

Sort by a unique key last

Paging an unsorted query gives unstable pages: the database may return rows in a different order on each request. Sort first, and end with a unique column (such as Id) so rows with equal sort values do not move between pages.

Synchronous

Both queries run synchronously (Count() and ToList()). The library has no async ToPage overload, because it does not depend on Entity Framework Core.

The null and range checks are the same as for the IEnumerable overload.

Page<T>​

Page<T> is a sealed class with read-only properties. System.Text.Json serializes it as it is, so you can return it from an API.

MemberTypeMeaning
ItemsIReadOnlyList<T>The items on this page. Never null.
PageNumberintThe 1-based page number that was requested.
PageSizeintThe maximum number of items per page.
TotalCountintThe number of items across all pages.
TotalPagesintTotalCount / PageSize, rounded up. 0 when there are no items.
HasPreviousboolPageNumber > 1 and TotalCount > 0.
HasNextboolPageNumber < TotalPages.
Totals and navigation
Page<int> exact = Enumerable.Range(1, 20).ToPage(pageNumber: 2, pageSize: 10);
// exact.TotalPages → 2 (no extra empty page when the count divides evenly)
// exact.HasNext → false

Page<int> empty = Enumerable.Empty<int>().ToPage(pageNumber: 1, pageSize: 10);
// empty.TotalCount → 0
// empty.TotalPages → 0
// empty.HasPrevious → false
// empty.HasNext → false
Page 2 of 25 items, serialized with System.Text.Json (camelCase)
{
"items": [11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
"pageNumber": 2,
"pageSize": 10,
"totalCount": 25,
"totalPages": 3,
"hasPrevious": true,
"hasNext": true
}

You can build a page yourself with the public constructor Page<T>(IReadOnlyList<T> items, int pageNumber, int pageSize, int totalCount). This is how you map a page of entities to a page of DTOs, as in the list endpoint example. The constructor only checks items (null → ArgumentNullException, ParamName "items"); it does not validate the numbers. TotalPages returns 0 when PageSize is 0 or negative.

Name clash with your own Page<T>

Page<T> lives in System.Collections.Generic, so it is in scope almost everywhere. If your project imports its own Page<T> with a using or a global using, the compiler reports CS0104 (ambiguous reference). Use an alias, for example using ApiPage = MyApp.Paging.Page<MyApp.Order>;, or write the fully qualified name.

Sorting from a string​

SortBy reads the kind of sort text that clients put in a ?sort= query parameter and turns it into OrderBy / ThenBy calls. On IQueryable<T> the sort is built as an expression tree, so EF Core translates it to ORDER BY.

The sort syntax​

TextMeaning
nameAscending by Name.
name asc, name ascending, +nameAscending (explicit).
name desc, name descending, -nameDescending.
age, name descBy Age, then by Name descending.
address.cityBy a nested property.
  • Clauses are separated by commas. Empty clauses (",age,, -name,") are skipped, but a clause of only spaces ("age, , name") is rejected.
  • A clause is a property name, then an optional direction, separated by spaces or tabs. Leading and trailing spaces are ignored.
  • Property names and directions are case-insensitive (NAME DESCENDING works). When two properties differ only by case, the exact-case match wins.
  • null, "" or white space leaves the order unchanged: the method returns the same source instance.

SortBy(sort)​

The examples below use this data:

Sample data
public sealed record Address(string City);
public sealed record Customer(string Name, int Age, Address Address);

List<Customer> customers = new List<Customer>
{
new Customer("Bob", 40, new Address("Cairo")),
new Customer("Ann", 30, new Address("Amman")),
new Customer("Dan", 30, new Address("Berlin")),
new Customer("Cid", 40, new Address("Dubai")),
};
Sorting by text
IEnumerable<string> byName = customers.SortBy("name").Select(c => c.Name);
// → ["Ann", "Bob", "Cid", "Dan"]

IEnumerable<string> byNameDesc = customers.SortBy("-name").Select(c => c.Name);
// → ["Dan", "Cid", "Bob", "Ann"]

IEnumerable<string> byAgeThenName = customers.SortBy("age, name desc").Select(c => c.Name);
// → ["Dan", "Ann", "Cid", "Bob"]

IEnumerable<string> byCityDesc = customers.SortBy("-address.city").Select(c => c.Address.City);
// → ["Dubai", "Cairo", "Berlin", "Amman"]

IEnumerable<Customer> unchanged = customers.SortBy(null);
// → the same instance as customers

The IQueryable<T> overload has the same parameters and returns an IQueryable<T>:

Sorting an EF Core query
IQueryable<Customer> query = _db.Customers.SortBy("age desc, name");
// SQL: ORDER BY [c].[Age] DESC, [c].[Name]

What can be sorted. Only public, readable, non-indexer instance properties whose type is comparable: numbers, enums, string, dates and times (including DateOnly and TimeOnly), TimeSpan, Guid, any type that implements IComparable or IComparable<T> for itself (also through a base type), and the nullable forms of these. Methods (ToString), indexers (Item), non-public and write-only properties are rejected. So are navigation objects and collections: "address" fails, "address.city" works. A property typed object is accepted, and its values must be comparable with each other when the sort runs.

Null values. In memory, a null key sorts first in ascending order and last in descending order. This holds for nullable properties (an int? Score sorts as [null, 1, 2]) and for a null navigation in a nested path. On IQueryable<T> the path is guarded (item.Address == null ? null : item.Address.City), and EF Core translates the guard. The database then orders the NULL values by its own rule: SQL Server and SQLite put them first in ascending order, PostgreSQL and Oracle put them last.

A null navigation sorts first
public sealed record Location(string City);
public sealed record Contact(string Name, Location? Location);

List<Contact> contacts = new List<Contact>
{
new Contact("Cairo office", new Location("Cairo")),
new Contact("Remote", null),
new Contact("Amman office", new Location("Amman")),
};

IEnumerable<string> ascending = contacts.SortBy("location.city").Select(c => c.Name);
// → ["Remote", "Amman office", "Cairo office"]

IEnumerable<string> descending = contacts.SortBy("-location.city").Select(c => c.Name);
// → ["Cairo office", "Amman office", "Remote"]

Ties. On in-memory data the sort is stable: items with equal keys keep their original order, so customers.SortBy("age") gives Ann, Dan, Bob, Cid. A database makes no such promise, so add a unique key as the last clause when you page.

Inheritance. A property hidden with new resolves to the most-derived one. When the element type is an interface, properties of its base interfaces are found too.

Exceptions

source is null → ArgumentNullException (ParamName "source"). Any problem in the sort text → ArgumentException with ParamName "sort":

  • a clause with more than two words ("name desc now"),
  • an unknown direction ("name sideways"; the message mentions sideways),
  • an unknown property ("salary"), an ambiguous one, or one that is not a sortable value ("address").

The message names the rejected property but never lists the type's properties. The text is checked when you call SortBy, not when you enumerate the result.

Do not pass request input to SortBy(sort)

Without an allow-list a client can sort by any public property, including nested navigation paths. Sorting reveals the relative order of values the client is never shown. For example, ?sort=owner.passwordHash makes EF Core add a JOIN and an ORDER BY on the hash. The text cannot run code, but it can leak information. Use the allow-list overload for anything that comes from a request.

SortBy(sort, allowedProperties)​

SortBy(sort, params string[] allowedProperties) accepts only the listed property names or dotted paths. Everything else is rejected with an error that lists what is allowed. This is the overload to use in a public API. It exists for both IEnumerable<T> and IQueryable<T>.

Allow-listed sort from a query string
IEnumerable<string> cities = customers
.SortBy("ADDRESS.CITY desc", "name", "address.city")
.Select(c => c.Address.City);
// → ["Dubai", "Cairo", "Berlin", "Amman"]

IEnumerable<string> names = customers
.SortBy("-AGE, NAME", "age", "name")
.Select(c => c.Name);
// → ["Bob", "Cid", "Ann", "Dan"]
A sort that is not allowed
customers.SortBy("name, age", "name", "address.city");
// throws ArgumentException (ParamName "sort")
// Message starts with: Cannot sort by 'age'. Allowed: name, address.city.

Because the message lists the allowed names, you can return it to the client as a validation error.

How entries are matched.

  • Each entry is resolved to a property path on T, with the same case-insensitive rules as the sort text. A clause is accepted only when it resolves to the same path, so "AGE" matches the entry "age".
  • Paths match as a whole. Allowing "address.city" does not allow "address" or "address.zip".
  • When a type has two properties that differ only by case (Name and NAME), allowing "Name" does not allow "NAME".
  • An empty allow-list allows nothing: any sort with a clause throws. Text made only of commas (",") has no clause, so it is treated as no sort.

Entries are checked at once. Every entry is resolved when you call the method, even when sort is null or blank. A misspelled, stale or non-sortable entry fails on the first request, not only on the first request that uses it.

A bad allow-list fails immediately
customers.SortBy(null, "name", "address");
// throws ArgumentException (ParamName "allowedProperties")
// Message starts with: The allowed property 'address' on Customer is not a sortable value.

customers.SortBy(null, "name", "salary");
// throws ArgumentException (ParamName "allowedProperties")
// Message starts with: The allowed property 'salary' cannot be resolved on Customer
Exceptions
  • source or allowedProperties is null → ArgumentNullException.
  • An entry that is null or blank, does not resolve to exactly one readable public property path, or does not end at a sortable value → ArgumentException, ParamName "allowedProperties". This is a bug in your code.
  • A clause that cannot be read, has an unknown direction, or names a property that is not allowed, unknown or ambiguous → ArgumentException, ParamName "sort". This is bad client input. When the property is rejected, the message lists the allowed names and does not say why the lookup failed.

The ParamName tells the two cases apart, so you can map "sort" to a 400 response and let "allowedProperties" surface as a server error.

Mapping a bad sort to 400
try
{
Page<Order> page = _db.Orders
.SortBy(sort, "createdAt", "total", "customer.name")
.ToPage(pageNumber, 20);

return Ok(page);
}
catch (ArgumentException exception) when (exception.ParamName == "sort")
{
return ValidationProblem(new ValidationProblemDetails(
new Dictionary<string, string[]> { ["sort"] = new[] { exception.Message } }));
}

Trimming and Native AOT​

All four SortBy overloads are marked [RequiresUnreferencedCode] and [RequiresDynamicCode]. They look up properties by name through reflection and build generic OrderBy calls at run time. In a trimmed or Native AOT app, the compiler warns at each call site. The warning says to keep the type's public properties with [DynamicallyAccessedMembers] or a TrimmerRootDescriptor. If you cannot do that, sort with a switch over the allowed names and plain OrderBy calls. ToPage, Page<T> and WhereIf use no reflection and carry no such attributes. See Trimming and AOT.

Conditional filters​

WhereIf on IQueryable​

WhereIf(condition, predicate) applies Where(predicate) only when condition is true. Otherwise it returns the same query. The predicate is an Expression<Func<T, bool>>, so EF Core translates it to SQL. Use it for optional search filters instead of a chain of if statements.

Optional filters on an EF Core query
IQueryable<Order> query = _db.Orders
.WhereIf(customerId is not null, order => order.CustomerId == customerId)
.WhereIf(from is not null, order => order.CreatedAt >= from)
.WhereIf(!string.IsNullOrWhiteSpace(search), order => order.Reference.Contains(search!));

With only customerId set, the SQL has one WHERE condition. With no filter set, the query is unchanged.

Exact behavior
IQueryable<int> numbers = new[] { 1, 2, 3 }.AsQueryable();

IQueryable<int> filtered = numbers.WhereIf(true, x => x > 1);
// → [2, 3]

IQueryable<int> same = numbers.WhereIf(false, x => x > 1);
// → the same instance as numbers
  • source or predicate is null → ArgumentNullException, even when condition is false.
  • When condition is false the predicate is never added to the query, so it may use a value that is only valid when the condition holds (such as search! above).
  • There is also a WhereIf(bool, Func<T, bool>) overload for IEnumerable<T>, described in Querying sequences.
Keep the static type IQueryable

If you store a query in an IEnumerable<T> variable, C# picks the IEnumerable<T> overload and the filter runs in memory after every row is loaded. Keep the variable typed IQueryable<T> until you call ToPage or ToList.

See also​