API Pagination, Filtering, and Sorting Explained

By Oleksandr Andrushchenko — Published on — Modified on

API Pagination, Filtering, and Sorting Explained
API Pagination, Filtering, and Sorting Explained

API pagination, filtering, and sorting control how clients retrieve large collections without loading every record at once. These features directly affect database performance, response latency, consistency, cache behavior, and the long-term stability of an API contract.

Production collection endpoints need more than a limit parameter. They require deterministic ordering, bounded page sizes, indexed filters, safe cursor design, predictable query syntax, and validation that prevents clients from generating expensive database queries.

Table of Contents

Collection Endpoint Model

A collection endpoint returns a subset of records matching a query. The query usually combines filters, sorting rules, pagination state, and a maximum page size.

GET /v1/orders
  ?status=paid
  &created_after=2026-01-01T00:00:00Z
  &sort=-created_at
  &limit=50
  &after=eyJjcmVhdGVkX2F0IjoiLi4uIn0

The API should translate this contract into a bounded database query with deterministic ordering and an execution plan that remains efficient as the dataset grows.

Concern Responsibility
Pagination Limit how many records are returned at once.
Filtering Restrict the result set to relevant records.
Sorting Define deterministic result order.
Validation Prevent unsupported or expensive query combinations.

Why Pagination Is Required

Returning every matching record makes response time, memory usage, serialization cost, network transfer, and database work grow without a reliable upper bound.

Pagination turns an unbounded query into a predictable operation.

Protecting API Capacity

Large responses consume application memory and CPU while JSON is built, serialized, compressed, and transferred.

Unbounded endpoint:
  database returns 500,000 rows
  -> application allocates large result objects
  -> JSON serialization consumes CPU
  -> response occupies worker connection
  -> client may time out before completion

Rule of Thumb: every collection endpoint should have a server-enforced maximum page size.

Protecting Database Performance

Pagination limits rows returned, but a poorly designed paginated query can still scan or sort millions of rows.

Efficient pagination requires indexes aligned with filters and ordering.

Query Pattern Potential Cost
Small indexed keyset query Low and predictable
Large offset query Database may scan and discard many rows.
Unindexed arbitrary sort Large sort and temporary memory or disk usage.
Exact total count May scan a large portion of the result set.

Creating Stable Contracts

Pagination parameters and response metadata become part of the public API contract. Changing from page numbers to cursors later can require client changes.

The pagination model should be selected from expected dataset size, mutation rate, client navigation needs, and ordering requirements.

Offset Pagination

Offset pagination skips a specified number of rows and returns the next page.

Advantages

  • Simple client model: pages can be represented by numbers.
  • Random access: clients can jump directly to a later page.
  • Easy SQL: supported directly through LIMIT and OFFSET.
  • Good administrative UX: page-based tables are straightforward.

Disadvantages

  • Large offsets are expensive: databases still process skipped rows.
  • Concurrent changes cause instability: inserts and deletes shift later pages.
  • Duplicate or missing records: clients may see records twice or not at all.
  • Exact totals encourage costly counts: page-number UIs often expect total pages.

When to Use / Real-World Use Cases

  • Administrative dashboards with moderate datasets.
  • Search results where users expect numbered pages.
  • Stable datasets that change infrequently.
  • Internal reporting tools where simplicity matters more than maximum scale.

Example

GET /v1/orders?limit=50&offset=100
SELECT
    id,
    status,
    total_cents,
    created_at
FROM orders
WHERE tenant_id = :tenant_id
ORDER BY created_at DESC, id DESC
LIMIT 50
OFFSET 100;

Production Note: deterministic ordering is still required. Sorting only by a non-unique timestamp can reorder records unpredictably.

Cursor Pagination

Cursor pagination returns an opaque continuation token representing the position after the last item in the current page.

Advantages

  • Stable continuation: clients continue from a known result position.
  • Efficient at scale: properly designed cursors avoid large offsets.
  • Good for frequently changing data: less page shifting than offsets.
  • Hides storage details: clients do not depend on internal sort values.

Disadvantages

  • No direct page jump: clients usually move sequentially.
  • More server logic: cursors must be encoded, decoded, and validated.
  • Sorting restrictions: only supported orderings can produce valid cursors.
  • Contract complexity: cursors may need to bind filters and sort configuration.

When to Use / Real-World Use Cases

  • Public APIs with large datasets.
  • Infinite scrolling in mobile and web applications.
  • Activity feeds ordered by timestamp and ID.
  • Frequently changing collections such as events, messages, and transactions.

Example

GET /v1/orders?limit=50&after=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0xMlQxODo0MjowMFoiLCJpZCI6Im9yZF8xMjMifQ

The cursor may internally represent the final record’s ordered values:

{
  "created_at": "2026-07-12T18:42:00Z",
  "id": "ord_123"
}

The representation should remain opaque to clients even when it uses encoded JSON internally.

Keyset Pagination

Keyset pagination uses the ordered values from the final record as the next query boundary. Cursor pagination often uses keyset pagination internally.

Advantages

  • Index-friendly: the database can seek directly from the boundary.
  • Predictable performance: later pages do not become progressively slower.
  • Stable under inserts: new earlier records do not shift the continuation point.
  • Good large-table behavior: avoids scanning discarded offset rows.

Disadvantages

  • Requires deterministic sort keys: ordering must include a unique tie-breaker.
  • Harder arbitrary navigation: jumping to page 500 is not natural.
  • Complex multi-column comparisons: each sort configuration needs matching predicates.
  • Changing sort fields complicates cursors: cursors cannot usually be reused across orderings.

When to Use / Real-World Use Cases

  • Large transaction tables.
  • Audit and event logs.
  • Message histories and feeds.
  • Public APIs where consistent latency matters.

Example

SELECT
    id,
    status,
    total_cents,
    created_at
FROM orders
WHERE tenant_id = :tenant_id
  AND (
        created_at < :cursor_created_at
        OR (
            created_at = :cursor_created_at
            AND id < :cursor_id
        )
      )
ORDER BY created_at DESC, id DESC
LIMIT 51;

Requesting one extra row makes it possible to determine whether another page exists without running a separate count query.

Pagination Strategy Comparison

Pagination choice should reflect dataset size, mutation rate, and navigation requirements.

Strategy Large Dataset Performance Stable During Changes Random Page Access Best Fit
Offset Degrades at high offsets Weak Yes Admin tables and moderate datasets
Cursor Strong Strong No Public APIs and feeds
Keyset Strong Strong No Large indexed tables

Rule of Thumb: use offset pagination for human-oriented page navigation and cursor-backed keyset pagination for high-volume production APIs.

Stable and Deterministic Ordering

Pagination cannot be reliable without a deterministic order. Every matching record must have one unambiguous position in the result sequence.

Unique Tie-Breaker

Timestamps are commonly duplicated. Sorting by created_at alone is not deterministic.

Unstable:
  ORDER BY created_at DESC

Stable:
  ORDER BY created_at DESC, id DESC

The final sort column should usually be unique.

NULL Ordering

Nullable sort fields need explicit behavior. Database defaults can differ or produce confusing cursors.

ORDER BY
    shipped_at DESC NULLS LAST,
    id DESC;

Cursor encoding must preserve whether the boundary value was NULL.

Data Changing Between Pages

New records, updates, and deletes may happen while a client traverses pages.

Change Possible Effect
New record inserted before cursor Usually not shown in the current traversal.
Record deleted after first page Later page contains fewer records.
Sort field updated Record may move before or after the cursor.

Production Note: cursor pagination improves stability but does not create a transactionally frozen snapshot by itself.

Cursor Design

A cursor is part of the API contract even when its contents are opaque. It must be validated, versioned, and protected from malformed input.

Opaque Cursors

Clients should treat cursors as uninterpreted strings.

Good:
  after=eyJ2IjoxLCJjcmVhdGVkX2F0IjoiLi4uIn0

Avoid:
  after_created_at=2026-07-12T18:42:00Z
  after_id=ord_123

Opaque cursors allow the server to change internal cursor structure without redesigning the endpoint.

Cursor Validation

Cursor decoding should reject malformed, unsupported, expired, or incomplete values with a client error.

  • Validate cursor version.
  • Validate expected field types.
  • Validate timestamp format.
  • Reject unexpected fields.
  • Apply a maximum encoded length.

Binding Cursors to Queries

Reusing a cursor with different filters or sorting can produce invalid results.

{
  "version": 1,
  "sort": "-created_at",
  "filter_hash": "c3917d...",
  "created_at": "2026-07-12T18:42:00Z",
  "id": "ord_123"
}

A filter hash or signed cursor can prevent accidental or intentional cursor reuse across incompatible queries.

Filtering Design

Filtering should expose a controlled set of indexed business fields. Allowing clients to construct arbitrary database predicates creates performance and security risks.

Exact-Match Filters

Exact-match filters work well for status, tenant-owned identifiers, categories, and enumerated values.

GET /v1/orders?status=paid
GET /v1/orders?customer_id=cus_123
GET /v1/orders?currency=USD

Values should be validated against known types or enumerations.

Range Filters

Range filters should use clear suffixes or structured operators.

GET /v1/orders
  ?created_after=2026-01-01T00:00:00Z
  &created_before=2026-02-01T00:00:00Z
  &total_cents_min=1000
  &total_cents_max=50000

Time ranges should define inclusive and exclusive boundaries consistently.

Multi-Value Filters

Multi-value filters can use repeated parameters or comma-separated values.

Repeated:
  ?status=paid&status=shipped

Comma-separated:
  ?status=paid,shipped

Repeated parameters often integrate better with existing URL libraries and avoid escaping ambiguity.

Search vs Filtering

Search and filtering solve different problems.

Operation Purpose Example
Filter Match structured fields. ?status=paid
Search Match text across one or more fields. ?search=invoice 123

Full-text search may require PostgreSQL text search, OpenSearch, Elasticsearch, or another specialized index rather than wildcard matching over large tables.

Sorting Design

Sorting should be explicit, deterministic, and limited to fields the database can support efficiently.

Sort Parameter Format

A concise pattern uses a minus prefix for descending order.

?sort=created_at
?sort=-created_at
?sort=status,-created_at

The API contract should define the default order when sort is omitted.

Sort Field Allowlist

Never interpolate arbitrary client strings directly into an ORDER BY clause.

ALLOWED_SORTS = {
    "created_at": "created_at ASC, id ASC",
    "-created_at": "created_at DESC, id DESC",
    "total": "total_cents ASC, id ASC",
    "-total": "total_cents DESC, id DESC",
}

An allowlist prevents SQL injection and unsupported high-cost sorts.

Multi-Column Sorting

Multi-column sorting gives clients flexibility but increases cursor and index complexity.

?sort=status,-created_at
ORDER BY
    status ASC,
    created_at DESC,
    id DESC;

Each supported sort combination may need a matching database index and cursor encoding strategy.

Database Performance

API query syntax should be designed together with database indexes. A flexible contract that cannot be executed efficiently will eventually become an operational problem.

Indexes for Pagination

A keyset query should use an index matching tenant filters and sort order.

CREATE INDEX orders_tenant_created_id_idx
ON orders (
    tenant_id,
    created_at DESC,
    id DESC
);

This index supports tenant isolation, sorting, and cursor continuation in one access path.

Indexes for Filtering

Frequently used filters may require composite or partial indexes.

-- Supports listing paid orders for one tenant in newest-first order.
CREATE INDEX orders_paid_tenant_created_idx
ON orders (
    tenant_id,
    created_at DESC,
    id DESC
)
WHERE status = 'paid';

Trade-off: indexes speed reads but increase storage and write cost. Add indexes from measured query patterns rather than every possible filter combination.

Avoiding COUNT Bottlenecks

Exact totals can be expensive on large or complex filtered datasets.

Response Need Alternative
Does another page exist? Fetch limit + 1 rows.
Approximate result size Use estimated or asynchronously calculated count.
Exact total for reporting Run a separate reporting query or cached aggregation.

Public cursor APIs often omit total counts because sequential navigation does not require them.

Query Cost Limits

The API should constrain query complexity.

  • Maximum page size.
  • Maximum date range.
  • Maximum number of filter values.
  • Allowlisted sort combinations.
  • Timeout for database queries.
  • Separate export workflow for large datasets.

Production Note: a request for one million records is usually an export job, not an ordinary API page.

Response Design

Pagination responses should separate collection items from continuation metadata.

Pagination Metadata

{
  "items": [
    {
      "id": "ord_123",
      "status": "paid",
      "created_at": "2026-07-12T18:42:00Z"
    }
  ],
  "pagination": {
    "limit": 50,
    "has_more": true,
    "next_cursor": "eyJ2IjoxLCJjcmVhdGVkX2F0IjoiLi4uIn0"
  }
}

Keep metadata structure consistent across collection endpoints.

Next and Previous Cursors

Forward pagination is usually sufficient for feeds and integrations. Bidirectional navigation requires careful reverse-order queries.

{
  "pagination": {
    "next_cursor": "next_opaque_value",
    "previous_cursor": "previous_opaque_value"
  }
}

Previous cursors should not be added unless clients have a clear navigation requirement.

Empty Pages

An empty collection should return a successful response with an empty list.

{
  "items": [],
  "pagination": {
    "limit": 50,
    "has_more": false,
    "next_cursor": null
  }
}

Empty result sets are not normally 404 Not Found.

Consistency and Concurrent Changes

Pagination traverses data over multiple requests. The dataset may change between those requests.

Duplicates and Missing Records

Offset pagination is especially vulnerable to inserts and deletes before the current offset.

Page 1 reads rows 1–50

New row inserted at position 1

Page 2 uses OFFSET 50

Previous row 50 shifts to position 51
  -> duplicate appears

Keyset pagination avoids this specific shift by continuing after an ordered boundary.

Snapshot Pagination

Some exports and reports require a stable snapshot.

Snapshot approaches include:

  • Capture an upper timestamp boundary and exclude newer records.
  • Create an export job that writes results to object storage.
  • Use a database snapshot or repeatable-read transaction for short operations.
  • Materialize a result set with an expiration time.

Long transactions are generally poor fits for user-driven pagination across many requests.

Eventual Consistency

APIs backed by replicas, search indexes, or distributed databases may return eventually consistent pages.

The contract should avoid promising exact read-after-write behavior unless the storage architecture provides it.

Ready-to-Use Example

The following example implements cursor-backed keyset pagination for a multi-tenant Orders API. It supports status filtering, bounded date ranges, deterministic sorting, opaque cursors, and one-extra-row detection.

PostgreSQL Schema and Index

CREATE TABLE orders (
    id text PRIMARY KEY,
    tenant_id text NOT NULL,
    customer_id text NOT NULL,
    status text NOT NULL,
    total_cents integer NOT NULL CHECK (total_cents >= 0),
    created_at timestamptz NOT NULL DEFAULT now()
);

-- Supports tenant-isolated newest-first keyset pagination.
CREATE INDEX orders_tenant_created_id_idx
ON orders (
    tenant_id,
    created_at DESC,
    id DESC
);

-- Supports the common tenant + status filter while preserving pagination order.
CREATE INDEX orders_tenant_status_created_id_idx
ON orders (
    tenant_id,
    status,
    created_at DESC,
    id DESC
);

OpenAPI Example

openapi: 3.0.3

info:
  title: Orders API
  version: "1.0"

paths:
  /v1/orders:
    get:
      summary: List orders
      description: Returns orders using cursor-based pagination.

      parameters:
        - name: limit
          in: query
          description: Maximum number of items returned.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50

        - name: after
          in: query
          description: Opaque cursor returned by the previous response.
          schema:
            type: string
            maxLength: 2048

        - name: status
          in: query
          description: Optional exact-match status filter.
          schema:
            type: string
            enum:
              - created
              - paid
              - shipped
              - canceled

        - name: created_after
          in: query
          description: Inclusive lower timestamp boundary.
          schema:
            type: string
            format: date-time

        - name: created_before
          in: query
          description: Exclusive upper timestamp boundary.
          schema:
            type: string
            format: date-time

        - name: sort
          in: query
          description: Supported deterministic ordering.
          schema:
            type: string
            enum:
              - -created_at
            default: -created_at

      responses:
        "200":
          description: Paginated order collection.
          content:
            application/json:
              schema:
                type: object
                required:
                  - items
                  - pagination
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/Order"

                  pagination:
                    $ref: "#/components/schemas/Pagination"

        "400":
          description: Invalid filter, sort, date range, or cursor.

components:
  schemas:
    Order:
      type: object
      required:
        - id
        - status
        - total_cents
        - created_at
      properties:
        id:
          type: string
        status:
          type: string
        total_cents:
          type: integer
        created_at:
          type: string
          format: date-time

    Pagination:
      type: object
      required:
        - limit
        - has_more
      properties:
        limit:
          type: integer
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true

FastAPI Example

from __future__ import annotations

import base64
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Literal

from fastapi import Depends, FastAPI, HTTPException, Query

app = FastAPI(title="Orders API")


@dataclass(frozen=True)
class Identity:
    tenant_id: str


@dataclass(frozen=True)
class Cursor:
    version: int
    created_at: datetime
    order_id: str
    filter_hash: str


ALLOWED_STATUSES = {"created", "paid", "shipped", "canceled"}
MAX_DATE_RANGE = timedelta(days=365)


def get_identity() -> Identity:
    # In production, tenant_id must come from validated authentication context.
    # Never trust a tenant identifier supplied directly by the client.
    return Identity(tenant_id="org_456")


def build_filter_hash(
    status: str | None,
    created_after: datetime | None,
    created_before: datetime | None,
    sort: str,
) -> str:
    # Bind the cursor to the original query so it cannot be reused
    # with different filters or ordering.
    canonical = json.dumps(
        {
            "status": status,
            "created_after": created_after.isoformat() if created_after else None,
            "created_before": created_before.isoformat() if created_before else None,
            "sort": sort,
        },
        sort_keys=True,
        separators=(",", ":"),
    )

    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]


def encode_cursor(cursor: Cursor) -> str:
    payload = {
        "v": cursor.version,
        "created_at": cursor.created_at.astimezone(timezone.utc).isoformat(),
        "id": cursor.order_id,
        "filter_hash": cursor.filter_hash,
    }

    raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")

    # URL-safe Base64 keeps the cursor easy to send as a query parameter.
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def decode_cursor(value: str, expected_filter_hash: str) -> Cursor:
    if len(value) > 2048:
        raise HTTPException(status_code=400, detail="Cursor is too long.")

    try:
        # Restore removed Base64 padding before decoding.
        padded = value + "=" * (-len(value) % 4)
        payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))

        if payload.get("v") != 1:
            raise ValueError("Unsupported cursor version.")

        cursor = Cursor(
            version=1,
            created_at=datetime.fromisoformat(payload["created_at"]),
            order_id=str(payload["id"]),
            filter_hash=str(payload["filter_hash"]),
        )
    except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
        raise HTTPException(status_code=400, detail="Invalid cursor.") from exc

    # Reject cursors created for a different filter or sort configuration.
    if cursor.filter_hash != expected_filter_hash:
        raise HTTPException(
            status_code=400,
            detail="Cursor does not match the current query.",
        )

    return cursor


async def query_orders(
    tenant_id: str,
    limit: int,
    status: str | None,
    created_after: datetime | None,
    created_before: datetime | None,
    cursor: Cursor | None,
) -> list[dict[str, Any]]:
    # This function represents a parameterized database query.
    # Never interpolate raw client values into SQL.
    rows = [
        {
            "id": "ord_123",
            "status": "paid",
            "total_cents": 1299,
            "created_at": datetime(2026, 7, 12, 18, 42, tzinfo=timezone.utc),
        }
    ]

    # Fetch limit + 1 rows in production to detect whether another page exists.
    return rows[: limit + 1]


@app.get("/v1/orders")
async def list_orders(
    limit: int = Query(default=50, ge=1, le=100),
    after: str | None = Query(default=None, max_length=2048),
    status: str | None = Query(default=None),
    created_after: datetime | None = Query(default=None),
    created_before: datetime | None = Query(default=None),
    sort: Literal["-created_at"] = "-created_at",
    identity: Identity = Depends(get_identity),
) -> dict[str, Any]:
    if status is not None and status not in ALLOWED_STATUSES:
        raise HTTPException(status_code=400, detail="Unsupported status filter.")

    if created_after and created_before:
        if created_after >= created_before:
            raise HTTPException(
                status_code=400,
                detail="created_after must be earlier than created_before.",
            )

        # Bound wide queries so clients cannot request unlimited history.
        if created_before - created_after > MAX_DATE_RANGE:
            raise HTTPException(
                status_code=400,
                detail="Date range cannot exceed 365 days.",
            )

    filter_hash = build_filter_hash(
        status=status,
        created_after=created_after,
        created_before=created_before,
        sort=sort,
    )

    cursor = decode_cursor(after, filter_hash) if after else None

    rows = await query_orders(
        tenant_id=identity.tenant_id,
        limit=limit,
        status=status,
        created_after=created_after,
        created_before=created_before,
        cursor=cursor,
    )

    has_more = len(rows) > limit
    items = rows[:limit]

    next_cursor = None

    if has_more and items:
        last_item = items[-1]

        next_cursor = encode_cursor(
            Cursor(
                version=1,
                created_at=last_item["created_at"],
                order_id=last_item["id"],
                filter_hash=filter_hash,
            )
        )

    return {
        "items": [
            {
                "id": row["id"],
                "status": row["status"],
                "total_cents": row["total_cents"],
                "created_at": row["created_at"].isoformat(),
            }
            for row in items
        ],
        "pagination": {
            "limit": limit,
            "has_more": has_more,
            "next_cursor": next_cursor,
        },
    }

Production Note: Base64 encoding provides opacity, not integrity. Sign or encrypt cursors when tampering must be prevented, or validate every decoded field strictly as shown.

Python Client Example

import requests

API_URL = "https://api.example.com/v1/orders"
ACCESS_TOKEN = "replace-with-access-token"

cursor = None

while True:
    params = {
        "limit": 50,
        "status": "paid",
        "sort": "-created_at",
    }

    if cursor:
        # Cursors must be reused with the same filter and sort configuration.
        params["after"] = cursor

    response = requests.get(
        API_URL,
        headers={
            "Authorization": f"Bearer {ACCESS_TOKEN}",
            "X-Request-ID": "req_order_export",
        },
        params=params,

        # Large exports should still use bounded per-page request timeouts.
        timeout=5,
    )

    response.raise_for_status()
    payload = response.json()

    for order in payload["items"]:
        print(order)

    pagination = payload["pagination"]

    if not pagination["has_more"]:
        break

    cursor = pagination["next_cursor"]

Common Mistakes

Most collection-endpoint problems come from treating pagination, filters, sorting, and indexes as separate concerns.

  • Returning unbounded collections without a maximum page size.
  • Using large offsets on rapidly growing tables.
  • Sorting by a non-unique field without a deterministic tie-breaker.
  • Allowing arbitrary sort columns directly in SQL.
  • Supporting filters without matching indexes.
  • Running an exact count for every cursor page.
  • Exposing cursor internals as a permanent client contract.
  • Allowing cursors to be reused with different filters.
  • Accepting unlimited date ranges or filter values.
  • Using wildcard search over large unindexed text fields.
  • Treating an empty collection as 404.
  • Using client-provided tenant IDs instead of trusted identity context.
  • Offering many sort combinations without measuring their query plans.

Production Checklist

A production collection endpoint should remain fast, deterministic, and predictable as data volume grows.

  • Set a default and maximum page size.
  • Choose offset or cursor pagination intentionally.
  • Use keyset pagination for large, changing datasets.
  • Define a deterministic default order.
  • Add a unique tie-breaker to every sort.
  • Keep cursors opaque and versioned.
  • Validate cursor structure and query compatibility.
  • Allowlist filter and sort fields.
  • Validate enumerations, ranges, and list sizes.
  • Bound date ranges and query complexity.
  • Create indexes matching common filters and ordering.
  • Use limit + 1 to determine whether another page exists.
  • Avoid exact totals unless clients truly need them.
  • Return consistent pagination metadata.
  • Return empty lists for empty result sets.
  • Load-test deep pagination and concurrent writes.
  • Use asynchronous exports for very large result sets.

Conclusion

API pagination, filtering, and sorting are database and contract-design concerns, not simple query-string conveniences. Their implementation determines whether collection endpoints stay reliable as tables grow and data changes.

Offset pagination is useful for page-number navigation, while cursor-backed keyset pagination provides more predictable performance and stability for large production APIs. Filtering should expose controlled indexed fields, and sorting should always be deterministic and allowlisted.

Key Takeaway: design collection endpoints around bounded queries, stable ordering, validated filters, opaque cursors, and indexes that match the public API contract.

Comments (0)