TS
Thomas Schmitz

Freiberuflicher IT-Berater, Softwareentwickler & DevOps. Praxisnahe Artikel zu API-Design, Architektur und Cloud-Workflows.

Serie: API-Design · Teil 7 von 22

API-Design Teil 7: Pagination, Filtering, Sorting

Cursor vs. Offset, sichere Filter, und wie du verhinderst, dass Clients deine Datenbank überlasten.

Praxisnah Checkliste

Ohne Pagination liefert eine API tausende Datensätze auf einmal – bis der Server kippt oder der Client das Timeout erreicht. Ohne durchdachtes Filtering öffnest du die Tür für Query-Injection und Performance-Probleme. Und ohne klare Sortierregeln bekommt jeder Client andere Ergebnisse.

Dieser Artikel hilft dir, robuste und sichere Query-Konventionen zu etablieren. Am Ende hast du ein dokumentiertes Schema für Pagination, Filtering und Sorting, das über alle List-Endpoints hinweg konsistent funktioniert.

Zielbild

Nach diesem Artikel kannst du:

  • Zwischen Cursor- und Offset-Pagination begründet wählen
  • Sichere Filter implementieren, die keine Injection erlauben
  • Sorting-Optionen definieren und Default-Verhalten festlegen
  • Limits setzen, die Server und Clients schützen
  • Eine Query-Konvention dokumentieren, die für alle Endpoints gilt

Kernfragen

Bevor du weiterliest, versuche diese Fragen für dein Projekt zu beantworten:

  1. Cursor oder Offset? Welche Pagination passt zu euren Daten?
  2. Welche Filter brauchen Clients? Alle Felder oder nur bestimmte?
  3. Wie verhindert ihr Filter-Injection? Allowlist oder Denylist?
  4. Was ist das Default-Sorting? Neueste zuerst? Alphabetisch?
  5. Was ist das Maximum Limit? 100? 1000? Unbegrenzt?

Pagination: Cursor vs. Offset

Pagination verhindert, dass APIs riesige Datenmengen auf einmal liefern. Zwei Hauptansätze haben sich etabliert.

Offset-Pagination

Client gibt an, wie viele Einträge übersprungen werden sollen:

GET /orders?offset=20&limit=10

Response:

{
  "data": [
    {
      "id": "order_001",
      "status": "pending"
    }
  ],
  "pagination": {
    "offset": 20,
    "limit": 10,
    "total": 1234
  }
}

Vorteile:

  • Einfach zu verstehen und implementieren
  • Springe zu Seite X möglich
  • Total Count verfügbar

Nachteile:

  • Instabil bei Änderungen (Eintrag gelöscht → Einträge rutschen)
  • Performance-Probleme bei großem Offset (OFFSET 10000 ist teuer)
  • Duplikate oder fehlende Einträge bei konkurrierenden Änderungen

Cursor-Pagination

Client nutzt einen opaken Cursor, der auf einen bestimmten Punkt zeigt:

GET /orders?cursor=eyJpZCI6MTIzfQ&limit=10

Response:

{
  "data": [
    {
      "id": "order_001",
      "status": "pending"
    }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6MTMzfQ",
    "has_more": true
  }
}

Vorteile:

  • Stabil bei Änderungen (Cursor zeigt auf festen Punkt)
  • Konstante Performance (kein OFFSET in der DB)
  • Minimiert Duplikate oder fehlende Einträge (bei stabilem Sort-Key)

Nachteile:

  • Kein Springe zu Seite X
  • Kein Total Count (oder nur mit zusätzlichem Query)
  • Cursor ist opak, Debugging schwieriger

Wann was verwenden?

Szenario Empfehlung
Kleine, statische Datenmengen Offset OK
Große, dynamische Datenmengen Cursor
UI mit Seitenzahlen (Seite 1, 2, 3...) Offset
Infinite Scroll, Feed Cursor
Daten ändern sich häufig Cursor
Export aller Daten Cursor
Öffentliche API Cursor (stabiler)

Empfehlung: Cursor-Pagination als Default. Offset nur wenn explizit Seitenzahlen nötig sind.

Cursor-Implementierung

Ein Cursor ist typischerweise ein Base64url-kodierter Wert, der danach signiert oder verschlüsselt wird:

// Cursor erstellen
const cursor = base64url(JSON.stringify({id: lastItem.id, created_at: lastItem.created_at}));

// Cursor dekodieren
const {id, created_at} = JSON.parse(base64urlDecode(cursor));

Wichtig: Cursor sollten:

  • Opak sein (Clients sollen sie nicht parsen)
  • Signiert oder verschlüsselt sein (gegen Manipulation)
  • Zeitlich begrenzt gültig sein (z.B. 24h)

Pagination Response Format

Konsistentes Format für alle List-Endpoints:

{
  "data": [
    {
      "id": "order_001",
      "status": "pending"
    },
    {
      "id": "order_002",
      "status": "shipped"
    }
  ],
  "pagination": {
    "cursor": "abc123",
    "next_cursor": "def456",
    "has_more": true,
    "limit": 20
  }
}

Für Offset-Pagination:

{
  "data": [
    {
      "id": "order_001",
      "status": "pending"
    }
  ],
  "pagination": {
    "offset": 0,
    "limit": 20,
    "total": 1234,
    "has_more": true
  }
}

Filtering: Sicher und performant

Filter erlauben Clients, nur relevante Daten abzurufen. Unkontrollierte Filter sind aber ein Sicherheits- und Performance-Risiko.

Einfache Filter

Für einfache Gleichheits-Filter nutze Query-Parameter:

GET /orders?status=pending&customer_id=user_abc123

Regel: Nur explizit erlaubte Felder sind filterbar (Allowlist). Keine generische Filter-auf-beliebiges-Feld-Funktion.

Filter-Allowlist

Definiere, welche Felder filterbar sind. Das hält den Query-Raum klein, erleichtert die Validierung und macht Performance planbar.

Beispiel: Filterbare Felder für /orders:

Feld Operatoren Beispiel
status eq ?status=pending
customer_id eq ?customer_id=user_abc
created_at gte, lte ?created_at_gte=2026-01-01
total_amount gte, lte ?total_amount_gte=1000

Komplexe Filter

Für komplexere Queries gibt es verschiedene Ansätze:

Suffix-Notation:

GET /orders?created_at_gte=2026-01-01&created_at_lte=2026-01-31
GET /orders?total_amount_gt=1000

Übliche Suffixe:

Suffix Bedeutung SQL
(keiner) Gleich = value
_ne Ungleich != value
_gt Größer als > value
_gte Größer oder gleich >= value
_lt Kleiner als < value
_lte Kleiner oder gleich <= value
_in In Liste IN (a, b, c)
_contains Enthält LIKE %value%

Bracket-Notation (alternative):

GET /orders?filter[status]=pending&filter[created_at][gte]=2026-01-01

Empfehlung: Suffix-Notation ist einfacher zu parsen und URL-freundlicher.

Filter-Injection verhindern

Niemals Filter direkt in SQL/Queries übernehmen:

// GEFÄHRLICH – SQL Injection möglich
const query = `SELECT * FROM orders WHERE ${filterField} = '${filterValue}'`;

// SICHER – Allowlist + Prepared Statements
const allowedFilters = ['status', 'customer_id', 'created_at'];
if (!allowedFilters.includes(filterField)) {
    throw new BadRequestError('Invalid filter field');
}
const query = `SELECT * FROM orders WHERE ${filterField} = $1`;

Regeln:

  1. Allowlist für erlaubte Filter-Felder
  2. Allowlist für erlaubte Operatoren pro Feld
  3. Prepared Statements / Parameter Binding
  4. Input-Validierung (Typ, Format, Länge)

Volltext-Suche

Für Freitext-Suche nutze einen separaten Parameter:

GET /products?q=smartphone

Wichtig:

  • q durchsucht vordefinierte Felder (z.B. name, description)
  • Dokumentiere, welche Felder durchsucht werden
  • Limitiere Mindestlänge (z.B. min. 3 Zeichen)
  • Rate-Limiting für Suche, weil sie teurer ist als einfache Filter

Sorting

Sorting definiert die Reihenfolge der Ergebnisse. Ohne explizites Sorting ist die Reihenfolge undefiniert – schlecht für Pagination und UX.

Einfaches Sorting

GET /orders?sort=created_at
GET /orders?sort=-created_at

Absteigend mit Minus-Präfix.

Oder expliziter:

GET /orders?sort=created_at&order=desc

Multi-Field Sorting

Mehrere Felder, kommasepariert:

GET /orders?sort=-created_at,customer_id

→ Erst nach created_at absteigend, dann nach customer_id aufsteigend.

Sortierbare Felder begrenzen

Nicht jedes Feld sollte sortierbar sein. Sorting auf nicht-indexierten Feldern kann die Datenbank überlasten. Dokumentiere die erlaubten Felder pro Endpoint.

Beispiel: Sortierbare Felder für /orders:

Feld Indexiert Default
created_at Ja Ja (desc)
updated_at Ja Nein
total_amount Ja Nein
status Nein Nein

Regel: Nur indexierte Felder sind sortierbar. Dokumentiere das Default-Sorting.

Default-Sorting

Jeder List-Endpoint braucht ein Default-Sorting. Sonst ist die Reihenfolge zufällig (oder schlimmer: abhängig von DB-Interna).

Empfehlung: -created_at (neueste zuerst) als sinnvoller Default für die meisten Ressourcen.

Limits und Defaults

Limits schützen Server und Clients vor Überlastung.

Limit-Parameter

GET /orders?limit=50

Sinnvolle Werte

Parameter Default Maximum Anmerkung
limit 20 100 Mehr nur mit expliziter Begründung
offset 0 10000 Verhindert Deep Pagination
q (Suche) - - Min. 3 Zeichen

Deep Pagination verhindern

Bei Offset-Pagination: Limitiere den maximalen Offset.

GET /orders?offset=100000&limit=20

→ Response: 400 Bad Request – Offset zu groß, nutze Cursor-Pagination.

Warum? OFFSET 100000 bedeutet: Datenbank muss 100.000 Rows lesen und verwerfen. Das ist teuer und wird mit wachsenden Daten schlimmer.

Total Count – mit Vorsicht

total ist praktisch für UI (1234 Ergebnisse), aber teuer bei großen Datenmengen.

Optionen:

  1. Immer liefern: Einfach, aber kann langsam werden
  2. Nur auf Anfrage: ?include_total=true
  3. Approximate: total_approximate: 1200 (schneller, aber ungenau)
  4. Nie: Nur has_more liefern

Empfehlung: has_more reicht für die meisten UIs. Total nur wenn explizit nötig.

Kombinierte Queries

Alle Parameter zusammen:

GET /orders?status=pending&created_at_gte=2026-01-01&sort=-created_at&limit=50&cursor=abc123

Reihenfolge der Verarbeitung

  1. Filter anwenden (WHERE)
  2. Sorting anwenden (ORDER BY)
  3. Pagination anwenden (LIMIT/OFFSET oder Cursor)

Konsistenz mit Cursor

Bei Cursor-Pagination muss das Sorting im Cursor kodiert sein. Sonst funktioniert nächste Seite nicht.

Regel: Sorting darf sich zwischen Seiten nicht ändern. Der Cursor enthält die Sortierung.

Regeln & Anti-Patterns

Do

  • Cursor-Pagination als Default für große/dynamische Daten
  • Filter-Allowlist mit erlaubten Feldern und Operatoren
  • Prepared Statements für alle Filter-Queries
  • Default-Sorting für jeden List-Endpoint
  • Sinnvolle Limits (Default 20, Max 100)
  • Deep Pagination verhindern

Don't

  • Unbegrenzte Ergebnismengen (?limit=999999)
  • Beliebige Felder filterbar machen
  • Filter direkt in Queries interpolieren
  • Sorting auf nicht-indexierten Feldern erlauben
  • Undefinierte Reihenfolge (kein Default-Sorting)
  • Total Count ohne Performance-Überlegung

Artefakt: Query-Konvention

# Query-Konvention

## Pagination

- **Methode:** Cursor-based (Offset nur für /reports)
- **Parameter:**
    - `limit`: Anzahl Ergebnisse (Default: 20, Max: 100)
    - `cursor`: Opaker Cursor für nächste Seite
- **Response:**
  ```json
  {
    "data": [...],
    "pagination": {
      "next_cursor": "abc123",
      "has_more": true
    }
  }
  ```

## Filtering

- **Syntax:** Query-Parameter mit Suffix für Operatoren
- **Operatoren:** (none)=eq, `_ne`, `_gt`, `_gte`, `_lt`, `_lte`, `_in`
- **Beispiel:** `?status=pending&created_at_gte=2026-01-01`

### Filterbare Felder pro Endpoint

| Endpoint  | Felder                                        |
|-----------|-----------------------------------------------|
| /orders   | status, customer_id, created_at, total_amount |
| /users    | email, role, is_active, created_at            |
| /products | category, price, is_available                 |

## Volltext-Suche (Kurzüberblick)

- **Parameter:** `q`
- **Min. Länge:** 3 Zeichen
- **Durchsuchte Felder:** Dokumentiert pro Endpoint

## Sorting (Kurzüberblick)

- **Parameter:** `sort`
- **Syntax:** Feldname, `-` Präfix für absteigend
- **Multi-Sort:** Kommasepariert (`sort=-created_at,name`)
- **Default:** `-created_at` (neueste zuerst)

### Sortierbare Felder pro Endpoint

| Endpoint  | Felder                               | Default     |
|-----------|--------------------------------------|-------------|
| /orders   | created_at, updated_at, total_amount | -created_at |
| /users    | created_at, name, email              | -created_at |
| /products | created_at, name, price              | -created_at |

## Limits

| Parameter           | Default | Maximum   |
|---------------------|---------|-----------|
| limit               | 20      | 100       |
| offset (wo erlaubt) | 0       | 10000     |
| q (min. Länge)      | -       | 3 Zeichen |

Checkliste

Bevor du zum nächsten Artikel gehst, prüfe:

  • [ ] Pagination-Methode ist gewählt (Cursor empfohlen)
  • [ ] Response-Format für Pagination ist definiert
  • [ ] Filter-Allowlist existiert für jeden List-Endpoint
  • [ ] Filter-Operatoren sind dokumentiert
  • [ ] Injection-Schutz ist implementiert (Prepared Statements)
  • [ ] Sortierbare Felder sind definiert und indexiert
  • [ ] Default-Sorting ist festgelegt
  • [ ] Limits sind definiert (Default und Maximum)
  • [ ] Deep Pagination ist verhindert oder limitiert
  • [ ] Query-Konvention ist dokumentiert

Wie es weitergeht

Im nächsten Teil geht es um Fehlerbehandlung und Validierung: Wie sieht ein gutes Fehlerformat aus? Welche Infos gehören in Fehlermeldungen – und welche nicht?

Alle Teile der Serie: Serie: API-Design

Mehr Beiträge aus dem Blog.