🧠Abfragemuster

Gaps & Islands, Top-N, laufende Summen, Pivot, rekursive CTEs, Anti-Join, Datumsgrenzen.

Die Grundlagen – JOINs, NULL-Logik, GROUP BY, Window Functions und CTEs – erklärt VisualSql Schritt für Schritt. Hier stehen die Rezepte, die man daraus baut.

🧠 Tipp 1★★★ ProfiROW_NUMBERLEADLücken

Gaps & Islands: Lücken in Nummernfolgen

😖 Problem
Rechnungsnummern müssen lückenlos sein – der Steuerprüfer fragt nach 1004, 1007, 1008, 1013 und 1014. Welche zusammenhängenden Bereiche („Inseln“) gibt es, und wo sind die Lücken?
💡 Lösung
Der Trick: nr - ROW_NUMBER() OVER (ORDER BY nr) ist innerhalb einer lückenlosen Folge konstant und springt bei jeder Lücke. Nach dieser Differenz gruppieren ergibt die Inseln. Die Lücken liefert LEAD(nr): Ist die nächste Nummer größer als nr + 1, fehlt etwas.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
SELECT MIN(nr), MAX(nr)
  FROM (SELECT nr, nr - ROW_NUMBER() OVER (ORDER BY nr) AS insel
          FROM rechnungen) t
 GROUP BY insel;
MySQL / MariaDBab MySQL 8.0 / MariaDB 10.2
nur gezeigt, nicht ausgeführt
SELECT MIN(nr), MAX(nr)
  FROM (SELECT nr, nr - ROW_NUMBER() OVER (ORDER BY nr) AS insel
          FROM rechnungen) t
 GROUP BY insel;
SQL Server
nur gezeigt, nicht ausgeführt
SELECT MIN(nr), MAX(nr)
  FROM (SELECT nr, nr - ROW_NUMBER() OVER (ORDER BY nr) AS insel
          FROM rechnungen) t
 GROUP BY insel;
Oracle
nur gezeigt, nicht ausgeführt
SELECT MIN(nr), MAX(nr)
  FROM (SELECT nr, nr - ROW_NUMBER() OVER (ORDER BY nr) AS insel
          FROM rechnungen)
 GROUP BY insel;

Oracle erlaubt kein AS vor Tabellen-Aliasen.

SQLiteab 3.25.0
▶ ausgeführt · sql.js
SELECT MIN(nr), MAX(nr)
  FROM (SELECT nr, nr - ROW_NUMBER() OVER (ORDER BY nr) AS insel
          FROM rechnungen)
 GROUP BY insel;

⚠️ Fallstricke

  • 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).
  • Gibt es doppelte Werte, DENSE_RANK() statt ROW_NUMBER() nehmen – sonst zerfällt eine Insel.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: Window Functions
🧠 Tipp 2★★ FortgeschrittenROW_NUMBERLATERALDISTINCT ON

Top-N je Gruppe: die zwei größten Bestellungen pro Kunde

😖 Problem
ORDER BY betrag DESC LIMIT 2 liefert die zwei größten Bestellungen insgesamt – gesucht sind aber die zwei größten je Kunde.
💡 Lösung
Pro Kunde durchnummerieren (PARTITION BY kunde_id ORDER BY betrag DESC) und außen auf nr <= 2 filtern. Window Functions dürfen nicht im WHERE stehen – deshalb die Unterabfrage.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
SELECT k.name, b.*
  FROM kunden k
 CROSS JOIN LATERAL (
   SELECT * FROM bestellungen
    WHERE kunde_id = k.kunde_id
    ORDER BY betrag DESC LIMIT 2) b;

-- nur Top-1: DISTINCT ON
SELECT DISTINCT ON (kunde_id) * FROM bestellungen ORDER BY kunde_id, betrag DESC;

LATERAL nutzt einen Index (kunde_id, betrag) optimal, wenn es viele Bestellungen, aber wenige Kunden gibt.

MySQL / MariaDBab MySQL 8.0
nur gezeigt, nicht ausgeführt
SELECT * FROM (
  SELECT b.*, ROW_NUMBER() OVER (PARTITION BY kunde_id ORDER BY betrag DESC) AS nr
    FROM bestellungen b) t
 WHERE nr <= 2;
SQL Server
nur gezeigt, nicht ausgeführt
SELECT k.name, b.*
  FROM kunden k
 CROSS APPLY (SELECT TOP (2) * FROM bestellungen
               WHERE kunde_id = k.kunde_id
               ORDER BY betrag DESC) b;

CROSS APPLY ist das SQL-Server-Gegenstück zu LATERAL.

Oracle
nur gezeigt, nicht ausgeführt
SELECT * FROM (
  SELECT b.*, ROW_NUMBER() OVER (PARTITION BY kunde_id ORDER BY betrag DESC) AS nr
    FROM bestellungen b)
 WHERE nr <= 2;
SQLiteab 3.25.0
▶ ausgeführt · sql.js
SELECT * FROM (
  SELECT b.*, ROW_NUMBER() OVER (PARTITION BY kunde_id ORDER BY betrag DESC) AS nr
    FROM bestellungen b)
 WHERE nr <= 2;

⚠️ Fallstricke

  • Gleichstand: ROW_NUMBER wählt willkürlich, RANK liefert bei Gleichstand mehr als N Zeilen, DENSE_RANK die N größten Werte. Fachlich entscheiden!
  • Ein Filter wie status <> 'storniert' gehört in die Unterabfrage – außen angewendet würde er Lücken in die Nummerierung reißen.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: ROW_NUMBER, RANK, DENSE_RANK
🧠 Tipp 3★★ FortgeschrittenSUM OVERROWSRANGE

Laufende Summen – und die RANGE-Falle

😖 Problem
Für eine Umsatzkurve braucht es den kumulierten Umsatz je Tag. Am 03.03. gibt es zwei Bestellungen – und plötzlich stehen dort zwei gleiche Zwischensummen.
💡 Lösung
SUM(betrag) OVER (ORDER BY datum) nutzt als Standard-Rahmen RANGE … CURRENT ROW – alle Zeilen mit gleichem Datum zählen sofort mit. Mit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW wird Zeile für Zeile addiert. Vergleiche die beiden Spalten am 03.03.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
SUM(betrag) OVER (PARTITION BY date_trunc('month', datum)
                  ORDER BY datum, bestell_id
                  ROWS UNBOUNDED PRECEDING)
MySQL / MariaDBab MySQL 8.0
nur gezeigt, nicht ausgeführt
SUM(betrag) OVER (PARTITION BY DATE_FORMAT(datum, '%Y-%m')
                  ORDER BY datum, bestell_id
                  ROWS UNBOUNDED PRECEDING)
SQL Server
nur gezeigt, nicht ausgeführt
SUM(betrag) OVER (PARTITION BY YEAR(datum), MONTH(datum)
                  ORDER BY datum, bestell_id
                  ROWS UNBOUNDED PRECEDING)

In SQL Server ist ROWS meist auch deutlich schneller als der Standard-Rahmen RANGE.

Oracle
nur gezeigt, nicht ausgeführt
SUM(betrag) OVER (PARTITION BY TRUNC(datum, 'MM')
                  ORDER BY datum, bestell_id
                  ROWS UNBOUNDED PRECEDING)
SQLiteab 3.25.0
▶ ausgeführt · sql.js
SUM(betrag) OVER (PARTITION BY strftime('%Y-%m', datum)
                  ORDER BY datum, bestell_id
                  ROWS UNBOUNDED PRECEDING)

⚠️ Fallstricke

  • Mit ORDER BY, aber ohne Rahmenangabe gilt RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW – gleiche Sortierwerte werden gemeinsam aufsummiert.
  • Die Sortierung im OVER bestimmt die Rechnung, das äußere ORDER BY nur die Anzeige. Beide gleich halten, sonst sieht die Kurve „springend“ aus.
  • Beim Monatswechsel nur den Monat (%m) zu partitionieren, wirft Januar 2026 und Januar 2027 zusammen – Jahr immer mitnehmen.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: Fensterrahmen sichtbar gemacht
🧠 Tipp 4★★ FortgeschrittenPivotCASEFILTERUNION ALL

Pivot und Unpivot mit CASE-Aggregation

😖 Problem
Der Vertrieb will eine Kreuztabelle: Kunden in Zeilen, Monate in Spalten. Und umgekehrt: eine breite Tabelle mit Quartalsspalten soll wieder „lang“ werden.
💡 Lösung
Pivot: Je Zielspalte ein Aggregat über einen CASE-Ausdruck – SUM(CASE WHEN monat = '01' THEN betrag END). Das funktioniert in jedem Dialekt. Unpivot: je Spalte ein SELECT, verbunden mit UNION ALL.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 9.4 (FILTER)
▶ ausführbar · PGlite
SUM(betrag) FILTER (WHERE extract(month FROM datum) = 1) AS jan
-- Unpivot: CROSS JOIN LATERAL (VALUES ('Q1', q1), ('Q2', q2)) u(quartal, wert)

Dynamische Spalten: Erweiterung tablefunc (crosstab) – oder in der Anwendung pivotieren.

MySQL / MariaDB
nur gezeigt, nicht ausgeführt
SUM(CASE WHEN MONTH(datum) = 1 THEN betrag END) AS jan
-- oder kürzer (Boolean = 0/1): SUM(IF(MONTH(datum) = 1, betrag, NULL))

Kein PIVOT/UNPIVOT – CASE-Aggregation und UNION ALL.

SQL Server
nur gezeigt, nicht ausgeführt
SELECT name, [1] AS jan, [2] AS feb, [3] AS mrz
  FROM (SELECT k.name, MONTH(b.datum) AS m, b.betrag
          FROM kunden k JOIN bestellungen b ON b.kunde_id = k.kunde_id) s
 PIVOT (SUM(betrag) FOR m IN ([1], [2], [3])) p;
-- Gegenstück: UNPIVOT (wert FOR quartal IN (q1, q2, q3, q4))

UNPIVOT lässt NULL-Werte weg (Süd/Q3 fehlt dann).

Oracle
nur gezeigt, nicht ausgeführt
SELECT * FROM (
  SELECT k.name, EXTRACT(MONTH FROM b.datum) AS m, b.betrag
    FROM kunden k JOIN bestellungen b ON b.kunde_id = k.kunde_id)
 PIVOT (SUM(betrag) FOR m IN (1 AS jan, 2 AS feb, 3 AS mrz));
-- UNPIVOT [INCLUDE NULLS] (wert FOR quartal IN (q1, q2, q3, q4))
SQLiteab 3.30.0 (FILTER)
▶ ausgeführt · sql.js
SUM(betrag) FILTER (WHERE strftime('%m', datum) = '01') AS jan
-- ohne FILTER: SUM(CASE WHEN … THEN betrag END)

⚠️ Fallstricke

  • 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.
  • Pivot-Spalten sind statisch: Ein neuer Monat braucht eine neue Spalte im SQL. Dynamisches Pivot geht nur mit dynamischem SQL.
  • Unpivot mit UNION ALL liest die Tabelle einmal je Spalte; LATERAL/VALUES bzw. UNPIVOT lesen sie nur einmal.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: GROUP BY und Aggregate
🧠 Tipp 5★★ FortgeschrittenWITH RECURSIVEHierarchiePfad

Rekursive CTE: Organigramm mit Pfad und Ebene

😖 Problem
mitarbeiter.chef_id verweist auf die eigene Tabelle. Wer steht wie tief unter der Geschäftsführung, und wie sieht der Weg von oben aus?
💡 Lösung
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. Ebene und Pfad werden dabei mitgeführt.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
WITH RECURSIVE org AS (…)
SELECT * FROM org;
-- ab 14 zusätzlich: SEARCH DEPTH FIRST BY ma_id SET ordnung
--                   CYCLE ma_id SET zyklus USING weg
MySQL / MariaDBab MySQL 8.0 / MariaDB 10.2.2
nur gezeigt, nicht ausgeführt
WITH RECURSIVE org (ma_id, name, ebene, pfad) AS (
  SELECT ma_id, name, 0, CAST(name AS CHAR(500)) FROM mitarbeiter WHERE chef_id IS NULL
  UNION ALL
  SELECT m.ma_id, m.name, o.ebene + 1, CONCAT(o.pfad, ' › ', m.name)
    FROM mitarbeiter m JOIN org o ON m.chef_id = o.ma_id)
SELECT * FROM org;

Der Spaltentyp kommt aus dem Anker – ohne CAST wird der Pfad auf die Länge des ersten Namens abgeschnitten (bzw. Fehler im strikten Modus).

SQL Server
nur gezeigt, nicht ausgeführt
WITH org (ma_id, name, ebene, pfad) AS (   -- ohne RECURSIVE
  SELECT ma_id, name, 0, CAST(name AS nvarchar(500)) FROM mitarbeiter WHERE chef_id IS NULL
  UNION ALL
  SELECT m.ma_id, m.name, o.ebene + 1, CAST(o.pfad + N' › ' + m.name AS nvarchar(500))
    FROM mitarbeiter m JOIN org o ON m.chef_id = o.ma_id)
SELECT * FROM org OPTION (MAXRECURSION 100);

Standardgrenze 100 Rekursionsstufen; Typen in Anker und Rekursion müssen exakt passen.

Oracleab 11gR2
nur gezeigt, nicht ausgeführt
WITH org (ma_id, name, ebene, pfad) AS (   -- Spaltenliste Pflicht, kein RECURSIVE
  SELECT ma_id, name, 0, name FROM mitarbeiter WHERE chef_id IS NULL
  UNION ALL
  SELECT m.ma_id, m.name, o.ebene + 1, o.pfad || ' › ' || m.name
    FROM mitarbeiter m JOIN org o ON m.chef_id = o.ma_id)
SELECT * FROM org;
-- klassisch: START WITH chef_id IS NULL CONNECT BY PRIOR ma_id = chef_id
SQLite
▶ ausgeführt · sql.js
WITH RECURSIVE org (ma_id, name, ebene, pfad) AS (…)
SELECT * FROM org;

⚠️ Fallstricke

  • Zyklen in den Daten (A ist Chef von B, B von A) führen zu Endlosschleifen – Abbruch über eine Tiefengrenze oder einen Besucht-Pfad.
  • UNION statt UNION ALL entfernt Duplikate je Runde – das kostet und kann Zyklen verschleiern.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: rekursive CTE Runde für Runde
🧠 Tipp 6★★★ ProfiWITH RECURSIVEStücklisteBOM

Stückliste auflösen: Mengen über Ebenen multiplizieren

😖 Problem
Ein Regal besteht aus Seitenteilen, Böden und einem Beschlagset; das Beschlagset wiederum aus Schrauben und Dübeln. Wie viele Schrauben braucht man für 3 Regale?
💡 Lösung
Die rekursive CTE startet beim Endprodukt und multipliziert die Menge je Ebene weiter. Am Ende summiert man die Blätter (Teile ohne eigene Stückliste).

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
WITH RECURSIVE aufloesung AS (…) SELECT …;
MySQL / MariaDBab MySQL 8.0 / MariaDB 10.2.2
nur gezeigt, nicht ausgeführt
WITH RECURSIVE aufloesung AS (…) SELECT …;

Standardgrenze cte_max_recursion_depth = 1000.

SQL Server
nur gezeigt, nicht ausgeführt
WITH aufloesung (komponente, menge, ebene) AS (…)
SELECT … OPTION (MAXRECURSION 0);   -- 0 = unbegrenzt
Oracleab 11gR2
nur gezeigt, nicht ausgeführt
WITH aufloesung (komponente, menge, ebene) AS (…) SELECT …;
SQLite
▶ ausgeführt · sql.js
WITH RECURSIVE aufloesung (komponente, menge, ebene) AS (…) SELECT …;

⚠️ Fallstricke

  • NOT IN (SELECT teil …) ist hier sicher, weil teil NOT NULL ist – bei nullbaren Spalten droht die NOT-IN-Falle (siehe Anti-Join).
  • Dasselbe Teil auf mehreren Wegen (Schraube direkt und über Bodenträger) wird korrekt addiert – deshalb am Ende GROUP BY.

📚 Belege

🧠 Tipp 7★★ FortgeschrittenWITH RECURSIVEgenerate_seriesLEFT JOIN

Datumsreihe erzeugen: Tage ohne Umsatz sichtbar machen

😖 Problem
Ein Umsatzdiagramm pro Tag zeigt nur Tage mit Bestellungen – Lücken verschwinden, die Kurve lügt.
💡 Lösung
Erst eine lückenlose Datumsreihe erzeugen (rekursive CTE bzw. generate_series), dann die Bestellungen per LEFT JOIN anhängen und fehlende Umsätze mit COALESCE(…, 0) füllen.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
SELECT t.tag::date, COALESCE(SUM(b.betrag), 0)
  FROM generate_series(DATE '2026-03-01', DATE '2026-03-10', INTERVAL '1 day') t(tag)
  LEFT JOIN bestellungen b ON b.datum = t.tag::date
 GROUP BY t.tag;
MySQL / MariaDBab MySQL 8.0
nur gezeigt, nicht ausgeführt
WITH RECURSIVE tage (tag) AS (
  SELECT DATE '2026-03-01'
  UNION ALL
  SELECT tag + INTERVAL 1 DAY FROM tage WHERE tag < '2026-03-10')
SELECT …;

MariaDB hat zusätzlich die Sequence-Engine (seq_1_to_10).

SQL Serverab 2022 (GENERATE_SERIES)
nur gezeigt, nicht ausgeführt
SELECT DATEADD(day, s.value, '2026-03-01') AS tag
  FROM GENERATE_SERIES(0, 9) AS s;
-- ältere Versionen: rekursive CTE oder Kalendertabelle
Oracle
nur gezeigt, nicht ausgeführt
SELECT DATE '2026-03-01' + LEVEL - 1 AS tag
  FROM dual CONNECT BY LEVEL <= 10;
SQLite
▶ ausgeführt · sql.js
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 …;

⚠️ Fallstricke

  • 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.
  • COUNT(*) zählt beim LEFT JOIN auch die leere Zeile als 1 – für „Anzahl Bestellungen“ COUNT(b.bestell_id) nehmen.
  • Für häufige Auswertungen lohnt eine feste Kalendertabelle (mit Feiertagen, KW, Quartal).

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: LEFT JOIN visuell
🧠 Tipp 8★★ FortgeschrittenNOT EXISTSNOT INNULL

Anti-Join: NOT EXISTS statt NOT IN (NULL-Falle)

😖 Problem
Newsletter nur an Kunden, die nicht auf der Sperrliste stehen. Mit NOT IN (SELECT email FROM sperrliste) kommt: gar nichts. Warum?
💡 Lösung
In der Sperrliste steht eine NULL. x NOT IN (a, NULL) bedeutet x <> a AND x <> NULL – und x <> NULL ist UNKNOWN, also nie TRUE. NOT EXISTS vergleicht zeilenweise und ist gegen NULL immun. Beide Ergebnisse werden unten angezeigt.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
SELECT k.* FROM kunden k
 WHERE NOT EXISTS (SELECT 1 FROM sperrliste s WHERE s.email = k.email);
-- oder: LEFT JOIN sperrliste s ON … WHERE s.email IS NULL
MySQL / MariaDB
nur gezeigt, nicht ausgeführt
SELECT k.* FROM kunden k
 WHERE NOT EXISTS (SELECT 1 FROM sperrliste s WHERE s.email = k.email);
SQL Server
nur gezeigt, nicht ausgeführt
SELECT k.* FROM kunden k
 WHERE NOT EXISTS (SELECT 1 FROM sperrliste s WHERE s.email = k.email);
-- EXCEPT liefert nur die Schlüsselmenge
Oracle
nur gezeigt, nicht ausgeführt
SELECT k.* FROM kunden k
 WHERE NOT EXISTS (SELECT 1 FROM sperrliste s WHERE s.email = k.email);

Oracle: '' ist NULL – eine leere Zeichenkette in der Sperrliste löst dieselbe Falle aus.

SQLite
▶ ausgeführt · sql.js
SELECT k.* FROM kunden k
 WHERE NOT EXISTS (SELECT 1 FROM sperrliste s WHERE s.email = k.email);

⚠️ Fallstricke

  • 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).
  • NOT IN ist nur sicher, wenn die Unterabfrage garantiert keine NULL liefert (NOT NULL-Spalte oder WHERE email IS NOT NULL).

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: NULL-Logik und die NOT-IN-Falle
🧠 Tipp 9★ EinsteigerCOALESCENULLIFDivision

COALESCE und NULLIF: Standardwerte und Division durch null

😖 Problem
Eine Kampagne hatte 0 Klicks – die Konversionsrate kaeufe / klicks bricht in PostgreSQL und SQL Server mit „Division durch null“ ab. Und fehlende E-Mails sollen im Bericht als „(keine)“ erscheinen.
💡 Lösung
NULLIF(klicks, 0) macht aus 0 ein NULL, die Division ergibt dann NULL statt Fehler. COALESCE(a, b, …) liefert den ersten Wert, der nicht NULL ist – perfekt für Ersatzwerte.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
ROUND(100.0 * kaeufe / NULLIF(klicks, 0), 1)
-- ohne NULLIF: ERROR: division by zero
MySQL / MariaDB
nur gezeigt, nicht ausgeführt
ROUND(100.0 * kaeufe / NULLIF(klicks, 0), 1)
-- ohne NULLIF: NULL (im strikten Modus bei INSERT/UPDATE Fehler)

IFNULL(a, b) ist die Zwei-Argument-Kurzform von COALESCE.

SQL Server
nur gezeigt, nicht ausgeführt
ROUND(100.0 * kaeufe / NULLIF(klicks, 0), 1)
-- ohne NULLIF: Fehler 8134 „Divide by zero error encountered“

ISNULL(a, b) übernimmt den Typ des ersten Arguments – COALESCE ist portabler.

Oracle
nur gezeigt, nicht ausgeführt
ROUND(100 * kaeufe / NULLIF(klicks, 0), 1)
-- ohne NULLIF: ORA-01476 divisor is equal to zero

NVL(a, b) ist Oracles Zwei-Argument-Variante; COALESCE ist Standard-SQL und wertet laut Doku kurzschließend aus.

SQLite
▶ ausgeführt · sql.js
ROUND(100.0 * kaeufe / NULLIF(klicks, 0), 1)
-- ohne NULLIF liefert SQLite bereits NULL (kein Fehler)

⚠️ Fallstricke

  • Ganzzahl-Division: kaeufe / klicks ergibt in SQLite, PostgreSQL und SQL Server bei zwei INTEGER-Werten 0 (abgeschnitten). Deshalb 100.0 * voranstellen.
  • COALESCE auf 0 verfälscht Durchschnitte: Eine Kampagne ohne Klicks hat keine Quote von 0 %, sondern keine Quote.

📚 Belege

Allgemeines SQL-Verhalten – in allen Beispielen ausgeführt bzw. im Text begründet.

Grundlagen in der Schwester-App
↗ VisualSql: NULL in Ausdrücken
🧠 Tipp 10★ EinsteigerBETWEENZeitstempelIndex

Datumsgrenzen: halb-offene Intervalle statt BETWEEN

😖 Problem
„Alle Logins im Februar“ mit BETWEEN '2026-02-01' AND '2026-02-28' verliert alle Logins am 28. nach Mitternacht – denn '2026-02-28 18:30' ist größer als '2026-02-28'.
💡 Lösung
Immer halb-offen filtern: >= Monatsanfang AND < nächster Monatsanfang. Das stimmt für Datum und Zeitstempel, für Schaltjahre und braucht keinen „letzten Tag“. Und die Spalte nicht in eine Funktion packen – sonst kann kein Index helfen.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
WHERE zeit >= DATE '2026-02-01'
  AND zeit <  DATE '2026-02-01' + INTERVAL '1 month'
MySQL / MariaDB
nur gezeigt, nicht ausgeführt
WHERE zeit >= '2026-02-01'
  AND zeit <  '2026-02-01' + INTERVAL 1 MONTH
SQL Server
nur gezeigt, nicht ausgeführt
WHERE zeit >= '20260201'
  AND zeit <  DATEADD(month, 1, '20260201')

Das Format JJJJMMTT wird unabhängig von Sprache/DATEFORMAT-Einstellung gelesen.

Oracle
nur gezeigt, nicht ausgeführt
WHERE zeit >= DATE '2026-02-01'
  AND zeit <  ADD_MONTHS(DATE '2026-02-01', 1)

Oracles DATE enthält immer auch eine Uhrzeit – BETWEEN-Fallen gibt es hier auch bei „reinen“ Datumsspalten.

SQLite
▶ ausgeführt · sql.js
WHERE zeit >= '2026-02-01'
  AND zeit <  date('2026-02-01', '+1 month')

Zeitstempel als ISO-8601-Text sortieren korrekt – aber nur bei einheitlichem Format.

⚠️ Fallstricke

  • 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.
  • BETWEEN ist inklusive an beiden Enden: BETWEEN '2026-02-01' AND '2026-03-01' zählt Mitternacht des 1. März doppelt (im Februar und im März).
  • WHERE strftime(…, zeit) = … bzw. YEAR(zeit) = 2026 ist korrekt, aber nicht „sargable“: Der Index auf zeit bleibt ungenutzt (siehe EXPLAIN: SEARCH vs. SCAN).
  • Zeitzonen: Grenzen in der Zeitzone der Daten bilden. „Februar in Berlin“ beginnt um 23:00 UTC am 31. Januar.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: Indizes & EXPLAIN