Troubleshooting
Symptom first. For the exact wording of any 400, see Errors — this page is for the cases where the message is not the problem.
"The field is not configured", but I configured it
Filter for field 'x' is not configured. and its sort and search equivalents have four causes, in the order worth checking:
- A
.When(false)gate. A conditional field reports exactly the message of a field that does not exist, on purpose — seeWhen. If the condition reads a role or a claim, the config was probably built with the wrong one, or built once at startup and cached across users. - The alias, not the property. Field names are arbitrary aliases:
.Sortable("createdAt", p => p.Created)is addressed ascreatedAt, never asCreated. Matching is case-insensitive, so case is not it. - The wrong config reached the endpoint. The provider named in
[PaginatedQuery<T>]/WithPagination<T>()documents the operation; the config passed toPaginate*Asyncis what enforces it. Nothing checks that they are the same one. - You declared the wrong kind.
Sortabledoes not make a field filterable, and neither makes it searchable. Each is a separate declaration.
A .When(...) gate stopped applying
The inverse symptom, and it has one cause worth checking before any other: declaring the same name twice for the same kind replaces the earlier declaration silently, and what is replaced may be the gated one. A second .Filterable("isHidden", …) with no .When(...) leaves the field ungated, and Build() does not catch it — the When-requires-ShowBadge check inspects only the declarations that survived, and the survivor has no When. Search the config for a second declaration of that name; the replacement may sit in a shared helper rather than next to the original.
Sorting is ignored, or wrong
sortByreplaces the defaults, it does not merge with them. A request that sends anysortBydrops everyDefaultSortByentry.- The tie-breaker is always last, whichever applied. Seeing an extra column at the end of the
ORDER BYis correct. A pagination configuration requires WithTieBreaker(...)is thrown when the configuration is built, not when a request arrives — so it surfaces at startup or on the first use of that config, never as a400. AddWithTieBreakeron any unique column; it is also what stops rows drifting between pages.- Rows appear twice or vanish while paging and there is a sort: the sort is not total. That is the same fix — a unique key as the final ordering column.
$ilike is not case-insensitive
$ilike names the intent, not a guarantee. Without the .PostgreSql package it emits a portable LIKE, and the case behaviour is then the engine's — which is not the same on every one of them, and on PostgreSQL is case-sensitive with no collation that changes it before 18.6. The per-leg table is the place to check what yours does. Register UsePostgreSql() for native ILIKE, or move the column to a type or collation that folds case on the engine you deploy on.
A value with a comma in it does not work
It cannot be expressed. $in, $btw and $contains-on-a-collection split on , with no escaping. Single-value operators take everything after the operator's colon verbatim, commas included, so $eq:Smith, John is fine — it is only the list operators that have no way through.
The links are null, or doubly escaped
"links": nullmeans no link context was supplied. In ASP.NET Core, that is the overload without theHttpRequest; elsewhere it is the default. See Response contract.%2524eq%253AActivein a link means the values were pre-escaped.PaginateLinkContextpercent-encodes what you give it, so supply$eq:Activeraw."next": nullon a page that clearly has more rows — checkmetarather than the link. IfcurrentPageexceedstotalPages, the page requested is past the end andnextis correctly absent.
OpenAPI shows the wrong parameters
- Both real and framework-generated parameters (
SortBy,Filters, an object-shaped query): the transformer was not registered. It is what strips the generated ones —AddOpenApi(o => o.AddOperationTransformer<PaginatedQueryOperationTransformer>()). - No pagination parameters at all: the operation carries no
[PaginatedQuery<T>]orWithPagination<T>(), so the transformer skipped it. searchByis missing: the config callsIgnoreSearchByInQueryParam(), which removes it from the contract, so documenting it would be wrong.- A badge renders as literal text: the class does not start with
language-, or it is on something other than a description. See OpenAPI → Badges.
Something threw a 500, not a 400
PaginateQueryException is the only exception the ProblemDetails filter maps. Anything else is a bug in your code rather than in the request, and the two most common ones are a queryable-shaped test double and a projection:
NotSupportedException… "provider is asynchronous but is not Entity Framework Core's"
means a mocking library is standing in for the provider. Nothing is wrong with the request; see Testing your pagination for the SQLite in-memory setup to use instead.
The projection one:
InvalidOperationExceptionfromPaginateProjectionBuilder
Automatic projection maps constructor parameters, not settable properties, so the target should be a record whose parameter names match entity members (case-insensitively). A parameter with nothing to bind to, or a member the provider cannot translate, fails here. Either fix the DTO or switch to PaginateSelectAsync and write the selector yourself.
A trimmed or AOT publish warns
Those warnings are accurate. The engine builds expression trees and uses reflection, so every entry point that reaches it is annotated [RequiresUnreferencedCode] and [RequiresDynamicCode] — see Requirements for the list. Suppressing them converts a build warning into a runtime failure; there is no trim-safe mode to switch on.
A [PaginatedQuery<TProvider>] endpoint has one more requirement the annotations cannot express, so the provider type carries [DynamicallyAccessedMembers(PublicConstructors)] instead: the OpenAPI transformer activates an unregistered provider with ActivatorUtilities.CreateInstance, and typeof(TProvider) roots the type but not its constructor. Without that annotation the document request answered 500 in a trimmed publish while working in development.
An audit tool says unknown parameters are silently accepted
They are, and it is deliberate. The binder reads exactly six inputs and ignores everything else, so a client's own utm_* or offset does not break the request. Strict binding would reject perfectly ordinary tracking parameters. The two where a wrong value would change the result — page and limit — are validated.
Reading what actually ran
The page query, before it runs. ApplyPagination composes it and stops, so ToQueryString() prints exactly what PaginateAsync would execute — filters, search, ordering, Skip/Take. A configured DbContext is enough; no server has to answer:
string sql = db.Products.ApplyPagination(request, config).Query.ToQueryString();See Query composers. It adds no projection, so pair it with the next one when the SELECT list is what you are chasing.
A selector, before committing to it. Apply the same Select yourself:
string sql = db.Products
.Select(p => new ProductSummary(p.Id, p.Name, p.Reviews.Count))
.ToQueryString();Enough to confirm the SELECT list is narrow, that a sub-collection became a join rather than N+1, and that nothing fell to client evaluation.
What actually ran — EF's own logging, which shows both statements:
options.UseNpgsql(connectionString).LogTo(Console.WriteLine, LogLevel.Information);