📋Spickzettel
Jeder Tipp auf einen Blick – plus die Dialekt-Matrix. Druckfreundlich.
🔀 Upserts
Einfügen oder aktualisieren – in einer Anweisung
Ein Upsert fügt ein und weicht bei einem Schlüsselkonflikt auf ein UPDATE aus.
INSERT INTO artikel (artikel_nr, bezeichnung, kategorie, preis, bestand) VALUES ('A-100', 'Teekanne Glas', 'Küche', 26.50, 0) ON CONFLICT (artikel_nr) DO UPDATE SET preis = excluded.preis;
⚠️ Das Konfliktziel ON CONFLICT (spalten) braucht einen passenden PRIMARY KEY oder UNIQUE-Index – sonst Fehler.
Zähler atomar hochzählen
Der Upsert rechnet in der Datenbank: Existiert die Seite, wird der alte Wert plus der neue addiert, sonst beginnt der Zähler bei 1.
INSERT INTO seitenaufrufe (seite, aufrufe, zuletzt) VALUES ('/start', 1, date('now')) ON CONFLICT (seite) DO UPDATE SET aufrufe = seitenaufrufe.aufrufe + excluded.aufrufe, zuletzt = excluded.zuletzt;
⚠️ Bei sehr heißen Zählern (tausende Schreibzugriffe pro Sekunde auf eine Zeile) wird die Zeilensperre zum Flaschenhals – dann besser Ereignisse einfügen und periodisch aggregieren.
Nur einfügen, wenn neu – und sehen, was wirklich eingefügt wurde
ON CONFLICT DO NOTHING überspringt Konflikte.
INSERT INTO abos (email, seit) VALUES ('theo.brandt@example.org', date('now')) ON CONFLICT (email) DO NOTHING RETURNING email;
⚠️ DO NOTHING ohne Konfliktziel überspringt jeden Eindeutigkeitskonflikt – auch den auf einem anderen UNIQUE-Index, den du vielleicht sehen wolltest.
Tabellen abgleichen: Lieferung in den Bestand buchen
MERGE verbindet Quelle und Ziel über eine Bedingung und entscheidet je Zeile: WHEN MATCHED → UPDATE, WHEN NOT MATCHED → INSERT.
MERGE INTO artikel a USING lieferung l ON a.artikel_nr = l.artikel_nr WHEN MATCHED THEN UPDATE SET bestand = a.bestand + l.menge, preis = l.preis WHEN NOT MATCHED THEN INSERT (artikel_nr, bezeichnung, kategorie, preis, bestand) VALUES (l.artikel_nr, l.bezeichnung, l.kategorie, l.preis, l.menge) RETURNING merge_action(), a.*;
⚠️ SQLite: Laut Doku sollte das SELECT in INSERT … SELECT … ON CONFLICT immer eine WHERE-Klausel haben (notfalls WHERE true) – sonst kann der Parser ON als Join-Bedingung lesen. In sql.js 3.49.1 führt das Weglassen hier zu einem Syntaxfehler.
INSERT OR REPLACE ist kein Upsert
Das hat Folgen: eine neue Autoincrement-ID, nicht angegebene Spalten fallen auf den Default zurück, ON DELETE CASCADE löscht abhängige Zeilen – und DELETE-Trigger feuern in SQLite nur mit PRAGMA recursive_triggers.
-- Falle: INSERT OR REPLACE INTO konten (email, name) VALUES (…); -- (REPLACE INTO … ist ein Alias dafür) -- richtig: INSERT INTO konten (email, name) VALUES (…) ON CONFLICT (email) DO UPDATE SET name = excluded.name;
⚠️ Neue ID: Die Zeile bekommt einen neuen Autoincrement-Wert – Verweise aus anderen Systemen (Caches, URLs, Exporte) zeigen ins Leere.
Race Condition: „erst prüfen, dann einfügen“
Die Prüfung gehört in die Datenbank: Ein UNIQUE-Constraint macht das zweite INSERT zum Fehler, ein Upsert macht daraus eine atomare Anweisung.
INSERT INTO anmeldungen (email, anzahl) VALUES (?, 1) ON CONFLICT (email) DO UPDATE SET anzahl = anmeldungen.anzahl + 1;
⚠️ Ohne UNIQUE-Constraint hilft auch eine Transaktion nicht: Unter READ COMMITTED sehen beide Sitzungen die fehlende Zeile, keine Sperre verhindert das zweite INSERT.
🕰️ Historie & Audit
Preis-Historie mit gültig_von / gültig_bis (SCD Typ 2)
Ein AFTER-UPDATE-Trigger schließt die aktuelle Version (gueltig_bis = jetzt) und legt eine neue offene Version an (gueltig_bis = NULL).
CREATE TRIGGER artikel_preis_historie AFTER UPDATE OF preis ON artikel WHEN old.preis IS NOT new.preis BEGIN UPDATE preis_historie SET gueltig_bis = CURRENT_TIMESTAMP WHERE artikel_nr = new.artikel_nr AND gueltig_bis IS NULL; INSERT INTO preis_historie (artikel_nr, preis, gueltig_von) VALUES (new.artikel_nr, new.preis, CURRENT_TIMESTAMP); END;
⚠️ Intervalle halb-offen speichern: [gueltig_von, gueltig_bis). Dann schließen sich Versionen lückenlos an, und eine Abfrage „Stand am“ trifft genau eine Zeile.
Audit-Log: wer hat was geändert – alt und neu als JSON
Drei Trigger (INSERT, UPDATE, DELETE) schreiben je eine Zeile in audit_log: Aktion, Schlüssel und die Zeile als JSON (alt bzw.
CREATE TRIGGER kunden_audit_upd AFTER UPDATE ON kunden BEGIN INSERT INTO audit_log (tabelle, aktion, schluessel, alt, neu, zeit) VALUES ('kunden', 'UPDATE', new.kunde_id, json_object('name', old.name, 'punkte', old.punkte), json_object('name', new.name, 'punkte', new.punkte), CURRENT_TIMESTAMP); END;
⚠️ Audit-Logs wachsen schnell: Aufbewahrungsfrist festlegen, nach Zeit partitionieren oder archivieren.
System-versionierte Tabellen: die Datenbank führt die Historie
SQL Server (ab 2016) und MariaDB (ab 10.3.4) können Tabellen system-versioniert führen: Jede Änderung wandert automatisch in die Historie, abgefragt wird mit FOR SYSTEM_TIME AS OF.
-- Keine System-Versionierung eingebaut (SQL:2011-Feature T180 fehlt). -- Üblich: Trigger wie im SQLite-Nachbau oder eine Erweiterung. -- Ab PostgreSQL 18: Anwendungszeit-Constraints, z. B. CREATE TABLE konto_limit ( konto_id integer, gueltig daterange, limit_eur integer, PRIMARY KEY (konto_id, gueltig WITHOUT OVERLAPS) ); -- benötigt die Erweiterung btree_gist
⚠️ System-Zeit ist Transaktionszeit (wann wurde es gespeichert) – nicht Geschäftszeit (ab wann gilt der Vertrag). Für rückwirkende Änderungen braucht es Anwendungszeit (gültig_von/bis selbst gepflegt).
Zeitreise-Abfrage: „Wie war der Stand am …?“
Gültig ist die Version mit gueltig_von <= Stichtag und (gueltig_bis IS NULL oder gueltig_bis > Stichtag).
SELECT artikel_nr, preis FROM preis_historie WHERE gueltig_von <= '2026-02-01' AND (gueltig_bis IS NULL OR gueltig_bis > '2026-02-01');
⚠️ BETWEEN gueltig_von AND gueltig_bis ist geschlossen – am Wechseltag passen zwei Versionen.
⚡ Trigger
BEFORE-Trigger zur Validierung
Ein BEFORE INSERT-Trigger prüft die neue Zeile und bricht mit einer verständlichen Meldung ab.
CREATE TRIGGER bestellung_pruefen BEFORE INSERT ON bestellungen WHEN new.betrag <= 0 BEGIN SELECT RAISE(ABORT, 'Betrag muss positiv sein'); END;
⚠️ Was deklarativ geht, gehört in CHECK, NOT NULL, FOREIGN KEY: schneller, für den Optimierer sichtbar und nicht per Trigger-Reihenfolge umgehbar. Trigger erst, wenn andere Tabellen oder komplexe Regeln beteiligt sind.
Abgeleitete Werte: Generated Column oder Trigger?
Werte aus derselben Zeile berechnet eine Generated Column – ganz ohne Trigger.
summe NUMERIC(10,2) GENERATED ALWAYS AS (menge * einzelpreis) STORED -- VIRTUAL (Standard) berechnet beim Lesen, STORED beim Schreiben
⚠️ Denormalisierte Summen driften, sobald ein Fall fehlt: Hier gibt es keinen UPDATE- und DELETE-Trigger auf positionen – eine gelöschte Position lässt betrag falsch stehen. Lieber zur Abfragezeit summieren (View) oder alle drei Ereignisse abdecken.
INSTEAD OF: Sichten beschreibbar machen
Ein INSTEAD OF UPDATE-Trigger auf der Sicht fängt die Änderung ab und schreibt stattdessen den umgerechneten Nettopreis in die Basistabelle.
CREATE TRIGGER artikel_brutto_upd INSTEAD OF UPDATE OF preis_brutto ON artikel_brutto BEGIN UPDATE artikel SET preis = ROUND(new.preis_brutto / 1.19, 2) WHERE artikel_nr = new.artikel_nr; END;
⚠️ Rundung: 35,70 € brutto → 30,00 € netto → wieder 35,70 €. Bei anderen Werten kann das Hin- und Zurückrechnen um einen Cent abweichen.
FOR EACH ROW oder FOR EACH STATEMENT?
Ein Zeilentrigger feuert je betroffener Zeile, ein Anweisungstrigger einmal pro Anweisung – auch wenn keine Zeile betroffen ist.
CREATE TRIGGER artikel_zeile AFTER UPDATE ON artikel FOR EACH ROW BEGIN INSERT INTO trigger_log (trigger_name, info) VALUES ('artikel_zeile', old.artikel_nr); END;
⚠️ Zeilentrigger bei Massen-Updates: n Aufrufe, n kleine INSERTs ins Log. Wenn möglich mengenbasiert (Anweisungstrigger mit Transition Tables) arbeiten.
Kaskaden und Rekursion: Trigger lösen Trigger aus
In SQLite sind rekursive Trigger standardmäßig aus: Das innere UPDATE löst den Trigger dann nicht noch einmal aus, und nur die direkten Untergebenen (6 und 8) wechseln.
PRAGMA recursive_triggers = ON; -- Standard: OFF -- Grenze: SQLITE_MAX_TRIGGER_DEPTH (Standard 1000)
⚠️ Ohne Abbruchbedingung (abteilung <> new.abteilung) läuft eine Rekursion bis zur Tiefengrenze und bricht dann mit Fehler ab.
👯 Duplikate
Duplikate finden mit GROUP BY … HAVING
Nach dem Merkmal gruppieren, das „gleich“ definiert, und mit HAVING COUNT() > 1 nur Gruppen mit mehreren Zeilen behalten.
SELECT email, COUNT(*) AS anzahl, GROUP_CONCAT(kontakt_id, ',') AS ids FROM kontakte GROUP BY email HAVING COUNT(*) > 1;
⚠️ „Duplikat“ ist eine fachliche Definition: gleiche E-Mail? gleicher Name und Ort? Jonas Feld aus Bonn (8) ist vielleicht umgezogen – oder eine andere Person.
Genau eine Zeile je Gruppe behalten (ROW_NUMBER)
ROW_NUMBER() OVER (PARTITION BY email ORDER BY angelegt DESC, kontakt_id DESC) nummeriert jede Gruppe durch – Nummer 1 ist der Gewinner.
DELETE FROM kontakte WHERE rowid IN ( SELECT rowid FROM ( SELECT rowid, ROW_NUMBER() OVER (PARTITION BY email ORDER BY angelegt DESC) AS nr FROM kontakte) WHERE nr > 1);
⚠️ Ohne eindeutige Sortierung (Gleichstand bei angelegt) ist zufällig, welche Zeile überlebt – immer einen eindeutigen zweiten Schlüssel anhängen.
Ältesten behalten mit EXISTS-Selbstjoin
Eine Zeile ist überflüssig, wenn es eine andere Zeile mit derselben E-Mail und kleinerer ID gibt.
DELETE FROM kontakte WHERE EXISTS (SELECT 1 FROM kontakte AS k2 WHERE k2.email = kontakte.email AND k2.kontakt_id < kontakte.kontakt_id);
⚠️ Ohne Index auf email prüft jede Zeile alle anderen – quadratischer Aufwand. Vorher CREATE INDEX … (email, kontakt_id).
Unscharfe Duplikate: normalisieren vor dem Vergleich
Vor dem Gruppieren normalisieren: LOWER(TRIM(email)).
SELECT LOWER(TRIM(email)) AS email_norm, COUNT(*) FROM kontakte GROUP BY LOWER(TRIM(email)) HAVING COUNT(*) > 1;
⚠️ Normalisieren ist Fachlogik: Bei E-Mail-Adressen ist der lokale Teil laut Standard theoretisch case-sensitiv, praktisch behandeln ihn fast alle Anbieter ohne Groß/Klein.
Duplikate verhindern: UNIQUE auf den normalisierten Wert
Ein eindeutiger Index auf LOWER(TRIM(email)) lehnt jede Variante einer vorhandenen Adresse ab.
CREATE UNIQUE INDEX ux_kontakte_email ON kontakte (LOWER(TRIM(email))); CREATE UNIQUE INDEX ux_aktiv ON kontakte (email) WHERE geloescht_am IS NULL;
⚠️ Der Index lässt sich erst anlegen, wenn keine Dubletten mehr existieren – sonst schlägt CREATE UNIQUE INDEX fehl.
🧠 Abfragemuster
Gaps & Islands: Lücken in Nummernfolgen
Der Trick: nr - ROW_NUMBER() OVER (ORDER BY nr) ist innerhalb einer lückenlosen Folge konstant und springt bei jeder Lücke.
SELECT MIN(nr), MAX(nr) FROM (SELECT nr, nr - ROW_NUMBER() OVER (ORDER BY nr) AS insel FROM rechnungen) GROUP BY insel;
⚠️ Bei Datumsfolgen (Login-Serien) funktioniert dasselbe mit datum - ROW_NUMBER() als Tage – die Datumsarithmetik ist aber dialektabhängig (julianday() in SQLite, date - integer in PostgreSQL).
Top-N je Gruppe: die zwei größten Bestellungen pro Kunde
Pro Kunde durchnummerieren (PARTITION BY kunde_id ORDER BY betrag DESC) und außen auf nr <= 2 filtern.
SELECT * FROM ( SELECT b.*, ROW_NUMBER() OVER (PARTITION BY kunde_id ORDER BY betrag DESC) AS nr FROM bestellungen b) WHERE nr <= 2;
⚠️ Gleichstand: ROW_NUMBER wählt willkürlich, RANK liefert bei Gleichstand mehr als N Zeilen, DENSE_RANK die N größten Werte. Fachlich entscheiden!
Laufende Summen – und die RANGE-Falle
SUM(betrag) OVER (ORDER BY datum) nutzt als Standard-Rahmen RANGE … CURRENT ROW – alle Zeilen mit gleichem Datum zählen sofort mit.
SUM(betrag) OVER (PARTITION BY strftime('%Y-%m', datum) ORDER BY datum, bestell_id ROWS UNBOUNDED PRECEDING)
⚠️ Mit ORDER BY, aber ohne Rahmenangabe gilt RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW – gleiche Sortierwerte werden gemeinsam aufsummiert.
Pivot und Unpivot mit CASE-Aggregation
Pivot: Je Zielspalte ein Aggregat über einen CASE-Ausdruck – SUM(CASE WHEN monat = '01' THEN betrag END).
SUM(betrag) FILTER (WHERE strftime('%m', datum) = '01') AS jan -- ohne FILTER: SUM(CASE WHEN … THEN betrag END)
⚠️ SUM(CASE … THEN betrag ELSE 0 END) macht aus „keine Bestellung“ eine 0 – ELSE weglassen liefert NULL. Beides kann richtig sein, es bedeutet aber Verschiedenes.
Rekursive CTE: Organigramm mit Pfad und Ebene
Eine rekursive CTE hat einen Anker (die Spitze, chef_id IS NULL) und einen rekursiven Teil, der je Runde die nächste Ebene per JOIN anhängt.
WITH RECURSIVE org (ma_id, name, ebene, pfad) AS (…) SELECT * FROM org;
⚠️ Zyklen in den Daten (A ist Chef von B, B von A) führen zu Endlosschleifen – Abbruch über eine Tiefengrenze oder einen Besucht-Pfad.
Stückliste auflösen: Mengen über Ebenen multiplizieren
Die rekursive CTE startet beim Endprodukt und multipliziert die Menge je Ebene weiter.
WITH RECURSIVE aufloesung (komponente, menge, ebene) AS (…) SELECT …;
⚠️ NOT IN (SELECT teil …) ist hier sicher, weil teil NOT NULL ist – bei nullbaren Spalten droht die NOT-IN-Falle (siehe Anti-Join).
Datumsreihe erzeugen: Tage ohne Umsatz sichtbar machen
Erst eine lückenlose Datumsreihe erzeugen (rekursive CTE bzw.
WITH RECURSIVE tage (tag) AS ( SELECT DATE('2026-03-01') UNION ALL SELECT DATE(tag, '+1 day') FROM tage WHERE tag < '2026-03-10') SELECT …;
⚠️ Die Bedingung auf bestellungen (Status) gehört in die ON-Klausel – im WHERE würde sie den LEFT JOIN zum INNER JOIN machen und die leeren Tage wieder entfernen.
Anti-Join: NOT EXISTS statt NOT IN (NULL-Falle)
In der Sperrliste steht eine NULL.
SELECT k.* FROM kunden k WHERE NOT EXISTS (SELECT 1 FROM sperrliste s WHERE s.email = k.email);
⚠️ Lea Winter hat keine E-Mail (NULL) und erscheint bei NOT EXISTS – fachlich prüfen, ob das gewollt ist (AND k.email IS NOT NULL).
COALESCE und NULLIF: Standardwerte und Division durch null
NULLIF(klicks, 0) macht aus 0 ein NULL, die Division ergibt dann NULL statt Fehler.
ROUND(100.0 * kaeufe / NULLIF(klicks, 0), 1) -- ohne NULLIF liefert SQLite bereits NULL (kein Fehler)
⚠️ Ganzzahl-Division: kaeufe / klicks ergibt in SQLite, PostgreSQL und SQL Server bei zwei INTEGER-Werten 0 (abgeschnitten). Deshalb 100.0 voranstellen.
Datumsgrenzen: halb-offene Intervalle statt BETWEEN
Immer halb-offen filtern: >= Monatsanfang AND < nächster Monatsanfang.
WHERE zeit >= '2026-02-01' AND zeit < date('2026-02-01', '+1 month')
⚠️ Das Ergebnis unterscheidet sich sogar je Engine: In SQLite (Zeitstempel als Text) fehlt bei BETWEEN auch der Login um Mitternacht des 28., in PostgreSQL (TIMESTAMP) nur der um 18:30 – falsch sind beide.
🧰 Praxis-Rezepte
Soft Delete: löschen, ohne zu löschen
Statt DELETE ein Zeitstempel geloescht_am.
CREATE UNIQUE INDEX ux_konten_email_aktiv ON konten (email) WHERE geloescht_am IS NULL;
⚠️ Jede Abfrage muss den Filter kennen – eine vergessene Stelle zeigt gelöschte Daten. Deshalb die Sicht (oder RLS) als einzige Schnittstelle.
Paginierung: OFFSET vs. Keyset (Seek-Methode)
Keyset-Paginierung merkt sich den Sortierschlüssel der letzten gezeigten Zeile und fragt „die nächsten 4 nach diesem Schlüssel“.
SELECT * FROM bestellungen WHERE (datum, bestell_id) > (?, ?) ORDER BY datum, bestell_id LIMIT 20;
⚠️ Der Sortierschlüssel muss eindeutig sein – sonst gehen Zeilen mit gleichem Datum an der Seitengrenze verloren. Deshalb (datum, bestell_id).
Sicheres Batch-Delete: große Mengen in kleinen Portionen
In Portionen löschen: je Durchlauf höchstens N Zeilen (hier 4), stabil nach Primärschlüssel sortiert, jede Portion in eigener kurzer Transaktion – wiederholen, bis 0 Zeilen betroffen sind.
DELETE FROM protokoll WHERE id IN (SELECT id FROM protokoll WHERE zeit < '2026-01-01' ORDER BY id LIMIT 5000); -- DELETE … LIMIT direkt nur mit SQLITE_ENABLE_UPDATE_DELETE_LIMIT
⚠️ Ohne Index auf dem Filter (zeit) sucht jeder Batch erneut per Tabellenscan – dann wird das Batchen langsamer als ein großes DELETE.
JSON-Spalten abfragen und aufklappen
->> holt einen Wert als SQL-Text/Zahl heraus, json_each (SQLite) bzw.
SELECT details ->> '$.zahlung' FROM auftraege; SELECT t.value, COUNT(*) FROM auftraege, json_each(details, '$.tags') t GROUP BY t.value;
⚠️ -> liefert JSON, ->> den SQL-Wert. details -> 'zahlung' = 'karte' vergleicht JSON mit Text und findet in PostgreSQL nichts (bzw. Fehler).
EXPLAIN in 60 Sekunden: SCAN oder SEARCH?
EXPLAIN QUERY PLAN (SQLite) bzw.
EXPLAIN QUERY PLAN SELECT * FROM bestellungen WHERE kunde_id = 3; -- EXPLAIN (ohne QUERY PLAN) zeigt den Bytecode der VM
⚠️ Pläne auf Testdaten mit 12 Zeilen sagen wenig über Produktion mit 12 Millionen – Statistiken aktuell halten (ANALYZE).
🌍 Dialekte im Vergleich
| Thema | PostgreSQL | MySQL / MariaDB | SQL Server | Oracle | SQLite |
|---|---|---|---|---|---|
| Upsert Upsert | INSERT … ON CONFLICT (9.5) · MERGE (15) | INSERT … ON DUPLICATE KEY UPDATE | MERGE (2008) | MERGE (9i) | INSERT … ON CONFLICT (3.24.0) |
| Upsert neue Zeile im Upsert | EXCLUDED.spalte | neu.spalte (Alias, 8.0.19) · VALUES(spalte) | Quell-Alias aus USING | Quell-Alias aus USING | excluded.spalte |
| Upsert eingefügte Zeilen zurückgeben | RETURNING | – (MariaDB: RETURNING ab 10.5) | OUTPUT inserted.* | RETURNING … INTO (PL/SQL) | RETURNING (3.35.0) |
| Upsert REPLACE (DELETE + INSERT) | – | REPLACE INTO | – | – | INSERT OR REPLACE |
| Historie System-Versionierung | – (Trigger; 18: WITHOUT OVERLAPS) | MariaDB: WITH SYSTEM VERSIONING (10.3.4) | Temporal Tables (2016) | Flashback Query (9i) · Data Archive (11g) | – (Trigger) |
| Historie Abfrage „Stand am“ | WHERE von <= t AND t < bis | MariaDB: FOR SYSTEM_TIME AS OF | FOR SYSTEM_TIME AS OF | AS OF TIMESTAMP | WHERE von <= t AND t < bis |
| Trigger Trigger-Zeitpunkte | BEFORE · AFTER · INSTEAD OF | BEFORE · AFTER | AFTER · INSTEAD OF | BEFORE · AFTER · INSTEAD OF · Compound (11g) | BEFORE · AFTER · INSTEAD OF (nur Views) |
| Trigger Trigger-Ebene | ROW und STATEMENT | nur ROW | nur Anweisung | ROW und Anweisung | nur ROW |
| Trigger alte/neue Werte | OLD / NEW · Transition Tables (10) | OLD / NEW | deleted / inserted | :OLD / :NEW | old / new |
| Trigger Trigger ändert eigene Tabelle | erlaubt, rekursiv (pg_trigger_depth()) | verboten (Fehler 1442) | RECURSIVE_TRIGGERS, Standard OFF | Zeilentrigger: ORA-04091 | PRAGMA recursive_triggers, Standard OFF |
| Trigger Abbruch im Trigger | RAISE EXCEPTION | SIGNAL SQLSTATE '45000' | THROW | RAISE_APPLICATION_ERROR | RAISE(ABORT, '…') |
| Indizes partieller Index | CREATE INDEX … WHERE | – (Generated Column + UNIQUE) | gefilterter Index (2008) | Ausdruck, der NULL liefert | CREATE INDEX … WHERE (3.8.0) |
| Indizes Index auf Ausdruck | (lower(email)) | ((LOWER(email))) ab 8.0.13 | berechnete Spalte + Index | funktionsbasierter Index | (LOWER(email)) ab 3.9.0 |
| Abfragen Seitenweise lesen | LIMIT/OFFSET · FETCH FIRST | LIMIT … OFFSET | OFFSET … FETCH (2012) | FETCH FIRST (12c) | LIMIT … OFFSET |
| Abfragen rekursive CTE | WITH RECURSIVE | WITH RECURSIVE (8.0) | WITH (ohne RECURSIVE) | WITH + Spaltenliste (11gR2) · CONNECT BY | WITH RECURSIVE |
| Abfragen JSON-Wert lesen | details ->> 'feld' | details ->> '$.feld' (5.7.13) | JSON_VALUE (2016) | JSON_VALUE | details ->> '$.feld' (3.38.0) |
| Abfragen Texte aggregieren | string_agg | GROUP_CONCAT | STRING_AGG (2017) | LISTAGG | group_concat |
| Abfragen Aggregat mit Filter | FILTER (WHERE …) (9.4) | SUM(CASE WHEN …) | SUM(CASE WHEN …) | SUM(CASE WHEN …) | FILTER (WHERE …) (3.30.0) |
Versionsangaben aus den offiziellen Dokumentationen – Belege auf der Seite „Quellen“.