"Once you have pagination, the next three things clients ask for are: 'filter by X,' 'sort by Y,' and 'give me only fields A, B, C.' These don't need GraphQL — they need consistent query conventions."
Four Common Query Surfaces
List endpoints accumulate four kinds of query parameters as they grow:
- Filtering — narrow the set:
?status=active&role=admin - Sorting — order the set:
?sort=-created_at,name - Sparse fieldsets — trim each item:
?fields=id,name - Related includes — expand related resources:
?include=orders,profile
Each has multiple competing conventions. Pick one and stick to it across your whole API; consistency matters more than which specific convention you chose.
Filtering Conventions
Simple equality (most common): ?status=active means "items where status equals active." ?status=active&role=admin means AND. Easy to parse, easy to document, covers 80% of needs.
Comma-separated for IN: ?status=active,pending means "status is one of these." Some APIs use repeated params instead (?status=active&status=pending) — both common.
Operator suffix for ranges: ?created_at_gte=2026-01-01&created_at_lt=2026-06-01. The suffix convention varies (Django uses __gte, REST APIs often _gte or [gte]=).
RSQL / FIQL for complex queries: ?filter=status==active;role==admin;created_at=ge=2026-01-01. A mini query language. Rarely worth the complexity unless you have a true "power user" segment.
When filtering needs a query body: if your filter is genuinely complex (nested boolean logic, large IN lists, full-text with operators), switch to POST + JSON body. URL length limits are real (most servers cap at 8KB). That's no longer GET — it's a POST that doesn't change state, which violates safety in spirit but is the practical workaround. Document the deviation.
Sorting Conventions
Three common patterns:
- Sign prefix (terse):
?sort=-created_at,name— minus prefix for descending, comma for multi-field. Most compact; widely understood. - Colon suffix:
?sort=created_at:desc,name:asc— explicit, more verbose, friendlier for some tooling. - JSON:API style:
?sort=-created_at,name(same as sign prefix). JSON:API standardized this.
Validate allowed sort fields server-side — if you let clients sort on arbitrary columns, you've created a denial-of-service via unindexed sorts. Whitelist: {created_at, updated_at, name} allowed; everything else rejected with 400.
Sparse Fieldsets — Save Bytes Without GraphQL
Sometimes clients only need a few fields. Why download the full user record when a typeahead just needs {id, display_name}?
GET /users/42?fields=id,display_name
→ {"id":"u_42","display_name":"Pippa"}
GET /users?fields=id,display_name,avatar_url
→ {"items":[{"id":"u_42","display_name":"Pippa","avatar_url":"..."}, ...]}
JSON:API formalizes per-type fieldsets: ?fields[user]=name,email&fields[order]=total. Useful when responses include multiple resource types via includes.
Bandwidth savings can be 10x+ for clients that only need summaries. Implementation cost: server filters fields in the serializer; caching layer needs Vary: query (or specifically Vary: Accept-Encoding + query-aware caching) so different field selections aren't served stale.
Related Includes — The 'Avoid N+1' Query
A user has orders. Clients displaying a user often need the orders too. Two ways to deliver:
- N+1 (bad): GET /users/42, then for each order GET /orders/{id}. N requests for one user.
- Include parameter (good): GET /users/42?include=orders returns the user plus the related orders in one shot.
JSON:API standardizes this with a top-level included array; ad-hoc APIs nest the related resources inline. The point is the same: avoid the request-amplification trap.
When to Reach for GraphQL or POST-Body Queries
If your clients regularly want "give me this user, plus their last 10 orders, with only certain fields per order, plus the user's account balance" — that's GraphQL territory or POST-body-query territory. Query parameters were never designed for that complexity.
The signs you've outgrown query parameters:
- URLs approaching 2KB (or hitting CDN/proxy limits).
- Filter logic with nested AND/OR/NOT.
- Clients regularly assembling 5+ query parameters.
- Documentation that requires a separate "query syntax" page.
Reach for GraphQL only if you have many clients with many different query needs. For most REST APIs, query parameters + sparse fieldsets + includes are enough.
cwkPippa's Query Reality
?brain=claude on session lists, ?archived=true, ?folder_id=.... No sorting parameter (default created_at desc); no fieldsets (responses are already lean); no includes (the React client is fine with N+1 because everything is local). When the council list grew past 100 rounds, the existing offset pagination + ?status=active filter handled it. Query complexity has stayed low; query conventions have stayed simple. That's not luck — it's a deliberate "don't grow features without proving you need them" stance.