Cloud allgemein

SQL Anti-Patterns: Häufige Fehler und Fixes (2026)

Die 10 häufigsten SQL-Anti-Patterns, die Pipelines verlangsamen und falsche Ergebnisse liefern — mit Fixes und Dialekt-Hinweisen.

Harbinger Team14. Mai 20269 Min. LesezeitAktualisiert 14.5.2026
  • sql
  • data-engineering
  • sql-anti-patterns
  • query-optimization
  • data-quality
  • best-practices
  • dach
Inhaltsverzeichnis16 Abschnitte

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 Zeitbombe
  • NOT IN mit Nulls liefert leere Ergebnisse — NOT EXISTS nutzen
  • Funktionen in WHERE-Klauseln machen Indizes unbrauchbar
  • HAVING ohne 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-PatternQuick Fix
SELECT * in PipelinesExplizite Spaltenliste
Korrelierte SubqueryWindow-Function oder pre-aggregierter JOIN
Impliziter Type-CastTypen matchen oder expliziter CAST
NOT IN mit NullsNOT EXISTS nutzen
HAVING für Non-AggregatMove zu WHERE
Nested SubqueriesRefactor zu CTEs
Funktion auf Spalte in WHERETransform aufs Literal pushen
NULL-Semantik ignorierenCOUNT(col), COALESCE, FILTER
Fan-out JoinKey-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.

H

Geschrieben von

Harbinger Team

Cloud-, Data- und AI-Engineer in DACH. Schreibt seit 2018 über infrastruktur­kritische 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.