ORDER BY, LIMIT und OFFSET sehen unschuldig aus — und sind es bei kleinen Datenmengen auch. Bei wachsenden Tabellen werden sie zur häufigsten Performance-Falle: OFFSET 10000 muss Postgres erst 10.000 Zeilen lesen und wegwerfen. Hier die Mechanik und die saubere Alternative für Pagination.

ORDER BY — Reihenfolge

Mit ORDER BY legst du fest, in welcher Reihenfolge das Resultset zurückkommt. Du kannst nach mehreren Spalten gleichzeitig sortieren:

SQL Mehrfache Sort-Spalten
SELECT id, total, created_at
FROM orders
ORDER BY total DESC, created_at ASC, id ASC;

Hier sortieren wir die Bestellungen nach drei Kriterien:

  1. zuerst nach total absteigend (höchste Beträge oben),
  2. bei gleichem Betrag nach created_at aufsteigend (älteste zuerst),
  3. bei gleichem Betrag und identischem Zeitstempel nach id aufsteigend (Tiebreaker).

ASC (= ascending, aufsteigend) ist der Default; DESC (= descending) muss explizit hingeschrieben werden. Mehrere Spalten werden lexikographisch verglichen — erst nach der ersten, bei Gleichstand nach der zweiten, usw.

ORDER BY kann auch:

  • Spalten-Position: ORDER BY 2, 3 — sortiert nach 2. und 3. SELECT-Spalte. Praktisch in Ad-hoc-Queries, gefährlich in Production (Reihenfolge der SELECT-Liste ändert die Sort-Logik).
  • Aliasse aus SELECT: SELECT total*1.19 AS total_incl_tax … ORDER BY total_incl_tax. Funktioniert in ORDER BY (anders als in WHERE).
  • Beliebige Ausdrücke: ORDER BY length(name), name.

NULL und die Reihenfolge

ORDER BY muss eine Entscheidung treffen, wo NULL-Werte landen. Postgres-Default:

  • ASCNULLS LAST (NULLs am Ende)
  • DESCNULLS FIRST (NULLs am Anfang)

Das ist die Konvention „NULL als unbekannt / fehlend → ans Ende der bekannten Werte”. Explizit überschreiben:

SQL
SELECT * FROM orders
ORDER BY shipped_at ASC NULLS FIRST;

Das ist nützlich für „pending oben, abgeschlossene unten”-Listen.

LIMIT — kürzen

SQL
SELECT id, total
FROM orders
ORDER BY created_at DESC
LIMIT 10;

LIMIT n schneidet bei n Zeilen ab. Postgres optimiert das oft schon im Plan: bei einem Index auf created_at muss nicht die ganze Tabelle gelesen werden, sondern nur die ersten 10 Zeilen.

LIMIT ALL (oder Weglassen) → keine Begrenzung. LIMIT 0 → null Zeilen (für Schema-Inspection praktisch).

OFFSET — und warum es teuer wird

SQL
SELECT id, total
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100;

Klassische Pagination-Form. Funktioniert — bis die Tabelle groß wird.

Das Problem: Postgres muss die ersten 100 Zeilen trotzdem lesen (sortiert), um sie zu überspringen. Bei OFFSET 10000 werden 10.020 Zeilen verarbeitet, davon 10.000 weggeworfen. Das ist linear schlechter, je tiefer man in die Pagination geht.

Plus: ein neuer Eintrag in orders während der Pagination kann Zeilen verschieben — eine Zeile auf Seite 5 erscheint plötzlich auf Seite 6, oder doppelt.

Keyset-Pagination — die saubere Alternative

Statt nach Offset, nach Wert der Sort-Spalte paginieren:

SQL Erste Seite
SELECT id, total, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;

Die letzte Zeile dieser Seite hat z. B. created_at = '2026-05-01 12:00', id = 100. Nächste Seite:

SQL Nächste Seite
SELECT id, total, created_at
FROM orders
WHERE (created_at, id) < ('2026-05-01 12:00', 100)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Der Trick: das Tupel-Vergleichs-Konstrukt (a, b) < (x, y) macht lexikographischen Vergleich — erst a < x, bei Gleichheit b < y. Mit Index auf (created_at, id) ist das ein einziger Index-Lookup, unabhängig davon, wie tief in der Pagination wir sind.

Vorteile:

  • Konstante Performance — Seite 1.000 ist genauso schnell wie Seite 1.
  • Konsistent bei nebenläufigen Inserts — keine doppelten oder übersprungenen Zeilen.

Nachteile:

  • Kein direkter Sprung zu „Seite 5” möglich — Pagination ist nur „nächste/vorherige”.
  • Braucht Cursor-Werte beim Client (oder URL).

Pragmatisch: für Endless-Scroll-/Feed-UIs ist Keyset perfekt. Für klassische Seiten-Navigation („Seite 5 von 200”) muss man entweder mit OFFSET leben oder ein Hybrid-Pattern bauen (Sprung-Seiten plus Keyset zwischen ihnen).

Total-Count und Pagination

Häufige Anforderung: „Seite 5 von 200” zeigen — also die Gesamtzahl. Bei großen Tabellen ist SELECT count(*) selbst eine teure Query (Sequential Scan über alles).

Drei Strategien:

AnsatzWann sinnvoll
COUNT(*) OVER () als SpalteGleiche Query, ein zusätzlicher Spaltenwert pro Zeile — aber Postgres muss trotzdem alle filtern
Approximate Count via pg_class.reltuplesSehr schnell, aber nur ungefähr (zuletzt vom Autovacuum geschätzt)
Cached Count in eigener TabellePflegeaufwand (Trigger oder Materialized View), aber konstanter Lookup
SQL Window-Variante
SELECT id, total,
       count(*) OVER () AS total_count
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20 OFFSET 100;
SQL Approximate Count
SELECT reltuples::bigint AS approximate_count
FROM pg_class
WHERE relname = 'orders';

Praxis-Beispiele

Stable Sort mit Tiebreaker

SQL Pagination ohne Verschiebungen
SELECT id, total, created_at
FROM orders
ORDER BY total DESC, id ASC   -- Tiebreaker: id
LIMIT 20;

Ohne den id-Tiebreaker ist die Sortierung bei gleichen total-Werten nicht deterministisch — gleiche Zeilen können bei jedem Aufruf in anderer Reihenfolge landen. Deswegen für Pagination: immer eine eindeutige Tiebreaker-Spalte (typisch id oder Primary Key).

Keyset-Pagination — kompletter Workflow

SQL Erste Seite
myapp=> SELECT id, total, created_at
        FROM orders
        ORDER BY created_at DESC, id DESC
        LIMIT 5;

 id  | total |       created_at
-----+-------+------------------------
 142 | 99.95 | 2026-05-06 11:32:17+00
 141 | 49.95 | 2026-05-06 10:15:00+00
 140 | 19.95 | 2026-05-06 09:08:42+00
 139 | 79.95 | 2026-05-06 08:00:00+00
 138 | 29.95 | 2026-05-05 22:45:11+00

Die letzte Zeile speichert der Client als Cursor: (2026-05-05 22:45:11+00, 138). Nächste Seite:

SQL Zweite Seite — mit Cursor
myapp=> SELECT id, total, created_at
        FROM orders
        WHERE (created_at, id) < ('2026-05-05 22:45:11+00', 138)
        ORDER BY created_at DESC, id DESC
        LIMIT 5;

 id  | total |       created_at
-----+-------+------------------------
 137 | 59.95 | 2026-05-05 18:30:00+00
 136 | 39.95 | 2026-05-05 17:00:00+00
 135 | 89.95 | 2026-05-05 14:22:00+00
 134 | 49.95 | 2026-05-05 11:11:11+00
 133 | 29.95 | 2026-05-04 23:58:00+00

Mit einem Index auf (created_at DESC, id DESC) ist das ein einziger Index-Lookup — Seite 100 ist genauso schnell wie Seite 1.

Total-Count nebenbei

SQL Window-Function statt zweiter Query
SELECT
    id, total, status,
    count(*) OVER () AS total_count
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20 OFFSET 100;

Der total_count ist in jeder Zeile gleich — Postgres berechnet ihn einmal und füllt ihn ein. Spart eine zweite Query, kostet aber: alle gefilterten Zeilen werden trotzdem gelesen, weil OVER () ohne Partition über das gesamte Result läuft.

FAQ

Warum ist meine Sortierung manchmal nicht stabil?

Wenn ORDER BY nur eine Spalte mit Duplikat-Werten hat, ist die Reihenfolge der gleichen Zeilen nicht-deterministisch — Postgres garantiert nichts. Lösung: einen eindeutigen Tiebreaker hinzufügen, üblicherweise id. So: ORDER BY created_at DESC, id DESC.

LIMIT ohne ORDER BY ist undefiniert.

SELECT … LIMIT 10 liefert irgendwelche 10 Zeilen — der Plan kann sich zwischen Queries unterscheiden. Wer eine bestimmte Auswahl will, muss ORDER BY setzen. Praktisch immer.

Warum ist Seite 1 schnell, Seite 1000 langsam?

Klassisches OFFSET-Problem. Postgres muss alle übersprungenen Zeilen lesen und sortieren. Bei tiefer Pagination wechselt der Plan oft sogar zu Sequential Scan, weil der Index-Vorsprung weg ist. Lösung: Keyset-Pagination.

NULL-Werte landen mal oben, mal unten — warum?

Postgres-Default: NULLS LAST für ASC, NULLS FIRST für DESC. Wer das umdrehen will, muss explizit NULLS FIRST/NULLS LAST an die Sort-Klausel hängen. Ein Index, der den Default kennt, hilft Postgres, ohne zusätzlichen Sort auszukommen.

ORDER BY mit Index — wird der wirklich genutzt?

Nur wenn die Sort-Reihenfolge zum Index passt. ORDER BY a ASC, b DESC mit einem Index auf (a, b) (beide ASC) wird nicht genutzt — Postgres muss trotzdem sortieren. Lösung: Index passend definieren (CREATE INDEX … ON t (a ASC, b DESC)) oder ORDER BY der Index-Definition anpassen.

FETCH FIRST n ROWS ONLY — der SQL-Standard.

Postgres unterstützt sowohl LIMIT n (Postgres-Stil) als auch FETCH FIRST n ROWS ONLY (SQL-Standard). Funktional identisch — wählst du nach Geschmack/Portabilität. FETCH FIRST n ROWS WITH TIES gibt’s auch (PG 13+) und liefert die zusätzlichen Zeilen, die mit der letzten den Sort-Wert teilen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu SQL-Grundlagen

Zur Übersicht