Query-string contract
The complete wire format. Six inputs, nothing else:
| Parameter | Repeatable | Example | Notes |
|---|---|---|---|
page | no | ?page=2 | 1-based, defaults to 1. |
limit | no | ?limit=50 | Defaults to the config's DefaultLimit, capped by MaxLimit. |
sortBy | yes | ?sortBy=price:DESC&sortBy=name:ASC | Applied in the order given. |
search | no | ?search=acme | Free text over the searchable fields. |
searchBy | yes | ?searchBy=name&searchBy=sku | Narrows search to a subset of them. |
filter.<field> | yes | ?filter.status=$eq:Active | One or more criteria per field. |
Anything else is ignored. offset, utm_source, your own tracking parameters — the binder reads exactly the six above and pages normally. This is deliberate: strict binding would reject perfectly ordinary client parameters. page and limit themselves are validated and return 400.
For repeatable parameters, repeat the key (?sortBy=a:ASC&sortBy=b:DESC). Comma-joining them in one value does not work — that reads as a single malformed instruction.
Rejections are quoted inline below where they help explain a rule. The complete list, and which message wins when a request is wrong in several ways at once, is in Errors; the ceilings that produce several of them are in Configuration API → Guards.
The object it binds to
The six parameters bind to PaginateQuery, and off the web you construct it directly — the property values carry the same strings the query string does, so everything on this page still describes what they mean:
| Parameter | Property | Type | Absent means |
|---|---|---|---|
page | Page | int | PaginateQuery.DefaultPage, which is 1 |
limit | Limit | int? | null → the config's DefaultLimit |
sortBy | SortBy | IReadOnlyList<string> | empty → the config's DefaultSortBy |
search | Search | string? | null → no search |
searchBy | SearchBy | IReadOnlyList<string> | empty → all searchable fields |
filter.<field> | Filters | IReadOnlyDictionary<string, IReadOnlyList<string>> | empty → no filters |
var request = new PaginateQuery {
Page = 2,
SortBy = ["price:DESC", "name:ASC"], // one entry per sort, in priority order
Filters = new Dictionary<string, IReadOnlyList<string>> {
["status"] = ["$eq:Active"], // the $op: prefix stays
},
};It is a class, not a record, so there is no with — value equality over those collection properties would compare by reference and lie. Use WithPage(n) to derive one request from another. Validation happens when the query executes, never here, so an out-of-range value produces the same 400 whichever way the request was built.
For the pipeline these six parameters feed — bind, validate everything, count, then order and page — see Getting started. This page is the parameters themselves.
About the SQL on this page
Every statement quoted here was captured from the engine running against SQLite, so the text is that provider's. What is stable across providers is the shape: which predicate an operator produces, how criteria combine, and where the parameters sit. Collection operators in particular look different elsewhere — SQLite reaches into a JSON column with json_each, another provider will not.
page
1-based. Must parse as a positive integer with no sign, decimal point or surrounding whitespace — 0, -1, +2, 2.0, abc, %202 and 2%20 all return 400 Query parameter 'page' must be a positive integer. Leading zeros are accepted and mean the same page: ?page=007 is page 7.
An empty or whitespace-only page is not an error either — it is read as if the parameter had not been sent, so ?page= serves page 1. The same holds for limit, where it leaves DefaultLimit in force. That is what keeps an HTML GET form, which submits every input it has whether or not the user filled it in, from rejecting the request it was meant to make.
The value becomes the OFFSET, always as a parameter:
-- ?page=2&limit=3
SELECT "p"."Id", "p"."Name", "p"."Status", "p"."Rank"
FROM "Products" AS "p"
ORDER BY "p"."Rank", "p"."Id"
LIMIT @p OFFSET @pPages past the end are not an error: you get items: [] with truthful meta, and the engine skips the second query entirely.
limit
Omitted → the config's DefaultLimit. Supplied → must be between 1 and the config's MaxLimit. An over-large limit is rejected, not clamped — silently returning fewer rows than asked for is the harder bug to notice.
Which 400 comes back depends on which rule was broken, and the two messages are not interchangeable:
| Input | Message |
|---|---|
?limit=150 against MaxLimit 100 | Query parameter 'limit' must be between 1 and 100. |
?limit=0, ?limit=-2, ?limit=2.0, ?limit=abc | Query parameter 'limit' must be a positive integer. |
A value that never was a positive integer is refused while the request is being read, before any configuration is in scope to name a ceiling — so the range message is reserved for a number that is one and is simply too large. (Construct a PaginateQuery directly with Limit = 0 and you get the range message instead: there was no query string to refuse it earlier.)
A resource that opted in with AllowUnlimited(maxRows) also accepts limit=-1: every matching row as one page, with page=1 and nothing else. It costs one query rather than two, meta.itemsPerPage reports what the page actually holds, and exceeding the configured ceiling is a 400. Everywhere else -1 is just another out-of-range limit, as are -2 and 0 even where the mode is enabled. See AllowUnlimited.
A resource may also cap how deep paging goes — (page - 1) × limit against WithMaxOffset. That refusal is raised before anything is counted or fetched, so a guarded deep page costs no query at all.
sortBy
sortBy=<field>:<ASC|DESC>Both parts are required — a bare ?sortBy=name is a 400. The direction is case-insensitive; the field name is matched against the configured Sortable names, also case-insensitively. Repeat the parameter for secondary sorts, in priority order:
GET /products?sortBy=status:ASC&sortBy=rank:DESCORDER BY "p"."Status", "p"."Rank" DESC, "p"."Id"That trailing "p"."Id" is the configured tie-breaker, appended to every query so that rows which compare equal cannot swap places between pages.
- More than
MaxSortFields(default 5) →400. - A field that is not configured sortable (or is disabled for this caller via
.When(...)) →400 Sort for field 'x' is not configured. - No
sortByat all → the config'sDefaultSortByentries, in declaration order. - The configured tie-breaker is always appended last, whichever of the two applied.
There is therefore always an ordering: a configuration cannot be built without a tie-breaker (see WithTieBreaker), so no request can reach an unordered page.
search and searchBy
search matches a substring. It is case-insensitive on PostgreSQL with the .PostgreSql package (see PostgreSQL); without it the case behaviour is the engine's, and it is not the same on every leg — the table under $ilike is the single home for that. The term is matched against every configured searchable field and the results OR'd together:
The term is trimmed before anything else happens, so ?search=%20widget%20 searches for widget and meta.search echoes the trimmed form. Both length guards measure the trimmed term: MaxSearchLength (default 256) and WithMinSearchLength (default 1).
A term that is absent, empty or entirely whitespace is no search at all rather than a short one: neither guard runs, no LIKE is emitted and meta.search is null. ?search=%20%20%20 with WithMinSearchLength(3) is a 200, not the length 400 — there is nothing left to measure. One character of content brings the guards back, so ?search=%20a%20 is the 400.
GET /products?search=gizmoWHERE "p"."Name" LIKE @p ESCAPE '\'
OR ("p"."Description" IS NOT NULL AND "p"."Description" LIKE @p ESCAPE '\')Two details worth reading off that: the same parameter is reused for every field, and a nullable column gets an explicit IS NOT NULL companion so the predicate stays three-valued-logic safe.
searchBy narrows the same term to a subset:
GET /products?search=gizmo&searchBy=nameWHERE "p"."Name" LIKE @p ESCAPE '\'%,_and[in the term are escaped — hence theESCAPE '\'— so they match literally rather than as wildcards.[only opens a character range on SQL Server, but escaping it everywhere keeps one pattern correct on the three engines this behaviour is verified against — PostgreSQL, SQLite and SQL Server, where an escaped character is read as a literal whether or not it is a wildcard. A provider that instead requires the escape character to be followed by%,_or itself will reject\[; if you deploy on one, supply a strategy of your own.- Matching is over code points, with no Unicode normalization.
cafétyped ascaf+é(NFC) and the same word ascafe+ a combining acute (NFD) are different terms on every leg, and only the form your rows are stored in will match. Normalize on write if your data can arrive in both forms; the library deliberately does not normalize the term, which would desynchronizemeta.searchfrom what the caller sent without making the stored values agree. - Longer than
MaxSearchLength(default 256) →400. - A
searchByfield that is not searchable →400; the same field twice →400. Both are validated even whensearchis absent, so a typo surfaces instead of silently searching everything. - If the config declares no searchable fields at all, sending
searchis a400, not a no-op. IgnoreSearchByInQueryParam()in the config makessearchByignored entirely; search then always spans all searchable fields.
Search and filters are AND'ed, with the search block parenthesised as a unit:
GET /products?filter.status=$eq:Active&search=widgetWHERE "p"."Status" = @p
AND ("p"."Name" LIKE @p1 ESCAPE '\' OR ("p"."Description" IS NOT NULL AND "p"."Description" LIKE @p1 ESCAPE '\'))filter.<field>
Grammar
filter.<field> = [$not:] [$and: | $or:] $<operator>[:<value>[,<value>…]]The prefixes are optional and order-independent. Parsing splits on the first : at each step and stops at the first operator token — everything after that colon is the value, verbatim. That rule is what lets values carry colons of their own:
?filter.createdAt=$gte:2026-01-01T00:00:00Z
?filter.name=$eq:Doohickey: legacyThe second one filters for the literal name Doohickey: legacy. Nothing after the operator's colon is inspected again.
Field names are matched case-insensitively, and case variants of the same field collapse into one entry, so ?filter.Status=…&filter.status=… is two criteria on one field rather than two fields.
A name is opaque to the engine — whatever the config called the field. That includes dots: a field reached through a navigation is conventionally named for its path, so ?filter.author.name=$eq:ann, ?sortBy=author.name:ASC and ?searchBy=author.name are ordinary requests against a field named author.name, not a nested-object syntax. See Nested attributes.
Operator reference
Each field whitelists its own operators in the config; sending one that is not whitelisted for that field is a 400, even though it exists. The SQL column shows the predicate the engine builds, with the surrounding SELECT/ORDER BY trimmed away.
Tokens are what a caller sends; the config grants them by their PaginateFilterOperator member name, and the two are spelled differently often enough to be worth a table:
| Token | Member | Token | Member |
|---|---|---|---|
$eq | Eq | $lt | LessThan |
$in | In | $lte | LessThanOrEqual |
$null | Null | $gt | GreaterThan |
$sw | StartsWith | $gte | GreaterThanOrEqual |
$ilike | ILike | $btw | Between |
$contains | Contains |
Tokens are matched case-insensitively. $not, $and and $or are modifiers, not operators — they have no enum member, are always available, and cannot be granted or withheld.
$eq — equality
?filter.status=$eq:ActiveWHERE "p"."Status" = @p$in — one of
Comma-separated. On SQLite the list arrives as a single JSON parameter rather than an inlined IN (…), which is exactly the point of parameterising: one cached plan, whatever the list contains.
?filter.status=$in:Active,DraftWHERE "p"."Status" IN (SELECT "p0"."value" FROM json_each(@p) AS "p0")An empty list is 400 Filter 'x' requires at least one '$in' value.
$null — is null
Takes no value, and refuses one — ?filter.description=$null:false is a 400 Filter 'description' does not take a value for '$null'. rather than a filter that reads as one thing and does the other. $not:$null is how you ask for the opposite:
?filter.description=$nullWHERE "p"."Description" IS NULLOn a non-nullable column the predicate is constant-folded, and the result is visible in the SQL rather than merely described:
?filter.rank=$null → WHERE 0 (matches nothing, and the second query never runs)
?filter.rank=$not:$null → no WHERE (matches everything)$sw — starts with
?filter.name=$sw:WidWHERE "p"."Name" LIKE @p ESCAPE '\' -- parameter: 'Wid%'$ilike and $contains on a string — contains
The two are the same predicate on a string field:
?filter.name=$ilike:widget
?filter.name=$contains:widgetWHERE "p"."Name" LIKE @p ESCAPE '\' -- parameter: '%widget%'With the .PostgreSql package registered, the same request emits native ILIKE instead. Without it the token names the intent, not a guarantee: the portable path is only as case-insensitive as the engine underneath it, and the answer differs by leg. Measured on Widget and WIDGET with ?filter.name=$ilike:widget:
| Leg | Matches |
|---|---|
plain IQueryable (in memory) | both — the expression is an OrdinalIgnoreCase IndexOf, ASCII and non-ASCII alike |
| SQLite | both, but ASCII only: its LIKE case-folds a–z and nothing else, so česky does not match ČESKY |
| SQL Server | per the column collation, which is usually case-insensitive |
| PostgreSQL, portable strategy | neither — no collation makes LIKE case-fold before 18.6, see PostgreSQL |
PostgreSQL, .UsePostgreSql() | both |
This divergence is deliberate and is the price of a portable LIKE: the in-memory leg has no column and no collation to consult, so it cannot follow one. Do not develop case-sensitivity expectations against the in-memory leg — recipes/testing/ explains why SQLite in-memory is the leg to assert pattern behaviour on, and even there the ASCII-only limit above applies.
$contains on a collection — set containment
On a collection field, $contains means the collection holds all the listed values, so each value becomes its own AND-ed predicate:
?filter.tags=$contains:redWHERE @p IN (SELECT "t"."value" FROM json_each("p"."Tags") AS "t")?filter.tags=$contains:red,smallWHERE @p IN (SELECT "t"."value" FROM json_each("p"."Tags") AS "t")
AND @p1 IN (SELECT "t0"."value" FROM json_each("p"."Tags") AS "t0")An empty list is 400 Filter 'x' requires at least one '$contains' value., and $contains on a scalar non-string field is 400 Filter 'x' supports '$contains' only for string or collection fields.
$lt $lte $gt $gte — comparisons
?filter.rank=$gte:3WHERE "p"."Rank" >= @pNumbers and dates are the obvious cases. string, Guid and enums order too, and on a database the ordering is the database's, not .NET's:
| Field type | Ordered by | Worth knowing |
|---|---|---|
string | the column's collation | $gt:m returns different rows under a case-sensitive and a case-insensitive collation. The engine does not impose one. Over a plain IQueryable there is no collation to follow, so ranges and sortBy both use StringComparison.InvariantCulture — deterministic, rather than whatever culture the host happens to run under. |
Guid | the database's byte order | which is not always .NET's Guid.CompareTo order. The same divergence already applies to sorting a Guid column; filters inherit it rather than introduce it. |
| enums | the underlying integral value, not the member name | so it follows declaration order. A model that maps the enum to text cannot translate this. |
bool | — | 400 Filter 'x' does not support comparison operators for type 'Boolean'. There is no ordering to ask for; use $eq. |
A NULL column never matches an un-negated comparison, in either direction — the same three-valued logic $eq follows. Under $not it does match; the negation covers the null guard too.
$btw — inclusive range
Exactly two comma-separated values; anything else is 400 Filter 'x' requires exactly two '$btw' values. It expands to the pair of comparisons rather than a BETWEEN keyword:
?filter.rank=$btw:20,50WHERE "p"."Rank" >= @p AND "p"."Rank" <= @p1Which is byte-for-byte what ?filter.rank=$gte:20&filter.rank=$lte:50 produces. $btw is a shorthand, not a different query.
Fields that are not columns
A filterable field can point at a related entity's property, in which case the engine joins:
?filter.categoryName=$eq:ElectronicsFROM "Products" AS "p"
LEFT JOIN "Categories" AS "c" ON "p"."CategoryId" = "c"."Id"
WHERE "c"."Name" = @pA field declared with FilterableMany(...) matches when any element of a sub-collection satisfies the criterion, which becomes an EXISTS:
?filter.reviewer=$eq:annWHERE EXISTS (SELECT 1 FROM "Reviews" AS "r" WHERE "p"."Id" = "r"."ProductId" AND "r"."Reviewer" = @p)?filter.rating=$gte:4WHERE EXISTS (SELECT 1 FROM "Reviews" AS "r" WHERE "p"."Id" = "r"."ProductId" AND "r"."Rating" >= @p)Note what that means for a request combining two of them: ?filter.reviewer=$eq:ann&filter.rating=$gte:4 matches a product with a review by ann and a review rated 4 or better — not necessarily the same review. Each criterion gets its own EXISTS. When you need "the same element satisfies both", model it as one filterable field over a computed value.
$not — negation
Negates the single criterion it prefixes, and the negation reaches the SQL rather than wrapping it in a NOT (…):
?filter.status=$not:$eq:Discontinued → WHERE "p"."Status" <> @p
?filter.name=$not:$ilike:apple → WHERE "p"."Name" NOT LIKE @p ESCAPE '\'
?filter.deletedAt=$not:$null → WHERE "p"."DeletedAt" IS NOT NULLA NULL row matches $not:<anything>. The negation is applied to the whole criterion, null guard included, so ?filter.deletedBy=$not:$eq:ann reads as "deleted by someone other than ann" and also returns every row that was never deleted at all. Both legs agree on this, which is why no test comparing them can reveal it. When that is not what you want, combine the negation with $not:$null on the same field, or filter the nullable key instead of the joined value.
Repeating a modifier does not accumulate it: $not:$not:$eq:x is a single negation, not a double one, and $and:$or: is last-wins. A client that negates a criterion by wrapping its string in $not: therefore gets a toggle that sticks — negate on the value, not on the already-prefixed string.
$and / $or — combining criteria on one field
Repeat filter.<field> to apply several criteria to the same field. Each criterion says how it joins the ones before it; the default is $and. A connector therefore only means something when there is something before it: a field's first criterion may not carry one, and ?filter.status=$or:$eq:Draft is a 400 Filter 'status' must not begin with '$or'; a connector joins a criterion to the one before it. That matters for a client that builds criteria uniformly and prefixes every one of them — the whole field would otherwise have been AND-ed and returned an empty page with no error.
# 20 <= rank <= 50
?filter.rank=$gte:20&filter.rank=$lte:50WHERE "p"."Rank" >= @p AND "p"."Rank" <= @p1# status is Active OR Draft
?filter.status=$eq:Active&filter.status=$or:$eq:DraftWHERE "p"."Status" = @p OR "p"."Status" = @p1Criteria on different fields are always joined with AND. There is no cross-field OR and no grouping / parentheses — that is a deliberate ceiling on how much query language is exposed. Combine on one field, and express anything richer as a dedicated filterable field.
Value formats
| Target type | Accepted |
|---|---|
string | anything, used verbatim |
bool | true / false, case-insensitive (not 1 / 0) |
char | exactly one character. A space cannot be expressed — an all-whitespace value is read as empty, and empty is a 400, see below. |
Guid | any format Guid.TryParse accepts |
integers (byte, sbyte, short, ushort, int, uint, long, ulong), decimal, float, double | invariant culture — . as the decimal separator and an optional leading sign, with no group separator: 1,5 is a 400, not fifteen. A magnitude the type cannot hold is a 400 too, NaN and Infinity included — $gt:1e400 on a double would otherwise compare against infinity and answer an empty page indistinguishable from "no rows match". |
DateTime, DateTimeOffset | ISO-8601 with a mandatory date: 2026-01-31, 2026-01-31T23:59, 2026-01-31T23:59:59 or 2026-01-31T23:59:59.1234567. The three forms that carry a time may be suffixed Z or +01:00; the bare date may not, so 2026-01-31Z is a 400. A value with no offset is read as UTC. A value with no date is a 400 rather than a silent "today", which would make a stored filter link mean something else after midnight. |
DateOnly | yyyy-MM-dd only. A value carrying a time (2026-01-03T10:00:00) is a 400 rather than a silent match on the whole day. |
TimeOnly | HH:mm, HH:mm:ss or HH:mm:ss.FFFFFFF — the fractional part is optional-width, so one to seven digits are accepted and 10:30:00.5 needs no padding. A value carrying a date is a 400, for the same reason. |
TimeSpan | h:mm, h:mm:ss or h:mm:ss.FFFFFFF — fractional digits optional-width here too (.NET's own form, colon required, optionally signed) or ISO-8601 PT2H30M. The hour component stops at 23: 24:00:00 is a 400 rather than twenty-four days, and a day count is spelled P5D. A bare number is a 400 — TimeSpan.TryParse would read 2 as two days. So is a duration in years or months (P1M): those have no fixed length, and answering with a 30-day approximation would be a filter for something the caller did not ask for. |
| enums | by name only and one name at a time, case-insensitive (Active, active). A numeric value is rejected however it is padded, and so is a comma-separated list — $in is the operator that takes several values, which means a [Flags] enum is addressed through its declared members. |
Instant, LocalDate, LocalDateTime, LocalTime, OffsetDateTime, YearMonth, Duration | ISO-8601, with the .NodaTime package — see NodaTime for the per-type forms |
any type implementing IParsable<TSelf> | whatever its own TryParse accepts, in the invariant culture — no registration needed |
| anything else | 400, unless registered via PaginateTypeSupport |
Resolution order is registry → built-ins → IParsable<TSelf> → 400. A parser you register therefore overrides a built-in one, which is what makes the table above a default rather than a ceiling.
An empty value (?filter.price=$eq:) is a 400 on every non-string field, nullable or not: it used to convert to null on a nullable one, which is $null spelled implicitly and without the field's allow-list being asked about it. $null is the way to match rows with no value, and the message says so. An empty value on a string field is unchanged — there it is a value, not an absence.
No escaping inside value lists. $in, $btw and $contains-on-a-collection split on ,; a value that itself contains a comma cannot be expressed. Single-value operators take the value whole, commas included. Entries are not trimmed: the padding in $in:a, b is part of the second value, exactly as it would be after $eq:. One spelling, one value, whichever operator asks for it.
Values are emitted as SQL parameters (EF.Parameter), never inlined literals — every @p on this page is that at work. The database can reuse one plan across every value your callers send, and a value can never be read as SQL.
One request, end to end
GET /products?page=1&limit=3&sortBy=status:ASC&sortBy=rank:DESC
&search=wid&filter.status=$eq:Active&filter.rank=$btw:20,50becomes one count and one page, both fully parameterised:
SELECT COUNT(*)
FROM "Products" AS "p"
WHERE "p"."Status" = @p AND "p"."Rank" >= @p1 AND "p"."Rank" <= @p2 AND ("p"."Name" LIKE @p3 ESCAPE '\' OR ("p"."Description" IS NOT NULL AND "p"."Description" LIKE @p3 ESCAPE '\'))
SELECT "p"."Id", "p"."Name", "p"."Status", "p"."Rank"
FROM "Products" AS "p"
WHERE "p"."Status" = @p AND "p"."Rank" >= @p1 AND "p"."Rank" <= @p2 AND ("p"."Name" LIKE @p3 ESCAPE '\' OR ("p"."Description" IS NOT NULL AND "p"."Description" LIKE @p3 ESCAPE '\'))
ORDER BY "p"."Status", "p"."Rank" DESC, "p"."Id"
LIMIT @p8 OFFSET @p7Four criteria, one WHERE, and not a single value inlined. Note the parameter numbering: the engine reuses @p3 for both halves of the search, and the paging parameters land at the end wherever the count of earlier parameters puts them.
The SELECT list is the projection's, not the entity's — see Projections for how that list is decided and how to widen it without loading the whole row.