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
| Method | What 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.
[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.
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.
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.
ToPagedoes not reorder anything. - A source that is not an
IReadOnlyCollection<T>(a lazyWhere, 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) * pageSizedoes not fit in anint(for exampleint.MaxValue) returns an empty page with the real total, not an overflow.
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.
Page<Invoice> page = _db.Invoices
.Where(invoice => invoice.CustomerId == customerId)
.OrderByDescending(invoice => invoice.IssuedOn)
.ThenBy(invoice => invoice.Id)
.ToPage(pageNumber: 2, pageSize: 50);
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.
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.
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.
| Member | Type | Meaning |
|---|---|---|
Items | IReadOnlyList<T> | The items on this page. Never null. |
PageNumber | int | The 1-based page number that was requested. |
PageSize | int | The maximum number of items per page. |
TotalCount | int | The number of items across all pages. |
TotalPages | int | TotalCount / PageSize, rounded up. 0 when there are no items. |
HasPrevious | bool | PageNumber > 1 and TotalCount > 0. |
HasNext | bool | PageNumber < TotalPages. |
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
{
"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.
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
| Text | Meaning |
|---|---|
name | Ascending by Name. |
name asc, name ascending, +name | Ascending (explicit). |
name desc, name descending, -name | Descending. |
age, name desc | By Age, then by Name descending. |
address.city | By 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 DESCENDINGworks). 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:
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")),
};
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>:
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.
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.
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 mentionssideways), - 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.
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>.
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"]
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 (
NameandNAME), 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.
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
sourceorallowedPropertiesisnull→ArgumentNullException.- An entry that is
nullor 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.
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.
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.
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
sourceorpredicateisnull→ArgumentNullException, even whenconditionisfalse.- When
conditionisfalsethe predicate is never added to the query, so it may use a value that is only valid when the condition holds (such assearch!above). - There is also a
WhereIf(bool, Func<T, bool>)overload forIEnumerable<T>, described in Querying sequences.
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
- Querying sequences:
WhereIfonIEnumerable<T>and other filters. - Transforming sequences: reshaping results after you page them.
- JSON and safe logging: serializing API responses.
- Trimming and AOT: which members use reflection, and how to keep them working.