skip to content

Filtering and Sorting Grammars

Designing the query-parameter language for filters and sorts: simple key=value, operator grammars, and multi-field sort syntax — plus whitelisting so clients can't query arbitrary columns. Interviewers ask to see if you can design a small grammar that stays consistent as the API grows.

part ofAPI stylesoverview, primer and where to startread it →
on this pageshow

questions

4

Design the query parameter that lets a client of a REST list endpoint sort by several fields at once, in either direction — for example newest first, then by name ascending. What syntax would you choose and why?

level: juniorimportance: must knowfreq 58%

answer

  1. sort=-created_at,name — order is precedence
  2. `-` desc, bare asc (avoid `+`, it decodes to space)
  3. whitelist fields, 400 on unknown
  4. always append id tiebreaker → total order
  5. changing sort invalidates a cursor

basics

~10 s

Use one comma-separated sort parameter where order matters and a leading - means descending: sort=-created_at,name. Whitelist the allowed field names, define a default sort, and always append a unique tiebreaker such as the id.

solid answer

~50 s

I'd use a single parameter carrying an ordered list: `?sort=-created_at,name`. Leading `-` means descending, no prefix means ascending, and left-to-right order is precedence. This is the JSON:API convention and the most widely recognized one. The alternatives are `sort=created_at&order=desc`, which can't express a second key and pairs two parameters that can get out of sync, and `sort[created_at]=desc&sort[name]=asc`, which is explicit but loses ordering because query-parameter order isn't reliably preserved through every client and gateway. The rules that matter beyond syntax: **whitelist** sortable fields — sorting is not a passthrough to arbitrary columns; define an explicit **default sort** so responses are deterministic without the parameter; and always append a **unique tiebreaker** (usually the id) to the sort key, otherwise rows with equal `created_at` can arrive in different orders on different pages and users see duplicates and gaps. Reject unknown fields with 400 rather than ignoring them silently.

code

http · 15 lines
http
GET /v1/orders?sort=-created_at,name&limit=50 HTTP/1.1

HTTP/1.1 200 OK

GET /v1/orders?sort=internal_cost HTTP/1.1

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/invalid-sort",
  "title": "Unsupported sort field",
  "detail": "'internal_cost' is not sortable",
  "allowed": ["created_at", "updated_at", "name", "total"]
}

go deeper

for a junior

Give the sort=-created_at,name syntax, say - means descending and left-to-right is precedence, and mention whitelisting.

for a middle

Compare the syntaxes and their failure modes, and raise the tiebreaker requirement and the documented default sort.

for a senior

Add null ordering, cost of sorting on joined/computed fields, cursor-vs-sort invalidation, and how you'd return an actionable 400.

for a principal

Set it as an estate-wide contract: one sort grammar in the style guide, a declared sortable-field set per resource treated as part of the public contract, and how you deprecate a sort field once clients depend on it.

## The candidate syntaxes **Prefixed comma list — `?sort=-created_at,name`.** One parameter, ordered left to right, `-` for descending. Used by JSON:API and a large share of public APIs. It is compact, unambiguous about precedence, easy to log, and trivially parseable by splitting on commas. Its only real awkwardness is that `-` and `+` must be handled carefully in URL encoding: `+` decodes to a space in a query string, so if you want an explicit ascending marker use `asc`/nothing rather than `+`. **Two parameters — `?sort=created_at&order=desc`.** Reads naturally and is common in older APIs, but it cannot express multi-field sorting at all, and it introduces a bad state: `order=desc` with no `sort`. Extending it later (`sort2`, `order2`) is worse than switching. **Bracketed map — `?sort[created_at]=desc&sort[name]=asc`.** Explicit per field, but ordering between the keys is not guaranteed to survive every client library, proxy, or framework's parameter parsing, and precedence is the whole point of a multi-field sort. **Suffix form — `?sort=created_at:desc,name:asc`.** Perfectly good and used widely; slightly more verbose than the `-` form and needs the colon percent-encoded in some contexts. Choose it if you find `-` cryptic; just be consistent. Any of these is defensible. What is not defensible is inventing a different one per endpoint. ## Rules beneath the syntax **Whitelist.** Sortable fields are a defined, documented set. This is not only about injection — an ORM will usually parameterize the column name safely or reject it — it is about the fact that sorting by an unsupported field can force an expensive full sort of the collection, and about not leaking internal column names or letting callers sort by fields they shouldn't see. Return `400` with a machine-readable error naming the offending field and, ideally, listing the allowed ones. **Default sort.** Without a `sort` parameter, the response order must still be deterministic and documented. "Whatever the storage returns" is not an order: it changes with plan choice, storage layout, and replica. Undocumented default order is a real production bug source because clients start depending on the accident. **Total ordering — the tiebreaker.** This is the detail that separates a rehearsed answer from a real one. If you sort by `created_at DESC` and a thousand rows share the same millisecond, the relative order of those rows is unspecified and may differ between the query that produced page 1 and the query that produced page 2. The user then sees an item twice and never sees another. The fix is to always append a unique, stable field: `ORDER BY created_at DESC, id DESC`. Expose it or not, but always apply it. Cursor pagination depends on this even more strictly — a cursor is meaningless without a total order. **Direction of the tiebreaker** should follow the primary key's direction so paging stays monotonic. **Nulls.** Decide and document where nulls land (`NULLS LAST` is the friendlier default for descending recency sorts) rather than inheriting whatever the storage engine does, because that differs across engines and will differ if you ever migrate. **Sorting on computed or joined fields** — `sort=owner.name`, `sort=-comment_count` — should be an explicit, small, deliberately supported list, not an emergent capability, because each one implies a join or an aggregate on the read path. **Interaction with filtering and cursors.** If the client can change the sort, the cursor from a previous sort order is invalid: a cursor encodes a position in a particular ordering. Either encode the sort into the cursor and reject mismatches with a `400`, or ignore the client's sort when a cursor is supplied. Silently applying a new sort to an old cursor produces nonsense pages. ## What good sounds like "`?sort=-created_at,name` — one parameter, order is precedence, `-` is descending. Whitelist the fields, document the default, and always add id as the final tiebreaker so the order is total; otherwise paging duplicates rows. And if a cursor is present, the sort is fixed by the cursor."

  • Why append the id to every sort even when the client didn't ask for it?
    To make the ordering total. If the requested sort key has ties, the relative order of tied rows is unspecified and can differ between requests, so paging over them duplicates some rows and skips others. Appending a unique column — usually the primary key, in the same direction as the last requested key — guarantees a single deterministic order across every page.
  • A client sends `sort=name` together with a cursor obtained under `sort=-created_at`. What should the API do?
    Reject it with `400`. A cursor encodes a position within a specific ordering, so applying a different sort to it yields an arbitrary slice with duplicates and gaps. Encode the sort (and filters) inside the cursor and validate that the request matches; the alternative — ignoring the client's sort silently — is confusing but at least not incorrect.

saying these in an interview costs you the question

  • Offering only `sort=field&order=desc`, which cannot express a second sort key
  • Assuming the ordering of bracketed parameters like `sort[a]=asc&sort[b]=desc` is preserved end to end
  • Passing the client's field name through to the storage layer without a whitelist
  • Not adding a unique tiebreaker, then blaming pagination for duplicated rows
  • Silently ignoring an unknown sort field instead of returning 400, so clients think a sort applied when it did not

context

open as a page

Plain `field=value` query parameters can only express equality. Compare the common ways a REST API expresses richer filter operators — bracket suffixes like `created_at[gte]=`, right-hand-side prefixes like `created_at=gte:`, and full expression languages such as RSQL/FIQL or OData `$filter` — and say which you'd pick.

level: middleimportance: must knowfreq 52%

basics

~20 s

Bracket suffixes (created_at[gte]=2024-01-01) and RHS prefixes (created_at=gte:2024-01-01) add per-field operators while keeping ordinary query parsing; RSQL/FIQL and OData $filter add a real expression language with AND/OR/grouping, at the cost of a parser, a validator, and unbounded query complexity. Start with the simple forms.

open as a page

Your list endpoint forwards query-string filter parameters into the data layer, and clients may name any field and any operator. What goes wrong in production, and how do you constrain the surface without crippling the API?

level: seniorimportance: must knowfreq 50%

basics

~20 s

Arbitrary filters leak internal and unauthorized fields, enable expensive unindexed scans, and can inject query fragments. Constrain with an explicit per-resource field×operator whitelist, typed value coercion, cost caps, and 400 on anything not allowed — never silent ignore.

open as a page

You are setting the filtering grammar for a public API that many teams will extend over years, and clients keep asking for richer queries. How do you decide between a small fixed set of filter parameters and a general query language, and how do you leave room to evolve without a breaking change?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Decide on evidence, not aesthetics: adopt a general language only when many clients genuinely need OR and grouping. Start with a bounded field×operator grammar, keep it additive, reserve syntax up front, and offer a separate search endpoint as the escape hatch for queries the URL should not carry.

open as a page