skip to content

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%

answer

  1. created_at[gte]= vs created_at=gte: vs RSQL/OData
  2. brackets → nested parsers; RHS → flat parsers, split on first colon
  3. parameter forms = implicit AND only
  4. expression language buys OR/grouping, costs unbounded cost
  5. whitelist field×operator, 400 not silent-ignore

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.

solid answer

~50 s

**Bracket suffix** — `?created_at[gte]=2024-01-01&status[in]=open,paid`. Reads well, maps naturally to nested parameter parsers (PHP/Rails/qs style), and each parameter is independently validated. Combination is implicit AND only. **RHS colon** — `?created_at=gte:2024-01-01`. Same expressiveness with flat parsing, no bracket encoding issues; the cost is that the value now needs splitting, so values containing `:` (timestamps!) need care about splitting on the *first* colon only. **Expression languages** — RSQL/FIQL (`?filter=created_at=ge=2024-01-01;status=in=(open,paid)`) or OData (`?$filter=created_at ge 2024-01-01 and status eq 'open'`). These give AND/OR/NOT and grouping, which the parameter forms simply cannot express, and OData brings a whole ecosystem. The price is a real grammar to parse, a translation layer, and a genuinely unbounded query surface you must budget and cap. My default: bracket or RHS operators with a whitelisted field/operator matrix. I reach for RSQL/OData only when clients demonstrably need OR and grouping across fields, and then I cap depth, term count, and cost.

code

http · 7 lines
http
GET /v1/orders?created_at[gte]=2024-01-01&status[in]=open,paid HTTP/1.1

GET /v1/orders?created_at=gte:2024-01-01&status=in:open,paid HTTP/1.1

GET /v1/orders?filter=created_at=ge=2024-01-01;status=in=(open,paid) HTTP/1.1

GET /v1/orders?$filter=created_at ge 2024-01-01 and (status eq 'open' or status eq 'paid') HTTP/1.1

go deeper

for a junior

Show the three shapes with a concrete example each and say that plain parameters mean equality AND-ed together.

for a middle

Compare parsing implications (nested vs flat), the first-colon split trap, and that only an expression language gives OR and grouping.

for a senior

Lead with the whitelist and the cost story: bounded parameter grammars vs an unbounded expression surface, caps, 400-not-ignore, and type coercion.

for a principal

Decide it as a platform standard — one grammar across the estate, the field×operator matrix as versioned public contract, and a documented escape hatch (a search endpoint with a JSON body) for queries the URL grammar shouldn't carry.

## The starting point A query string maps names to values, so `?status=open&owner_id=42` naturally means "status equals open AND owner equals 42". Everything beyond equality-and-AND needs an encoding decision. ## Form 1: bracket suffix operators ``` ?created_at[gte]=2024-01-01&created_at[lt]=2024-02-01&status[in]=open,paid&name[contains]=acme ``` The field is the parameter name, the operator is a bracketed suffix. Stripe, Shopify-era APIs and many Rails/Node stacks use this because their query parsers (`qs`, Rack) already decode `a[b]=c` into a nested map. Advantages: each parameter is a self-contained, independently validatable unit; the field name stays at the front, which makes documentation and whitelisting straightforward; two constraints on the same field compose naturally as a range. Disadvantages: brackets should be percent-encoded (`%5B`/`%5D`) to be strictly correct, though every browser and server tolerates them raw; and it is implicit-AND only — there is no way to say "status open OR total > 1000". ## Form 2: right-hand-side operator prefixes ``` ?created_at=gte:2024-01-01&price=lt:100&status=in:open,paid ``` The parameter name is exactly the field, and the operator rides on the value. Parsing stays flat — no nested-parameter support required, which suits Java/Go/Kotlin stacks whose query parsers are flat maps. The trap is splitting: an ISO-8601 timestamp `2024-01-01T10:00:00Z` contains colons, so you must split on the **first** colon only, and you need a rule for a literal value that itself starts with a known operator prefix (usually: require the prefix form always, or offer an `eq:` escape). Same expressiveness and same implicit-AND limit as brackets. ## Form 3: a real expression language **FIQL/RSQL** — one parameter carrying an infix expression: `filter=created_at=ge=2024-01-01;(status==open,total=gt=1000)` where `;` is AND and `,` is OR. Compact, URL-friendly by design, and there are ready parsers (e.g. rsql-parser in the JVM world) that hand you an AST. **OData `$filter`** — `$filter=created_at ge 2024-01-01 and (status eq 'open' or total gt 1000)`. A full OASIS-standard query language with functions (`contains`, `startswith`), plus `$select`, `$orderby`, `$top`, `$skip`, `$expand`. Huge expressiveness and a real tooling ecosystem; also a large surface to implement, and a spec your team must actually read. **GraphQL-style structured filters in a JSON body** (`POST /orders/search`) is the fourth cousin — worth naming because it is the honest escape hatch when queries exceed URL length or need nesting, at the cost of losing GET caching and clean bookmarkable URLs. ## What actually decides it **Do clients need OR across fields?** If the answer is genuinely no — and for most CRUD-ish APIs it is — the parameter forms win on every other axis: simpler to document, simpler to validate, simpler to translate, and impossible to write a pathological query in. **Cost boundedness.** A parameter form has a fixed maximum shape: N whitelisted fields × M allowed operators. An expression language lets a caller compose an arbitrarily deep predicate; you must then cap expression depth, term count, and the number of non-indexed fields per query, and you should reject rather than silently degrade. Teams that adopt RSQL/OData without those caps discover it when one caller writes a 40-term OR. **Evolvability.** Adding an operator to a parameter form is additive and safe. Adding one to an expression language means grammar and parser changes, and any operator you support is effectively permanent contract. **Consistency.** Whichever you pick, use it identically across every resource. Two grammars in one API is the outcome nobody wants and the most common real-world state. ## The rules that apply to all three Whitelist which fields are filterable and which operators each supports — filtering is a defined capability per field, not a passthrough. Reject unknown fields and unsupported operators with `400` and a machine-readable body naming the offender, never silently ignore them (silent ignoring returns unfiltered data, which in an authorization-adjacent context is a security incident, not a usability wart). Validate and coerce values by field type before they reach the storage layer. Document type-specific semantics — is `name[contains]` case-sensitive? are date bounds inclusive? — because those are the questions that generate support tickets. ## A strong answer "Brackets or RHS prefixes over a whitelisted field×operator matrix, because 90% of real filter needs are AND-of-constraints and those forms stay validatable and cost-bounded. RSQL or OData only when clients need OR and grouping, and then with depth/term caps and a translation layer I control. Whatever I pick, one grammar for the whole API, 400 on anything not whitelisted."

  • Why is silently ignoring an unrecognized filter parameter dangerous rather than merely sloppy?
    Because the response then contains rows the caller believed were excluded. If a client relies on `?owner_id=42` to scope a list and the server drops the unknown parameter, it returns every owner's records — a data exposure caused by a typo. Failing closed with a `400` naming the unsupported parameter turns a silent leak into a loud, fixable error.
  • How do you keep an expression language like RSQL or OData from becoming a denial-of-service surface?
    Cap the parsed AST before executing it: maximum depth, maximum number of terms, maximum number of distinct fields, and a rule that at least one term hits a supported filterable-and-indexed field. Add a per-query timeout and separate rate limits or quotas for filtered list calls, and reject over-complex queries with a clear `400` rather than degrading them silently.

saying these in an interview costs you the question

  • Splitting an RHS value like `created_at=gte:2024-01-01T00:00:00Z` on every colon instead of only the first
  • Claiming a parameter grammar can express OR across different fields
  • Interpolating the client's field name or operator into a query string instead of mapping through a whitelist
  • Adopting OData or RSQL without caps on expression depth and term count
  • Using two different filter grammars in different parts of the same API

context