Inhaltsverzeichnis16 Abschnitte
- TL;DR
- 1. SELECT in Production-Pipelines
- 2. Korrelierte Subqueries statt JOINs
- 3. Implizite Typ-Konvertierungen
- 4. NOT IN mit Nulls
- 5. Aggregieren vor Joinen
- 6. HAVING statt WHERE für Non-Aggregate-Filter
- 7. Subqueries überstrapazieren statt CTEs
- 8. Non-SARGable Predicates
- 9. NULL-Semantik in Aggregationen vergessen
- 10. Auf nicht-unique Keys joinen ohne Check
- Quick-Reference Cheat Sheet
- Eine Notiz zu Dialekten
- FAQ
- Fazit
- Weiterlesen
SQL Anti-Patterns: Häufige Fehler und wie du sie fixt
Eine Query, die läuft, ist nicht dasselbe wie eine Query, die korrekt, wartbar oder schnell ist. SQL-Anti-Patterns sind die wiederkehrenden Fehler, die Pipelines verlangsamen, falsche Ergebnisse liefern und zukünftige Engineers deine Arbeit löschen lassen wollen. Die meisten sind einfach zu vermeiden, sobald du sie kennst.
TL;DR
SELECT *in Pipelines ist eine tickende ZeitbombeNOT INmit Nulls liefert leere Ergebnisse —NOT EXISTSnutzen- Funktionen in WHERE-Klauseln machen Indizes unbrauchbar
HAVINGohne Aggregat-Filter ist ineffizient
1. SELECT * in Production-Pipelines
SELECT * fühlt sich praktisch an. In einer Pipeline ist es eine tickende Zeitbombe.
Das Problem: Wenn eine Source-Tabelle eine Spalte hinzufügt, nimmt dein Downstream-SELECT * sie automatisch mit — potenziell bricht es Transformationen, die ein festes Schema erwarten, bläht Storage auf oder gibt sensible Spalten ungewollt weiter.
-- Anti-Pattern (PostgreSQL)
CREATE TABLE fct_orders AS
SELECT *
FROM raw.orders
JOIN raw.customers USING (customer_id);
-- Fix: explizite Spaltenliste
CREATE TABLE fct_orders AS
SELECT
o.order_id,
o.customer_id,
o.order_total,
o.placed_at,
c.email,
c.country
FROM raw.orders o
JOIN raw.customers c USING (customer_id);
Ausnahme: SELECT * ist okay in Ad-hoc-Exploration. Nie in einem dbt-Model, View oder geplanten Pipeline.
2. Korrelierte Subqueries statt JOINs
Eine korrelierte Subquery läuft einmal pro Zeile der äußeren Query. Auf einer Tabelle mit 10 Mio Zeilen sind das 10 Mio Subquery-Executions.
-- Anti-Pattern (PostgreSQL) — korrelierte Subquery, O(n) Subquery-Executions
SELECT
customer_id,
total_spend,
(SELECT AVG(total_spend) FROM orders) AS avg_spend -- OK, unkorreliert
(SELECT MAX(placed_at) -- KORRELIERT — läuft pro Zeile
FROM orders o2
WHERE o2.customer_id = o1.customer_id) AS last_order_date
FROM orders o1;
-- Fix: Window-Function
SELECT
customer_id,
total_spend,
AVG(total_spend) OVER () AS avg_spend,
MAX(placed_at) OVER (PARTITION BY customer_id) AS last_order_date
FROM orders;
Wann korrelierte Subqueries okay: EXISTS-Checks mit Early-Termination oder LATERAL-Joins in PostgreSQL für komplexe Per-Row-Logic. Erst messen.
3. Implizite Typ-Konvertierungen
Wenn du eine String-Spalte mit einem Integer-Literal vergleichst, konvertiert die Datenbank still einen Typ in den anderen. Das deaktiviert oft Index-Nutzung und kann mit Null-Edge-Cases falsche Ergebnisse liefern.
-- Anti-Pattern (PostgreSQL) — implizite Konvertierung, Index auf status_code nicht genutzt
SELECT * FROM orders WHERE status_code = 1;
-- status_code ist VARCHAR — DB konvertiert 1 zu '1' und scannt die Tabelle
-- Fix: Typen matchen
SELECT * FROM orders WHERE status_code = '1';
-- Anti-Pattern — Date als String, falsche Ergebnisse in manchen Dialekten
SELECT * FROM events WHERE event_date > '2026-01-01';
-- Funktioniert in PostgreSQL, schlägt in manchen MySQL-Configs still fehl
-- Fix: expliziter Cast
SELECT * FROM events WHERE event_date > DATE '2026-01-01'; -- PostgreSQL/BigQuery
-- oder
SELECT * FROM events WHERE event_date > CAST('2026-01-01' AS DATE); -- universell
In Spark SQL ist implizite Type-Coercion besonders gefährlich — Spark versucht zwischen Typen zu casten und produziert null statt Fehler.
4. NOT IN mit Nulls
NOT IN mit einer Subquery, die NULL zurückgeben kann, ist eine der häufigsten Quellen stillen Datenverlusts in SQL. Das Ergebnis ist immer leer bei Nulls.
-- Anti-Pattern (PostgreSQL) — 0 Zeilen, wenn cancelled_orders ein NULL enthält
SELECT order_id
FROM orders
WHERE order_id NOT IN (SELECT order_id FROM cancelled_orders);
-- Wenn cancelled_orders auch nur ein NULL hat, liefert das nichts.
-- SQL-Logic: NOT IN expandiert zu AND order_id != val1 AND order_id != val2 ...
-- NULL-Vergleich ist UNKNOWN, also ist der Ausdruck UNKNOWN (nicht TRUE).
-- Fix: NOT EXISTS
SELECT o.order_id
FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM cancelled_orders co WHERE co.order_id = o.order_id
);
-- Alternative: LEFT JOIN / IS NULL Pattern
SELECT o.order_id
FROM orders o
LEFT JOIN cancelled_orders co ON o.order_id = co.order_id
WHERE co.order_id IS NULL;
Immer NOT EXISTS oder LEFT JOIN ... IS NULL nutzen, wenn du Zeilen basierend auf einer Subquery ausschließt, die Nulls enthalten könnte.
5. Aggregieren vor Joinen
Große Tabellen joinen und dann aggregieren ist langsamer als zuerst aggregieren und dann joinen — aber die Reihenfolge zählt für Korrektheit.
-- Anti-Pattern (Spark SQL) — volle Tabellen joinen, dann aggregieren (riesiger Shuffle)
SELECT
c.country,
SUM(o.total_cents) AS revenue
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
GROUP BY c.country;
-- Fix: Orders zuerst aggregieren, dann joinen
WITH order_agg AS (
SELECT customer_id, SUM(total_cents) AS total_revenue
FROM orders
GROUP BY customer_id
)
SELECT
c.country,
SUM(oa.total_revenue) AS revenue
FROM order_agg oa
JOIN customers c ON oa.customer_id = c.customer_id
GROUP BY c.country;
Der Fix reduziert Row-Count vor dem Join. In Spark senkt das Shuffle-Volumen drastisch. In Standard-RDBMS macht der Query-Planner das oft automatisch — aber nicht immer. Nicht annehmen.
6. HAVING statt WHERE für Non-Aggregate-Filter
WHERE filtert Zeilen vor Aggregation. HAVING filtert nachher. HAVING für Non-Aggregat-Prädikate zwingt die DB, alle Zeilen zu aggregieren und dann die meisten wegzuwerfen.
-- Anti-Pattern (PostgreSQL) — country-Filter NACH Aggregation
SELECT country, COUNT(*) AS customer_count
FROM customers
GROUP BY country
HAVING country = 'DE'; -- aggregiert alle Länder, dann Filter auf DE
-- Fix: vor Aggregation filtern
SELECT country, COUNT(*) AS customer_count
FROM customers
WHERE country = 'DE'
GROUP BY country;
HAVING ist korrekt, wenn das Prädikat ein Aggregat enthält (HAVING COUNT(*) > 100). Für plain Column-Filter immer WHERE.
7. Subqueries überstrapazieren statt CTEs
Tiefe Subquery-Verschachtelung ist unlesbar und unwartbar. Modernes SQL (PostgreSQL, BigQuery, Snowflake, DuckDB, Spark SQL) unterstützt CTEs — nutze sie.
-- Anti-Pattern — drei Level Subquery-Nesting
SELECT *
FROM (
SELECT customer_id, AVG(order_total) AS avg_order
FROM (
SELECT customer_id, order_id, SUM(line_total) AS order_total
FROM (
SELECT * FROM order_lines WHERE is_cancelled = FALSE
) active_lines
GROUP BY customer_id, order_id
) order_totals
GROUP BY customer_id
) customer_avgs
WHERE avg_order > 100;
-- Fix: CTEs für Lesbarkeit
-- DuckDB SQL
WITH active_lines AS (
SELECT customer_id, order_id, line_total
FROM order_lines
WHERE is_cancelled = FALSE
),
order_totals AS (
SELECT customer_id, order_id, SUM(line_total) AS order_total
FROM active_lines
GROUP BY customer_id, order_id
),
customer_avgs AS (
SELECT customer_id, AVG(order_total) AS avg_order
FROM order_totals
GROUP BY customer_id
)
SELECT *
FROM customer_avgs
WHERE avg_order > 100;
CTEs sind nicht nur Style. Sie sind testbar (dbt kann einzelne CTE-Layer referenzieren), in manchen Engines cachebar und für den nächsten Engineer verständlich.
8. Non-SARGable Predicates
Ein Prädikat ist SARGable (Search ARGument able), wenn die DB einen Index zur Evaluierung nutzen kann. Eine Funktion um eine Spalte zu wrappen bricht SARGability.
-- Anti-Pattern (PostgreSQL) — Funktion auf Spalte, Index auf placed_at nicht genutzt
SELECT * FROM orders WHERE YEAR(placed_at) = 2026;
SELECT * FROM orders WHERE DATE_TRUNC('month', placed_at) = '2026-01-01';
SELECT * FROM orders WHERE UPPER(email) = 'ALICE@EXAMPLE.COM';
-- Fix: Transform aufs Literal pushen, nicht auf die Spalte
SELECT * FROM orders
WHERE placed_at >= '2026-01-01' AND placed_at < '2027-01-01';
SELECT * FROM orders
WHERE placed_at >= '2026-01-01' AND placed_at < '2026-02-01';
SELECT * FROM orders WHERE email = LOWER('ALICE@EXAMPLE.COM');
-- oder besser: zur Write-Zeit lowercasen, lowercase-Spalte abfragen
9. NULL-Semantik in Aggregationen vergessen
COUNT(*) zählt alle Zeilen inklusive Nulls. COUNT(column) zählt Non-NULL-Werte. AVG(column) ignoriert Nulls (was du meist willst, aber manchmal nicht).
-- Misleading: COUNT(*) vs COUNT(column) Confusion (PostgreSQL)
SELECT
COUNT(*) AS total_rows, -- 1000 (inkl. NULLs)
COUNT(email) AS with_email, -- 800 (NULLs raus)
AVG(order_total) AS avg_total -- average IGNORIERT null order_total Zeilen
FROM orders;
-- Fix: explizit zum NULL-Handling
SELECT
COUNT(*) AS total_rows,
COUNT(email) AS rows_with_email,
COUNT(*) FILTER (WHERE email IS NULL) AS rows_without_email, -- PostgreSQL-Syntax
AVG(COALESCE(order_total, 0)) AS avg_total_incl_nulls_as_zero
FROM orders;
10. Auf nicht-unique Keys joinen ohne Check
Wenn du zwei Tabellen auf einem Key joinst, der in beiden nicht unique ist, erzeugst du still ein kartesisches Produkt für matchende Zeilen. Umsatz-Zahlen explodieren. Duplikate überall.
-- Anti-Pattern — orders.customer_id ist unique, aber promotions.customer_id NICHT
-- Das multipliziert Zeilen still
SELECT o.order_id, o.total, p.promo_code
FROM orders o
JOIN promotions p ON o.customer_id = p.customer_id;
-- Hat ein Kunde 3 Promotions, erscheint jede Order 3x
-- Fix: Uniqueness vor Join validieren
SELECT customer_id, COUNT(*) FROM promotions GROUP BY 1 HAVING COUNT(*) > 1;
-- Dann richtige Join-Form nutzen (z.B. nur neueste Promo):
WITH latest_promo AS (
SELECT customer_id, promo_code,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM promotions
)
SELECT o.order_id, o.total, lp.promo_code
FROM orders o
LEFT JOIN latest_promo lp ON o.customer_id = lp.customer_id AND lp.rn = 1;
Siehe Data Quality Testing für automatisierte Checks, die Duplicate-Join-Keys vor Production fangen.
Quick-Reference Cheat Sheet
| Anti-Pattern | Quick Fix |
|---|---|
SELECT * in Pipelines | Explizite Spaltenliste |
| Korrelierte Subquery | Window-Function oder pre-aggregierter JOIN |
| Impliziter Type-Cast | Typen matchen oder expliziter CAST |
NOT IN mit Nulls | NOT EXISTS nutzen |
| HAVING für Non-Aggregat | Move zu WHERE |
| Nested Subqueries | Refactor zu CTEs |
| Funktion auf Spalte in WHERE | Transform aufs Literal pushen |
| NULL-Semantik ignorieren | COUNT(col), COALESCE, FILTER |
| Fan-out Join | Key-Uniqueness vor Join validieren |
Eine Notiz zu Dialekten
SQL ist nicht eine Sprache — es ist eine Familie von Dialekten. DATE_TRUNC ist PostgreSQL- und BigQuery-Syntax. Spark SQL nutzt TRUNC(date, 'MONTH'). FILTER (WHERE ...) ist PostgreSQL; BigQuery und Spark nutzen COUNTIF. Immer Dialekt angeben, wenn du SQL teilst, und immer auf der Ziel-Engine testen.
Wenn du SQL über verschiedene Datenquellen erkunden und validieren willst, ohne Infrastruktur aufzubauen, läuft Harbinger Explorer DuckDB SQL direkt im Browser. Nützlich, um Query-Logic auf CSVs oder API-Daten zu testen, bevor du sie ins Production-Warehouse portierst.
FAQ
Was ist der wichtigste Anti-Pattern, den ich heute fixen sollte? NOT IN mit Nulls. Es liefert still leere Ergebnisse und keiner merkt es, bis Reports falsch sind.
Sind alle SARGable-Issues relevant in modernen Engines? Bei Cloud-Warehouses (Snowflake, BigQuery) mit columnar Storage weniger relevant als bei klassischen RDBMS. Aber Best Practice bleibt.
dbt hilft beim Vermeiden? Ja. dbt fördert CTEs, explizite Spalten und Tests. Aber die Anti-Patterns oben kann auch dbt nicht alle automatisch fangen.
Welcher Linter empfiehlt sich? SQLFluff für allgemeines Linting, dbt-Linter für dbt-Projekte. Beide fangen die häufigsten Issues.
DACH-Sonderfall? Postgres ist in DACH-Mittelstand weit verbreitet. Die Patterns oben gelten alle, mit besonderem Fokus auf NULL-Handling und Type-Coercion.
Fazit
Die meisten SQL-Bugs sind unsichtbar — sie werfen keine Fehler, sie liefern nur falsche Zahlen. Die Anti-Patterns oben sind besonders gefährlich, weil sie funktionieren, bis sie es nicht tun: die falsche Row-Count zählt erst, wenn jemand die Zahlen wirklich prüft.
Nächster Schritt: Eine Query aus deiner aktuellen Pipeline durch diese Checkliste schicken. NOT IN / NULL und Fan-out-Join-Patterns sind die wahrscheinlichsten Bugs in bestehendem Code.
Weiterlesen
Stand: 14. Mai 2026.
Geschrieben von
Harbinger Team
Cloud-, Data- und AI-Engineer in DACH. Schreibt seit 2018 über infrastrukturkritische Tech-Entscheidungen — keine Marketing- Folien, sondern echte Trade-offs aus Production-Workloads.
Hat dir das geholfen?
Jede Woche ein neuer Artikel über DACH-Cloud, Data und AI — direkt in dein Postfach. Kein Spam, kein Marketing-Sprech.
Kein Spam. 1-Klick-Abmeldung. Datenschutz bei Loops.so.