REST API Query Parameters for Sorting and Filtering: Patterns, Pitfalls, and Examples

Design REST API sorting and filtering query parameters: proven patterns, examples, security, pagination, and performance tips for scalable endpoints.

ASOasis
8 min read
REST API Query Parameters for Sorting and Filtering: Patterns, Pitfalls, and Examples

Image used for representation purposes only.

Why sorting and filtering matter in REST APIs

Efficient sorting and filtering are table stakes for any production-grade REST API. They determine how quickly clients can shape collections into meaningful results, how stable pagination remains over time, and how easily you can cache responses. Well-designed query parameters make your API predictable, fast, and secure; poorly designed ones lead to brittle clients, runaway queries, and support tickets.

This article distills proven patterns, trade‑offs, and examples for implementing sort and filter query parameters that scale.

Core principles and naming conventions

Before syntax, align on a few principles:

  • Keep it HTTP-friendly: use GET for retrieval. Sorting and filtering belong in the query string.
  • Be consistent: pick one style and stick to it (snake_case or camelCase; bracket or dot notation; operator names).
  • Be explicit about types: dates, booleans, and numbers should be unambiguous.
  • Validate and allowlist: only permit known fields and operators.
  • Make results deterministic: specify default sort orders to stabilize pagination and caches.

Common, readable conventions:

  • Bracketed operators: price[gte]=10&price[lte]=100
  • Dot operators: price.gte=10&price.lte=100
  • Repeated keys for multi-values: status=active&status=pending
  • JSON:API-inspired sort: sort=price,-createdAt (leading “-” means descending)

Any of these are viable; choose the one your audience finds most familiar and document it thoroughly.

Designing sort parameters

A compact, expressive sort helps clients tailor ordering without confusion.

Recommended options:

  • Single or multi-field:
    • sort=price
    • sort=price,-createdAt (primary ascending by price, tie-break by createdAt descending)
  • Separate order param (more verbose, less common):
    • sort=price&order=desc
  • Null handling and stability:
    • Define whether nulls come first or last (e.g., NULLS LAST).
    • Always add a stable secondary key (often id) to guarantee consistent pagination when primary keys tie.

Examples

GET /products?sort=price,-createdAt
GET /users?sort=-lastLoginAt,id

Implementation tips

  • Maintain an allowlist of sortable fields to prevent injection and poor plans.
  • Map client names to database columns (e.g., createdAt -> created_at).
  • Normalize the incoming sort string into a structured array your data layer understands.

Designing filter parameters

Filtering varies more than sorting. Aim for a small, orthogonal set of operators that cover 90% of needs.

Common operators

  • Equality and inequality: eq, ne
  • Comparisons: lt, lte, gt, gte
  • Membership: in, nin
  • Text search: like (case-sensitive), ilike (case-insensitive), startsWith, endsWith, contains
  • Boolean: true/false
  • Date/time: before/after/between with ISO 8601 timestamps

Syntax options

  1. Bracketed operators (concise and explicit)
GET /orders?status[in]=pending,processing&total[gte]=50&createdAt[between]=2025-01-01,2025-12-31
  1. Dot operators (similar clarity)
GET /orders?status.in=pending,processing&total.gte=50&createdAt.between=2025-01-01,2025-12-31
  1. Repeated parameters (simple for equality)
GET /orders?status=pending&status=processing
  1. Single filter envelope (good for complex queries, but harder to cache by CDNs)
GET /orders?filter=status.in:pending|processing;total.gte:50

Recommendations

  • Prefer brackets or dots: they compose well with multiple fields and operators.
  • Use comma-separated lists for in/nin; URL-encode spaces and reserved characters.
  • Treat empty strings and nulls distinctly; document how to filter for each.
  • For ranges, choose between [between]=start,end or paired bounds [gte]/[lte]. Support both if you can.

Examples by type

# Equality and sets
GET /products?category=shoes&brand[in]=nike,adidas

# Numeric range
GET /products?price[gte]=25&price[lte]=100

# Text search (case-insensitive contains)
GET /customers?name[ilike]=smith

# Date range (ISO 8601)
GET /events?startAt[gte]=2026-01-01T00:00:00Z&startAt[lte]=2026-12-31T23:59:59Z

# Boolean
GET /tasks?completed=false

Pagination and its interplay with sorting

Sorting and pagination are inseparable. Unstable sort orders cause duplicate or missing records across pages.

  • Offset pagination
    • page=2&pageSize=50
    • Requires a deterministic sort. Add a stable secondary field (id) if the primary field changes frequently.
  • Cursor pagination
    • cursor=eyJzb3J0IjoicHJpY2UiLCJsYXN0VmFsdWUiOjEwMC4wfQ==
    • Scales better on large datasets and avoids deep offsets. Cursor construction should embed the sort key(s) and direction.

Examples

# Offset-based with stable secondary key
GET /articles?sort=-publishedAt,id&page=1&pageSize=20

# Cursor-based (opaque to clients)
GET /articles?sort=-publishedAt&cursor=...&limit=20

Rules of thumb

  • Always default to a documented sort for collection endpoints.
  • For cursor pagination, mandate a unique final tie-breaker (id) in the sort order.
  • Reject ambiguous sorts (e.g., sort=title only) if the field is not unique and no stable tie-breaker is present.

Caching and canonicalization

CDNs and proxies treat the full query string as part of the cache key. Small inconsistencies can obliterate hit rates.

  • Canonicalize parameter order server-side (e.g., alphabetize keys, normalize whitespace and list ordering when order is semantically irrelevant).
  • Prefer explicit default values so equivalent requests map to the same cache key.
  • Use ETag/Last-Modified headers so clients can revalidate even when query params vary.
  • Document a canonical form in your style guide to help client SDKs produce stable URLs.

Performance and indexing

Sorting and filtering are only as good as the underlying data access.

  • Add composite indexes that match common filter + sort prefixes (e.g., INDEX(status, created_at DESC, id)).
  • Avoid non-sargable predicates (leading wildcards in LIKE, functions wrapping indexed columns).
  • Cap pageSize and list length for in filters to keep plans predictable.
  • Timebox queries and return a clear error when limits are exceeded; suggest narrower filters in the message.
  • Monitor query plans; add telemetry tags for each operator and field to see real usage.

Security and reliability

User-controlled query builders are prime targets. Defend aggressively.

  • Allowlist fields and operators; reject unknowns with a helpful error.
  • Use parameterized queries or trusted query builders/ORMs to prevent SQL/NoSQL injection.
  • Guard against parameter pollution (repeated keys where not allowed); define precedence or reject duplicates.
  • Enforce type validation and strict parsing (e.g., booleans must be true/false, not 1/0 unless documented).
  • Apply rate limits factoring in unique parameter permutations to curb cache-busting abuse.
  • Log rejected filters to spot enumeration attempts on hidden fields.

Error responses

  • Return 400 Bad Request for invalid syntax or types.
  • Use 422 Unprocessable Entity for well-formed requests with semantically invalid filters (e.g., conflicting bounds).
  • Provide machine-readable details (problem+json) including field, operator, and hint.

Example error payload

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

{
  "type": "https://api.example.com/problems/invalid-filter",
  "title": "Invalid filter operator",
  "detail": "Unsupported operator 'btw' for field 'price'. Did you mean 'between'?",
  "invalidParams": [
    {"name": "price[btw]", "reason": "unknownOperator"}
  ]
}

Discoverability and documentation

Good docs reduce guesswork and support tickets.

  • Document allowed fields, operators, data types, and examples per resource.
  • Provide a matrix of field × operator applicability (e.g., price supports [eq, lt, lte, gt, gte, between]).
  • Offer copy-paste cURL samples and SDK snippets.
  • Consider a describe endpoint (OPTIONS or GET /products/meta) that enumerates sortable/filterable fields.
  • Align with a known profile when helpful (e.g., JSON:API sort syntax) without importing its entire complexity.

Implementation snippets

Below are minimal, safe patterns you can adapt. They favor allowlists and structured parsing.

Node.js (Express + TypeScript) example

// Allowed mappings
const sortable = new Set(["price", "createdAt", "id"]);
const filterable: Record<string, Set<string>> = {
  price: new Set(["eq", "lt", "lte", "gt", "gte", "between", "in"]),
  status: new Set(["eq", "in"]),
  createdAt: new Set(["lt", "lte", "gt", "gte", "between"]),
};

function parseSort(sort?: string) {
  if (!sort) return [{ field: "id", dir: "asc" }];
  return sort.split(",").map(token => {
    const dir = token.startsWith("-") ? "desc" : "asc";
    const field = token.replace(/^[-+]/, "");
    if (!sortable.has(field)) throw new Error(`Invalid sort field: ${field}`);
    return { field, dir };
  }).concat({ field: "id", dir: "asc" }); // stable tie-breaker
}

function parseFilters(qs: Record<string, unknown>) {
  const filters: Array<{ field: string; op: string; value: any }> = [];
  for (const [rawKey, rawValue] of Object.entries(qs)) {
    const m = rawKey.match(/^(\w+)(?:\[(\w+)\])?$/); // field[op]
    if (!m) continue;
    const [, field, op = "eq"] = m;
    if (!filterable[field]?.has(op)) throw new Error(`Invalid operator ${op} for ${field}`);
    const value = Array.isArray(rawValue) ? rawValue : String(rawValue);
    filters.push({ field, op, value });
  }
  return filters;
}

app.get("/products", async (req, res, next) => {
  try {
    const sorts = parseSort(String(req.query.sort || ""));
    const filters = parseFilters(req.query);
    // Build a parameterized SQL/ORM query from sorts + filters
    // ...
    res.json({ data: [], meta: { sorts, filters } });
  } catch (e) {
    next(e);
  }
});

Python (FastAPI + Pydantic) example

from fastapi import FastAPI, Query, HTTPException
from pydantic import BaseModel

app = FastAPI()

ALLOWED_SORT = {"price", "createdAt", "id"}
ALLOWED_FILTERS = {
    "price": {"eq", "lt", "lte", "gt", "gte", "between", "in"},
    "status": {"eq", "in"},
}

class ProductListParams(BaseModel):
    sort: str | None = None

@app.get("/products")
async def list_products(sort: str | None = Query(None), **filters):
    sorts = []
    if sort:
        for token in sort.split(','):
            direction = 'desc' if token.startswith('-') else 'asc'
            field = token.lstrip('+-')
            if field not in ALLOWED_SORT:
                raise HTTPException(400, f"Invalid sort field {field}")
            sorts.append((field, direction))
    else:
        sorts = [("id", "asc")]

    parsed = []
    for k, v in filters.items():
        # k like 'price[gte]'
        import re
        m = re.match(r'^(\w+)(?:\[(\w+)\])?$', k)
        if not m:
            continue
        field, op = m.group(1), (m.group(2) or 'eq')
        if field not in ALLOWED_FILTERS or op not in ALLOWED_FILTERS[field]:
            raise HTTPException(400, f"Invalid operator {op} for {field}")
        parsed.append({"field": field, "op": op, "value": v})

    # Translate parsed to SQL/ORM query safely
    return {"data": [], "meta": {"sort": sorts, "filters": parsed}}

Testing checklist

Use this list to harden behavior before GA:

  • Default sort is documented and deterministic.
  • Multi-field sort respects token order and direction.
  • Unknown fields/operators are rejected with clear errors.
  • Type coercion works for numbers, booleans, dates; invalid types fail fast.
  • Commas in list values are parsed and URL-encoded correctly.
  • Pagination with updates in the dataset does not duplicate or skip items.
  • Cache keys remain stable under semantically equivalent requests.
  • Max page size, list length, and timeouts are enforced.
  • Parameter pollution is handled (deduped or rejected).

Versioning and evolution

Filters evolve as products grow. Plan for change:

  • Additive changes (new operators/fields) are backward compatible.
  • Deprecate risky operators with warnings in response headers before removal.
  • Use semantic versioning in docs and changelogs; prefer not to bump the API version solely for adding filters.
  • Provide per-field deprecation metadata in the describe/meta endpoint.

A practical recipe

  • Choose a readable syntax: brackets or dots for operators; JSON:API-style sort tokens.
  • Enforce allowlists for fields and operators; validate types strictly.
  • Make sorting stable with a secondary key (id) and document defaults.
  • Pair filters with matching indexes; prefer cursor pagination for very large datasets.
  • Canonicalize query strings and use ETag/Last-Modified for efficient caching.
  • Ship great docs, examples, and a describe endpoint for discoverability.

With these patterns, your REST API delivers fast, predictable, and secure collection queries that scale with your users and data.

Related Posts