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:
SELECT id, total, created_at
FROM orders
ORDER BY total DESC, created_at ASC, id ASC;Hier sortieren wir die Bestellungen nach drei Kriterien:
- zuerst nach
totalabsteigend (höchste Beträge oben), - bei gleichem Betrag nach
created_ataufsteigend (älteste zuerst), - bei gleichem Betrag und identischem Zeitstempel nach
idaufsteigend (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 inORDER BY(anders als inWHERE). - Beliebige Ausdrücke:
ORDER BY length(name), name.
NULL und die Reihenfolge
ORDER BY muss eine Entscheidung treffen, wo NULL-Werte landen. Postgres-Default:
ASC→NULLS LAST(NULLs am Ende)DESC→NULLS FIRST(NULLs am Anfang)
Das ist die Konvention „NULL als unbekannt / fehlend → ans Ende der bekannten Werte”. Explizit überschreiben:
SELECT * FROM orders
ORDER BY shipped_at ASC NULLS FIRST;Das ist nützlich für „pending oben, abgeschlossene unten”-Listen.
LIMIT — kürzen
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
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:
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:
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:
| Ansatz | Wann sinnvoll |
|---|---|
COUNT(*) OVER () als Spalte | Gleiche Query, ein zusätzlicher Spaltenwert pro Zeile — aber Postgres muss trotzdem alle filtern |
Approximate Count via pg_class.reltuples | Sehr schnell, aber nur ungefähr (zuletzt vom Autovacuum geschätzt) |
| Cached Count in eigener Tabelle | Pflegeaufwand (Trigger oder Materialized View), aber konstanter Lookup |
SELECT id, total,
count(*) OVER () AS total_count
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20 OFFSET 100;SELECT reltuples::bigint AS approximate_count
FROM pg_class
WHERE relname = 'orders';Praxis-Beispiele
Stable Sort mit Tiebreaker
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
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+00Die letzte Zeile speichert der Client als Cursor: (2026-05-05 22:45:11+00, 138). Nächste Seite:
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+00Mit 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
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
- SELECT – ORDER BY
- SELECT – LIMIT/OFFSET
- Use The Index, Luke! – Pagination
- Window Functions – count(*) OVER ()