API Filtering and Sorting
Conventions for filtering collections and sorting results in REST APIs — LHS bracket notation, RHS colon notation, multi-field sort syntax, sparse fieldsets, partial responses, comparison operators, GraphQL field selection vs REST partial responses, and documenting query parameters in OpenAPI.
What it is
Filtering and sorting are query capabilities on collection endpoints that let clients retrieve the subset of data they need without receiving the entire collection. They work alongside pagination: filtering reduces the total result set; sorting determines the order; pagination retrieves it in pages.
Unlike pagination, which has established specifications (Relay, RFC 5988), filtering and sorting conventions are largely community-driven, with several competing styles. The goal is to choose a consistent, secure, and indexable approach for your API and document it thoroughly in OpenAPI.
Filtering approaches
Simple equality filters
GET /orders?status=confirmed
GET /orders?customerId=42&status=pending
The simplest approach: map query parameters directly to field values. Sufficient for equality filters on a known set of filterable fields. Explicitly allowlist filterable fields — never pass arbitrary query parameters directly to a database query.
LHS bracket notation
GET /orders?total[gte]=100&total[lte]=500
GET /orders?createdAt[gte]=2024-01-01&status[in][]=pending&status[in][]=confirmed
The Left-Hand Side bracket notation encodes the operator as a bracket suffix on the field name. Used by Loopback, JSON:API filter implementations. Operators: [eq], [ne], [gt], [gte], [lt], [lte], [in], [nin], [like], [ilike].
RHS colon notation
GET /orders?filter=total:gte:100,total:lte:500
GET /orders?filter=status:in:pending|confirmed
GET /orders?total=gte:100&createdAt=gte:2024-01-01
The Right-Hand Side colon notation places the operator in the value: field=operator:value. Popularised by some API gateway implementations and custom DSLs. Less portable but compact for complex filters.
FIQL (Feed Item Query Language)
GET /orders?q=status==confirmed;total=gt=100
GET /orders?q=status==pending,status==confirmed (OR)
FIQL (RFC draft) uses ==, !=, =gt=, =lt=, =ge=, =le= operators with ; for AND and , for OR. Used by Apache CXF and some feed/event APIs. Expressive but requires a dedicated parser.
Filter as JSON body
GET /orders
Content-Type: application/json
{
"filter": {
"status": { "$in": ["pending", "confirmed"] },
"total": { "$gte": 100, "$lte": 500 },
"createdAt": { "$gte": "2024-01-01" }
}
}
A JSON body filter (MongoDB-style syntax) is expressive and supports arbitrary nesting. However, GET requests with bodies violate HTTP semantics and break caching. Use POST to a search-specific endpoint (POST /orders/search) when complex filter expressions are required. See Elasticsearch's Query DSL as a reference implementation.
Sorting conventions
Single field ascending:
GET /orders?sort=createdAt
Single field descending (prefix with minus):
GET /orders?sort=-createdAt
Multiple fields (comma-separated, precedence left to right):
GET /orders?sort=-createdAt,id
Alternative (sort and order as separate params):
GET /orders?sortBy=createdAt&order=desc
Alternative (repeated sort param):
GET /orders?sort[]=createdAt:desc&sort[]=id:asc
The -field prefix for descending is the most concise and widely adopted convention (used by JSON:API, Stripe, GitHub). Ensure all sortable fields have database indexes. For pagination stability, always include a tiebreaker (e.g., unique id) as the final sort key.
Allowlist sortable fields — accepting arbitrary sort values and passing them to ORDER BY is a SQL injection risk. Define explicitly which fields are sortable.
Sparse fieldsets and partial responses
REST partial response (Google API style):
GET /orders/42?fields=id,status,total,customer.name
→ { "id": "42", "status": "confirmed", "total": 149.99,
"customer": { "name": "Alice" } }
JSON:API sparse fieldsets:
GET /articles?fields[articles]=title,body&fields[people]=name
GraphQL field selection (always explicit):
query {
order(id: "42") {
id
status
total
customer { name }
}
}
Sparse fieldsets allow clients to request only the fields they need, reducing payload size and improving performance for bandwidth-constrained clients. GraphQL makes field selection mandatory and structural. In REST, partial responses are opt-in via a fields query parameter. Implement with a projection list — never dynamically construct SELECT clauses from unvalidated input.
Security considerations
- Allowlist filterable and sortable fields — reject unrecognised filter parameters with 400, not silently ignore them. Never pass unvalidated input to a SQL WHERE or ORDER BY clause.
- Validate filter operators — only allow operators explicitly supported for each field type (e.g.,
likeon an indexed text field, not on a UUID). - Authorisation-aware filtering — a
customerIdfilter must be enforced server-side regardless of what value the caller provides — never trust the caller to self-scope their query. - Limit filter complexity — unbounded deeply nested filter expressions can cause expensive query plans; cap nesting depth and number of conditions.
OpenAPI documentation
paths:
/orders:
get:
parameters:
- name: status
in: query
description: Filter by order status. Multiple values are OR'd.
schema:
type: array
items:
type: string
enum: [pending, confirmed, shipped, delivered, cancelled]
style: form
explode: true
- name: "total[gte]"
in: query
description: Filter orders with total greater than or equal to value.
schema:
type: number
minimum: 0
- name: sort
in: query
description: |
Sort field. Prefix with - for descending.
Examples: sort=createdAt (ascending), sort=-createdAt (descending).
Comma-separated for multi-field: sort=-createdAt,id
schema:
type: string
pattern: "^-?[a-zA-Z]+(,-?[a-zA-Z]+)*$"
examples:
newest_first:
value: "-createdAt"
oldest_first:
value: "createdAt"
Pros and cons
Pros of structured filter params
- Declarative — clients express intent without knowing the backend query language.
- Cacheable — GET requests with query params are cached by HTTP caches and CDNs.
- Easy to document in OpenAPI with specific operators per field.
- Stateless — filter state is entirely in the URL; URLs are shareable and bookmarkable.
Cons
- No single standard — LHS brackets, RHS colons, FIQL all exist; pick one and document it.
- Complex boolean logic (AND/OR/NOT combinations) is awkward in query params; use POST /search.
- URL length limits constrain complex filter expressions — browsers cap URLs at ~2,000 chars.
- Maintaining index coverage for every filter combination is an ongoing DBA challenge.
Decision checklist
- Are filterable and sortable fields explicitly allowlisted (not dynamic)?
- Are database indexes in place for the most common filter/sort combinations?
- Is the filter convention (LHS bracket, RHS colon, simple equality) consistently applied across all endpoints?
- Are all filter parameters documented in OpenAPI with examples?
- Are server-side authorisation constraints applied regardless of filter values (e.g., customer scoping)?
- For complex search requirements, is POST /resource/search used instead of an unwieldy GET?
Related patterns
- API Pagination — filtering determines the total result set; pagination retrieves it in pages.
- REST API Design — filtering is a core collection query capability.
- OpenAPI Specification — document all filter parameters and their operators.
- GraphQL — field selection and filtering are first-class in GraphQL query language.
Further reading
- JSON:API Filtering — jsonapi.org/recommendations/#filtering
- Google API Design Guide — filtering and sorting — cloud.google.com/apis/design/standard_fields
- Microsoft REST API Guidelines — filtering — github.com/microsoft/api-guidelines
- Elasticsearch Query DSL — reference for POST-based search endpoints.