📋Spickzettel

Jeder Tipp auf einen Blick – plus die Dialekt-Matrix. Druckfreundlich.

Die Druckansicht blendet Navigation und Footer aus und setzt den Code dunkel auf hell.

🔀 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

ThemaPostgreSQLMySQL / MariaDBSQL ServerOracleSQLite
Upsert
Upsert
INSERT … ON CONFLICT (9.5) · MERGE (15)INSERT … ON DUPLICATE KEY UPDATEMERGE (2008)MERGE (9i)INSERT … ON CONFLICT (3.24.0)
Upsert
neue Zeile im Upsert
EXCLUDED.spalteneu.spalte (Alias, 8.0.19) · VALUES(spalte)Quell-Alias aus USINGQuell-Alias aus USINGexcluded.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 < bisMariaDB: FOR SYSTEM_TIME AS OFFOR SYSTEM_TIME AS OFAS OF TIMESTAMPWHERE von <= t AND t < bis
Trigger
Trigger-Zeitpunkte
BEFORE · AFTER · INSTEAD OFBEFORE · AFTERAFTER · INSTEAD OFBEFORE · AFTER · INSTEAD OF · Compound (11g)BEFORE · AFTER · INSTEAD OF (nur Views)
Trigger
Trigger-Ebene
ROW und STATEMENTnur ROWnur AnweisungROW und Anweisungnur ROW
Trigger
alte/neue Werte
OLD / NEW · Transition Tables (10)OLD / NEWdeleted / inserted:OLD / :NEWold / new
Trigger
Trigger ändert eigene Tabelle
erlaubt, rekursiv (pg_trigger_depth())verboten (Fehler 1442)RECURSIVE_TRIGGERS, Standard OFFZeilentrigger: ORA-04091PRAGMA recursive_triggers, Standard OFF
Trigger
Abbruch im Trigger
RAISE EXCEPTIONSIGNAL SQLSTATE '45000'THROWRAISE_APPLICATION_ERRORRAISE(ABORT, '…')
Indizes
partieller Index
CREATE INDEX … WHERE– (Generated Column + UNIQUE)gefilterter Index (2008)Ausdruck, der NULL liefertCREATE INDEX … WHERE (3.8.0)
Indizes
Index auf Ausdruck
(lower(email))((LOWER(email))) ab 8.0.13berechnete Spalte + Indexfunktionsbasierter Index(LOWER(email)) ab 3.9.0
Abfragen
Seitenweise lesen
LIMIT/OFFSET · FETCH FIRSTLIMIT … OFFSETOFFSET … FETCH (2012)FETCH FIRST (12c)LIMIT … OFFSET
Abfragen
rekursive CTE
WITH RECURSIVEWITH RECURSIVE (8.0)WITH (ohne RECURSIVE)WITH + Spaltenliste (11gR2) · CONNECT BYWITH RECURSIVE
Abfragen
JSON-Wert lesen
details ->> 'feld'details ->> '$.feld' (5.7.13)JSON_VALUE (2016)JSON_VALUEdetails ->> '$.feld' (3.38.0)
Abfragen
Texte aggregieren
string_aggGROUP_CONCATSTRING_AGG (2017)LISTAGGgroup_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“.