{ }
GET
REST API · Pagination · Filter · Sortierung
Pagination, Filter und Sortierung
in REST APIs sauber entwerfen

List-Endpoints sind die komplexesten Teile einer REST API: Sie müssen Pagination, Filterung und Sortierung konsistent unterstützen, ohne die API-Oberfläche aufzublasen. Der Unterschied zwischen einem gut und einem schlecht entworfenen List-Endpoint liegt in drei Entscheidungen: Cursor oder Offset, welche Filterparameter erlaubt, und wie das Meta-Objekt die Konsumenten informiert.

17 Min. Lesezeit Cursor · Offset · Filter · Sort · Meta-Objekt REST API Design · OpenAPI 3.x

1. Die Grundentscheidung: Cursor oder Offset?

Die erste und wichtigste Designentscheidung für jeden List-Endpoint ist die Wahl des Pagination-Mechanismus. Offset-Pagination (?page=2&per_page=20) ist intuitiv, weit verbreitet und einfach zu implementieren. Cursor-Pagination (?cursor=eyJpZCI6MTAwfQ&limit=20) ist komplexer, aber konsistenter bei veränderlichen Daten und skalierbarer bei großen Datensätzen.

Die Entscheidung hängt vom Anwendungsfall ab: Wenn Nutzer oder Clients zu beliebigen Seiten springen müssen (z. B. Seite 47 von 200), ist Offset die einzige praktikable Option. Wenn die Daten sich häufig ändern und Nutzer sequenziell durch Ergebnisse navigieren (z. B. ein Activity-Feed oder Log-Stream), ist Cursor die zuverlässigere Wahl. Das Problem mit Offset bei veränderlichen Daten: Wenn zwischen Seite 1 und Seite 2 ein neues Element vorne eingefügt wird, verschiebt sich der gesamte Offset – Elemente werden doppelt oder gar nicht gezeigt.

Eine pragmatische Strategie: Offset für Admin-Interfaces und Reports (willkürliche Seitensprünge wichtig), Cursor für User-Feeds und Timelines (Konsistenz wichtig). Beide Modelle können in derselben API koexistieren – verschiedene Endpoints mit unterschiedlichen Anforderungen können unterschiedliche Pagination-Typen haben. Das sollte in der OpenAPI-Dokumentation klar signalisiert werden.

2. Offset-Pagination: einfach, aber mit Grenzen

Offset-Pagination ist der einfachste Einstieg und für viele Use Cases vollständig ausreichend. Die Grundform: ?page=2&per_page=20 oder alternativ ?offset=40&limit=20. Beide Varianten sind verbreitet, aber innerhalb einer API sollte nur eine konsequent verwendet werden. Die page/per_page-Variante ist nutzerfreundlicher und näher an der menschlichen Intuition ("Seite 3"), die offset/limit-Variante ist präziser und direkter auf die SQL-Schicht gemappt.

Wichtige Designentscheidungen bei Offset-Pagination: Das Maximum für per_page muss begrenzt sein (typisch: 100 oder 200), um Resource-Exhaustion-Angriffe zu verhindern. Der Default sollte sinnvoll sein (20 oder 25 ist üblich). Das Total-Count im Meta-Objekt ist bei Offset nützlich, damit Clients die Gesamtzahl der Seiten berechnen können – aber auch teuer, weil ein COUNT-Query auf großen Tabellen langsam ist. Für sehr große Datensätze kann ein approximatives Total (totalApproximate) oder gar kein Total eine bessere Option sein.


# Offset pagination — OpenAPI parameter definitions
components:
  parameters:
    PageParam:
      name: page
      in: query
      description: Page number (1-indexed)
      schema:
        type: integer
        minimum: 1
        default: 1

    PerPageParam:
      name: per_page
      in: query
      description: Items per page. Maximum 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  schemas:
    OffsetPaginationMeta:
      type: object
      required: [total, page, perPage, totalPages]
      properties:
        total:
          type: integer
          minimum: 0
          description: Total number of items matching the current filters
          example: 842
        page:
          type: integer
          minimum: 1
          description: Current page number
          example: 3
        perPage:
          type: integer
          minimum: 1
          maximum: 100
          description: Items per page as requested
          example: 20
        totalPages:
          type: integer
          minimum: 0
          description: Total number of pages (ceil(total / perPage))
          example: 43
        links:
          type: object
          description: Navigation links for common page transitions
          properties:
            self: { type: string, format: uri }
            first: { type: string, format: uri }
            prev: { type: string, format: uri, nullable: true }
            next: { type: string, format: uri, nullable: true }
            last: { type: string, format: uri }

3. Cursor-Pagination: konsistent bei veränderlichen Daten

Cursor-Pagination verwendet einen opaken Zeiger (Cursor) auf die Position im Datensatz. Der Cursor ist typischerweise ein Base64-kodiertes JSON-Objekt, das den Wert des Sort-Feldes des letzten zurückgegebenen Elements enthält – z. B. {"id": 100, "createdAt": "2026-05-10T12:00:00Z"} als Cursor für den Datensatz sortiert nach createdAt DESC, id DESC. Der Server kann diesen Cursor sicher dekodieren und die Abfrage mit einer WHERE (createdAt, id) < (cursorCreatedAt, cursorId)-Bedingung einschränken.

Ein wichtiges API-Design-Prinzip: Cursor müssen opak für Clients sein. Clients dürfen den Cursor nicht interpretieren oder konstruieren – er wird vom Server generiert und unverändert im nächsten Request zurückgesendet. Das erlaubt es dem Server, die Cursor-Implementierung zu ändern, ohne die API-Oberfläche zu brechen. Der Cursor sollte zeitlich begrenzt sein (z. B. 24 Stunden), um zu verhindern, dass Clients sehr alte Cursor unbegrenzt verwenden.


# Cursor pagination — OpenAPI definitions
components:
  parameters:
    CursorParam:
      name: cursor
      in: query
      description: |
        Opaque pagination cursor from the previous response's meta.cursors.next.
        Do NOT construct or interpret this value — treat as a black box.
        Cursors expire after 24 hours.
      schema:
        type: string
        example: eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI2LTA1LTEwIn0

    LimitParam:
      name: limit
      in: query
      description: Number of items to return. Maximum 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  schemas:
    CursorPaginationMeta:
      type: object
      required: [limit, hasMore]
      properties:
        limit:
          type: integer
          description: Items per page as requested
          example: 20
        hasMore:
          type: boolean
          description: Whether more items exist after the current page
          example: true
        cursors:
          type: object
          properties:
            next:
              type: string
              nullable: true
              description: Cursor for the next page. Null if this is the last page.
              example: eyJpZCI6MTIwfQ
            prev:
              type: string
              nullable: true
              description: Cursor for the previous page. Null if this is the first page.
              example: eyJpZCI6ODl9

# Example usage in a list endpoint
paths:
  /orders:
    get:
      summary: List orders (cursor-paginated)
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Order'
                  meta:
                    $ref: '#/components/schemas/CursorPaginationMeta'

4. Das Meta-Objekt: was Konsumenten wirklich brauchen

Das Meta-Objekt in einer List-Response ist die Schnittstelle zwischen der API und der Paginierungslogik im Client. Was Konsumenten wirklich brauchen, variiert je nach Pagination-Typ und Anwendungsfall. Für Offset-Pagination: Total-Count, aktuelle Seite, Seiten insgesamt und direkte Navigationslinks. Für Cursor-Pagination: hasMore-Flag, Cursor für die nächste und vorherige Seite.

Ein häufiger Fehler: Das Meta-Objekt wird so gestaltet, dass der Client noch rechnen muss. Wenn total und perPage zurückgegeben werden, muss der Client Math.ceil(total/perPage) rechnen, um die Seitenanzahl zu kennen. Besser: totalPages direkt mitgeben. Wenn cursor zurückgegeben wird, aber kein hasMore-Flag, muss der Client testen, ob cursor null ist. Besser: hasMore: false explizit angeben. Das Meta-Objekt sollte so gestaltet sein, dass der Client-Code minimal bleibt.

5. Filter-Parameter: Syntax, Typen und Grenzen

Filter-Parameter sind einer der häufigsten Quellen von API-Design-Inkonsistenz. Drei unterschiedliche Teams bauen drei unterschiedliche Filter-Syntaxen: ?status=active, ?filter[status]=active und ?filter=status:active. Konsumenten müssen drei verschiedene Syntaxen lernen, Dokumentation wird komplexer, und generierter Client-Code muss alle drei Varianten unterstützen.

Die Empfehlung: Einfache Gleichheits-Filter als direkte Query-Parameter (?status=active&category=electronics). Mehrfach-Werte durch kommaseparierte Listen oder mehrfachen Parameter (?status=active,pending oder ?status=active&status=pending). Range-Filter durch spezifische Parameter (?price_min=10&price_max=100 oder ?created_after=2026-01-01&created_before=2026-12-31). Für komplexe Filteranforderungen (AND/OR-Kombinationen, Negation) bietet sich ein JSON-basierter Filterparameter an – aber nur, wenn die API wirklich komplexe Queries erlauben muss.


# Filter parameters — OpenAPI definitions for list endpoint
paths:
  /products:
    get:
      summary: List products with filtering, sorting and pagination
      parameters:
        # Simple equality filter
        - name: status
          in: query
          description: Filter by product status. Multiple values allowed (comma-separated).
          schema:
            type: string
            enum: [active, inactive, draft, archived]
          example: active
        - name: category_id
          in: query
          description: Filter by category UUID. Matches exact category, not children.
          schema:
            type: string
            format: uuid
        # Range filter
        - name: price_min
          in: query
          description: Minimum price (inclusive), in EUR cents.
          schema:
            type: integer
            minimum: 0
        - name: price_max
          in: query
          description: Maximum price (inclusive), in EUR cents.
          schema:
            type: integer
            minimum: 0
        # Date range filter
        - name: created_after
          in: query
          description: Only products created after this date (ISO 8601 date).
          schema:
            type: string
            format: date
            example: "2026-01-01"
        - name: created_before
          in: query
          schema:
            type: string
            format: date
            example: "2026-12-31"
        # Full-text search
        - name: q
          in: query
          description: Full-text search across name and description. Min 2 chars.
          schema:
            type: string
            minLength: 2
            maxLength: 200
        # Sorting
        - name: sort
          in: query
          description: >
            Sort field and direction. Prefix with - for descending.
            Supported fields: name, price, createdAt, updatedAt.
          schema:
            type: string
            enum: [name, -name, price, -price, createdAt, -createdAt, updatedAt, -updatedAt]
            default: -createdAt
        # Pagination
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'

6. Sortierung: einfach konsistent halten

Sortierparameter sind einfacher zu designen als Filterparameter, aber die Inkonsistenzen sind trotzdem häufig. Das verbreitetste Muster: ?sort=field für aufsteigend, ?sort=-field für absteigend (das Minuszeichen-Prefix ist von vielen JSON:API-Implementierungen inspiriert). Alternativ: ?sort_by=name&sort_order=asc als separate Parameter.

Wichtige Designentscheidungen: Welche Felder sind sortierbar? Nicht alle Felder, die zurückgegeben werden, sind sinnvoll oder effizient sortierbar. Die sortierbaren Felder in der OpenAPI-Dokumentation als Enum zu definieren ist besser als einen freien String zuzulassen – es verhindert fehlerhafte Sortierparameter, die zu Server-Fehlern oder unexpected Behavior führen. Multi-Field-Sortierung (z. B. ?sort=-createdAt,name) ist optional und sollte nur hinzugefügt werden, wenn Konsumenten sie wirklich brauchen.

7. List-Endpoints vollständig in OpenAPI dokumentieren

Die vollständige OpenAPI-Dokumentation eines List-Endpoints ist aufwendiger als die eines einzelnen Resource-Endpoints, weil alle Parameter-Kombinationen und ihre Auswirkungen auf die Response dokumentiert werden müssen. Wichtig: Parameterkombinationen, die nicht unterstützt werden (z. B. Cursor-Pagination zusammen mit einem Page-Parameter), sollten explizit dokumentiert werden – entweder im description-Feld oder durch einen 400-Response mit Beschreibung.

Reusable Components reduzieren die Wiederholung erheblich: Pagination-Parameter als $ref-Referenzen, Meta-Schemas als wiederverwendbare Komponenten, gemeinsame Filter-Parameter (Datums-Range, Suche) als Parameter-Komponenten. Das macht OpenAPI-Dokumente wartbarer und reduziert die Chance, dass verschiedene List-Endpoints unterschiedliche Konventionen für dasselbe Konzept verwenden.

Kriterium Offset-Pagination Cursor-Pagination Empfehlung
Implementierungsaufwand Niedrig Mittel–Hoch Offset für einfache Cases
Konsistenz bei Datenänderungen Niedrig (Duplikate/Lücken) Hoch Cursor für veränderliche Daten
Willkürliche Seitensprünge Ja (Seite 47 direkt) Nein Offset für Admin-UIs
Performance bei großen Datensätzen Niedrig (OFFSET teuer) Hoch (Index-Scan) Cursor ab ~100k Datensätzen
Total Count verfügbar Ja (COUNT-Query) Nein (by design) Offset wenn Total benötigt

8. Performance-Überlegungen für Pagination und Filter

Pagination und Filter haben direkte Auswirkungen auf Datenbankperformance, die beim API-Design berücksichtigt werden müssen. Offset-Pagination mit großen Offsets (LIMIT 20 OFFSET 10000) ist teuer: Die Datenbank muss 10.000 Zeilen lesen und verwerfen. Cursor-Pagination vermeidet das durch eine WHERE-Bedingung auf indizierten Spalten. Bei großen Datensätzen (ab ca. 100.000 Einträgen) ist der Performance-Unterschied messbar.

Filter-Parameter, die auf nicht indizierten Feldern operieren, können Full-Table-Scans auslösen. Das API-Design sollte sich der Datenbank-Realität bewusst sein: Nur Felder, die indexiert sind oder effizient abgefragt werden können, sollten als Filter-Parameter exponiert werden. Das bedeutet nicht, dass die API alle Datenbankeinschränkungen nach außen trägt – aber es bedeutet, dass der API-Designer mit dem Datenbankteam abstimmt, welche Filter-Kombinationen unterstützt werden sollen.

10. Zusammenfassung

Pagination, Filter und Sortierung für REST APIs sauber zu entwerfen, erfordert drei klare Entscheidungen: Cursor oder Offset (je nach Konsistenz-Anforderung und Datensatzgröße), welche Filter-Parameter und -Syntaxen konsistent über alle List-Endpoints verwendet werden, und wie das Meta-Objekt Konsumenten mit allen nötigen Informationen versorgt, ohne dass sie rechnen müssen. Diese Entscheidungen früh zu treffen und konsequent durchzuhalten ist wichtiger als die konkrete Syntax-Wahl.

OpenAPI-Dokumentation für List-Endpoints zahlt sich besonders aus: Reusable Parameters und Schemas für Pagination und Filter reduzieren Wiederholung und erzwingen Konsistenz. Clients, die aus OpenAPI generiert werden, erhalten die Pagination-Logik kostenlos. Contract Tests mit Postman/Newman verifizieren, dass das Meta-Objekt korrekt befüllt ist und Cursor funktionieren. Der Aufwand für sorgfältiges List-Endpoint-Design ist einmalig – schlechtes Design verfolgt eine API ihr ganzes Leben lang.

Pagination, Filter und Sortierung — Das Wichtigste auf einen Blick

Cursor vs. Offset

Cursor für veränderliche Daten und große Datensätze. Offset wenn Seitensprünge oder Total Count benötigt werden. Beide können in derselben API koexistieren.

Meta-Objekt

So gestalten, dass der Client nicht rechnen muss: totalPages statt total+perPage, hasMore statt cursor-null-check, Navigationslinks direkt im Meta.

Filter-Konsistenz

Einfache Gleichheitsfilter als direkte Query-Parameter. Range-Filter als _min/_max-Pairs. Nur indexierte Felder als Filter exponieren.

OpenAPI-Reuse

Pagination-Parameter und Meta-Schemas als reusable components/$ref. Sortierbares Felder als Enum definieren — verhindert fehlerhafte Sortierparameter.

11. FAQ: Pagination, Filter und Sortierung in REST APIs

1Cursor vs. Offset: Hauptunterschied?
Offset erlaubt Seitensprünge, ist aber inkonsistent bei veränderlichen Daten. Cursor ist konsistent, erlaubt aber keine willkürlichen Sprünge. Wahl hängt vom Anwendungsfall ab.
2Wann Cursor-Pagination einsetzen?
Bei häufig veränderlichen Daten (Feeds, Logs) und bei Datensätzen ab ~100k Einträgen, wo OFFSET-Queries teuer werden.
3Wie opak muss ein Cursor sein?
Vollständig opak. Clients dürfen ihn nicht interpretieren oder konstruieren. Base64-kodiertes JSON ist üblich. Interne Struktur kann jederzeit geändert werden.
4Immer total im Meta-Objekt?
Bei Offset meist ja. Aber COUNT-Queries können bei großen Tabellen teuer sein. Alternativen: approximates Total oder weglassen und hasMore verwenden.
5Filter-Syntax für Mehrfach-Werte?
Kommaseparierte Liste (?status=active,pending) oder mehrfacher Parameter (?status=active&status=pending). Wichtig: Konsistenz über alle Endpoints hinweg.
6Alle Felder als Filter anbieten?
Nein. Nur indexierte Felder. Felder ohne Index als Filter zu exponieren riskiert Full-Table-Scans. API-Oberfläche sollte Datenbankstärken widerspiegeln.
7Inkompatible Parameter in OpenAPI dokumentieren?
Im description-Feld der Parameter oder Operation erklären. 400-Response mit Beschreibung dokumentieren. OpenAPI hat kein natives Konzept für Parameter-Inkompatibilität.
8Minuszeichen-Prefix für Sortierung?
?sort=-createdAt = absteigend sortiert. Von JSON:API inspiriert, weit verbreitet. Alternative: ?sort_by=createdAt&sort_order=desc als separate Parameter.
9Offset und Cursor in derselben API?
Ja. Verschiedene Endpoints können unterschiedliche Typen haben. In OpenAPI klar signalisieren, damit Clients wissen, welchen Typ sie erwarten.
10Hohe per_page-Werte verhindern?
maximum-Constraint im OpenAPI-Schema von per_page. Server validiert und gibt 422 zurück. Typisches Maximum: 100 für Listen, 200 für Export-Endpoints.