SELECT & Query-Grundlagen
SELECT, WHERE & ORDER BY
SELECT ruft Zeilen aus einer oder mehreren Tabellen ab. Spalten immer explizit angeben statt * für Performance und Klarheit (Schema-Änderungen brechen die App nicht). WHERE filtert Zeilen vor der Gruppierung. ORDER BY sortiert Ergebnisse (ASC Default, DESC absteigend). LIMIT/OFFSET implementieren Paginierung — für große Datensätze Keyset-Pagination bevorzugen (WHERE id > last_id).
-- basic query: select specific columns
SELECT id, name, email
FROM users
WHERE age >= 18 AND status = 'active'
ORDER BY name ASC, created_at DESC
LIMIT 10 OFFSET 0;
-- select all columns (avoid in production)
SELECT * FROM products;
-- column aliases with AS
SELECT name AS product_name, price * 1.1 AS price_with_tax
FROM products;DISTINCT & Aliases
DISTINCT entfernt doppelte Zeilen aus dem Resultat. Es operiert auf der gesamten Zeile, nicht einzelnen Spalten — SELECT DISTINCT city, country gibt eindeutige Stadt+Land-Paare zurück. Tabellen-Aliase (u, o) verkürzen Queries und sind erforderlich, wenn eine Tabelle mit sich selbst gejoint wird. Spalten-Aliase benennen Ausgabespalten für Lesbarkeit um.
-- unique values only
SELECT DISTINCT country FROM users;
SELECT DISTINCT city, country FROM users; -- unique combos
-- table aliases (essential for joins)
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id;
-- column alias (AS is optional)
SELECT name product_name, COUNT(*) count
FROM products
GROUP BY name;Filtern: BETWEEN, IN, IS NULL
BETWEEN ist an beiden Enden inklusiv. IN matcht jeden Wert in einer Liste oder Subquery. NULL erfordert IS NULL / IS NOT NULL (kann nicht = NULL verwenden). Vorsicht mit NOT IN und Subqueries — wenn die Subquery NULL zurückgibt, gibt NOT IN gar keine Zeilen zurück. Stattdessen NOT EXISTS verwenden, das NULLs korrekt behandelt und oft schneller ist.
SELECT * FROM products
WHERE price BETWEEN 10 AND 100 -- inclusive range
AND category IN ('tech', 'home') -- match any value
AND stock IS NOT NULL -- exclude NULLs
AND discount IS NULL; -- only NULLs
-- NOT IN with NULL caveat: returns nothing if subquery has NULL!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders); -- risky if NULLs
-- safer: NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);LIKE & Pattern Matching
LIKE verwendet % (null oder mehr Zeichen) und _ (genau ein Zeichen) als Wildcards. LIKE ist in den meisten Datenbanken Case-sensitiv außer MySQL (standardmäßig Case-insensitiv). ILIKE in PostgreSQL für Case-insensitives Matching verwenden. Für komplexe Patterns Regex verwenden (~ in PostgreSQL, REGEXP in MySQL). LIKE mit führendem % kann keine Indexes verwenden — für Performance Full-Text-Search in Betracht ziehen.
-- LIKE: basic pattern matching
SELECT * FROM users WHERE name LIKE 'A%'; -- starts with A
SELECT * FROM users WHERE name LIKE '%son'; -- ends with son
SELECT * FROM users WHERE name LIKE '%a%'; -- contains a
SELECT * FROM users WHERE name LIKE '_a%'; -- second char is a
-- ILIKE (PostgreSQL): case-insensitive
SELECT * FROM users WHERE name ILIKE 'a%';
-- SIMILAR TO (PostgreSQL): regex-like
SELECT * FROM users WHERE name SIMILAR TO '[AB]%';
-- full regex (PostgreSQL)
SELECT * FROM users WHERE name ~ '^A[a-z]+$';CASE-Ausdrücke
CASE ist SQLs If-Then-Else, pro Zeile ausgewertet. Es kann in SELECT, WHERE, ORDER BY und HAVING erscheinen. Das 'Pivot'-Pattern (SUM of CASE) transformiert Zeilen in Spalten — nützlich für Reporting. CASE gibt NULL zurück, wenn kein WHEN passt und es kein ELSE gibt. Immer ELSE für vorhersagbare Ergebnisse inkludieren.
-- conditional logic in queries
SELECT
name,
price,
CASE
WHEN price < 10 THEN 'cheap'
WHEN price < 50 THEN 'moderate'
WHEN price < 100 THEN 'expensive'
ELSE 'luxury'
END AS price_category
FROM products;
-- CASE in aggregation (pivot table)
SELECT
category,
COUNT(*) AS total,
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count,
SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) AS inactive_count
FROM products
GROUP BY category;JOINs
INNER JOIN
INNER JOIN gibt nur Zeilen zurück, die in beiden Tabellen Matches haben. JOIN ist eine Abkürzung für INNER JOIN. Für Multi-Tabellen-Queries Tabellen Schritt für Schritt joinen. ON gibt die Join-Bedingung an; USING(column) ist eine Abkürzung, wenn beide Tabellen dieselbe Spalte haben. Inner Joins schließen nicht-matchende Zeilen von beiden Seiten aus.
-- only matching rows from both tables
SELECT u.name, o.total, o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 100
ORDER BY o.total DESC;
-- multiple joins
SELECT u.name, o.total, p.product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;
-- USING (when column names match)
SELECT * FROM users
JOIN profiles ON users.id = profiles.user_id;LEFT JOIN (LEFT OUTER JOIN)
LEFT JOIN gibt ALLE Zeilen der linken Tabelle zurück, mit NULLs für nicht-matchende rechte Zeilen. Essenziell für 'alles inkludieren'-Queries. Das Anti-Join-Pattern (WHERE right.id IS NULL) findet Zeilen in der linken Tabelle ohne Match in der rechten — nützlich für 'Nutzer, die nicht bestellt haben'. COUNT(right.id) zählt nicht-NULL Werte, sodass es 0 für Nutzer ohne Bestellungen zurückgibt.
-- all users, with their orders (NULL if no orders)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.name;
-- find users with NO orders (anti-join pattern)
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
-- count orders per user (including zero)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;RIGHT & FULL OUTER JOIN
RIGHT JOIN gibt alle rechte-Tabelle-Zeilen zurück; es ist äquivalent zum Tauschen der Tabellen und Verwenden von LEFT JOIN (was lesbarer ist). FULL OUTER JOIN gibt alle Zeilen beider Tabellen zurück, mit NULLs wo es kein Match gibt — nützlich für Datenabgleich. MySQL unterstützt FULL OUTER JOIN nicht direkt; mit LEFT JOIN UNION RIGHT JOIN emulieren.
-- RIGHT JOIN: all rows from right table
SELECT u.name, o.total
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- returns all orders, even orphaned ones (user_id = NULL)
-- FULL OUTER JOIN: all rows from both tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- returns all users AND all orders, matching where possible
-- Note: RIGHT JOIN is rarely used (just swap tables and use LEFT)
-- FULL OUTER JOIN is useful for finding mismatches between tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
WHERE u.id IS NULL OR o.user_id IS NULL;CROSS JOIN & Self Join
CROSS JOIN produziert ein kartesisches Produkt — jede Zeile von A gepaart mit jeder Zeile von B. Für das Generieren von Kombinationen verwenden (Größen × Farben). Self Joins (eine Tabelle mit sich selbst joinen) sind häufig für hierarchische Daten (Mitarbeiter-Manager), das Finden von Duplikaten oder das Vergleichen von Zeilen innerhalb derselben Tabelle. In Self Joins immer Tabellen-Aliase verwenden, um die beiden 'Kopien' zu unterscheiden.
-- CROSS JOIN: Cartesian product (every combination)
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
-- produces all size+color combinations
-- Self join: join a table to itself
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- self join for hierarchical data
SELECT c.name AS child, p.name AS parent
FROM categories c
JOIN categories p ON c.parent_id = p.id;
-- self join to find duplicates
SELECT a.id, a.email, b.id AS dup_id
FROM users a
JOIN users b ON a.email = b.email AND a.id < b.id;NATURAL JOIN & JOIN-Typen-Zusammenfassung
NATURAL JOIN joint automatisch auf Spalten mit demselben Namen — praktisch, aber gefährlich, da Schema-Änderungen das Join-Verhalten unbemerkt ändern können. In Produktion vermeiden. LATERAL Joins erlauben einer Subquery, auf Spalten der äußeren Query zu referenzieren — mächtig für 'Top N pro Gruppe'-Queries. Die Komma-Syntax (FROM a, b) ist äquivalent zu CROSS JOIN.
-- NATURAL JOIN: joins on all matching column names
-- (rarely recommended — implicit, fragile)
SELECT * FROM users NATURAL JOIN profiles;
-- joins on any column that exists in BOTH tables
-- Summary of join types:
-- INNER JOIN : matching rows only
-- LEFT JOIN : all left + matching right
-- RIGHT JOIN : all right + matching left
-- FULL JOIN : all from both sides
-- CROSS JOIN : Cartesian product
-- SELF JOIN : table joined to itself
-- LATERAL JOIN (PostgreSQL): subquery can reference outer query
SELECT u.name, recent.*
FROM users u,
LATERAL (
SELECT * FROM orders o
WHERE o.user_id = u.id
ORDER BY o.created_at DESC
LIMIT 3
) recent;GROUP BY & Aggregation
GROUP BY & HAVING
GROUP BY kollabiert Zeilen in Gruppen, eine Zeile pro Gruppe. Aggregatfunktionen (COUNT, SUM, AVG, MIN, MAX) operieren auf jeder Gruppe. WHERE filtert einzelne Zeilen VOR der Gruppierung; HAVING filtert Gruppen NACH der Aggregation. Nicht-aggregierte Spalten in SELECT müssen in GROUP BY erscheinen (Standard-SQL). MySQL ist nachsichtig, aber unvorhersehbar — immer alle nicht-aggregierten Spalten in GROUP BY aufnehmen.
-- aggregate per group
SELECT category, COUNT(*) AS cnt, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 5
ORDER BY cnt DESC;
-- HAVING filters groups (after aggregation)
-- WHERE filters rows (before aggregation)
SELECT dept, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active' -- filter rows first
GROUP BY dept
HAVING AVG(salary) > 50000; -- then filter groupsAggregatfunktionen
COUNT(*) zählt alle Zeilen inklusive NULLs; COUNT(column) zählt nur nicht-NULL Werte. COUNT(DISTINCT col) zählt eindeutige Werte. SUM/AVG ignorieren NULLs. AVG = SUM/COUNT(nicht-NULL), daher beeinflussen NULLs den Durchschnitt. STRING_AGG (PostgreSQL) / GROUP_CONCAT (MySQL) verkettet Strings pro Gruppe. BOOL_OR/BOOL_AND geben true zurück, wenn irgendein/alle Werte true sind.
SELECT
COUNT(*) AS total_rows, -- counts all rows
COUNT(email) AS emails_filled, -- counts non-NULL emails
COUNT(DISTINCT country) AS countries,
SUM(amount) AS total_revenue,
AVG(amount) AS avg_order,
MIN(amount) AS smallest_order,
MAX(amount) AS largest_order,
-- string aggregation (PostgreSQL)
STRING_AGG(name, ', ') AS all_names,
-- boolean aggregation
BOOL_OR(is_active) AS any_active,
BOOL_AND(is_active) AS all_active
FROM orders;GROUP BY mehrere Spalten
Gruppierung nach mehreren Spalten erstellt eine Hierarchie von Gruppen. WITH ROLLUP fügt Zwischensummen- und Gesamtsummen-Zeilen hinzu (NULL in der gruppierten Spalte). GROUPING SETS lassen dich genau angeben, welche Gruppierungs-Kombinationen du willst — flexibler als ROLLUP. CUBE generiert alle möglichen Gruppierungs-Kombinationen. Essenziell für Reporting und OLAP-Queries.
-- multi-level grouping
SELECT
EXTRACT(YEAR FROM created_at) AS yr,
EXTRACT(MONTH FROM created_at) AS mon,
category,
COUNT(*) AS cnt,
SUM(total) AS revenue
FROM orders
GROUP BY yr, mon, category
ORDER BY yr DESC, mon DESC, cnt DESC;
-- GROUP BY with ROLLUP (subtotals + grand total)
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category WITH ROLLUP;
-- last row has NULL category = grand total
-- GROUPING SETS (PostgreSQL): specify multiple groupings
SELECT category, status, COUNT(*)
FROM products
GROUP BY GROUPING SETS ((category, status), (category), ());HAVING vs. WHERE
Die Schlüsselunterscheidung: WHERE filtert einzelne Zeilen vor der Aggregation (kann SUM, COUNT usw. nicht verwenden), während HAVING Gruppen nach der Aggregation filtert (kann Aggregatfunktionen verwenden). WHERE verwenden, um Daten früh zu reduzieren (bessere Performance), dann HAVING, um die aggregierten Ergebnisse zu filtern. Beide können in derselben Query erscheinen — WHERE zuerst, dann GROUP BY, dann HAVING.
-- WHERE: filters rows BEFORE grouping
-- Cannot use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10 -- OK: filter on raw column
GROUP BY category;
-- HAVING: filters groups AFTER grouping
-- Can use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) > 5 -- OK: filter on aggregate
AND AVG(price) > 20; -- OK: multiple aggregate filters
-- combining both
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10 -- filter rows
GROUP BY category -- group
HAVING COUNT(*) > 5; -- filter groupsDatum/Zeit-Aggregation
Date Truncation ist essenziell für Zeitreihen-Reporting. DATE(col) extrahiert nur das Datum; EXTRACT/TIME_PART bekommt spezifische Komponenten (Jahr, Monat, Stunde). TO_CHAR formatiert Daten für Gruppierung und Anzeige. Für Zeitreihen-Analyse DATE_TRUNC('month', col) in Betracht ziehen, das den Timestamp-Typ beibehält. Datumsspalten für Performance auf großen Tabellen indizieren.
-- group by date parts
SELECT
DATE(created_at) AS order_date,
COUNT(*) AS orders,
SUM(total) AS revenue
FROM orders
GROUP BY DATE(created_at)
ORDER BY order_date DESC;
-- group by hour
SELECT
EXTRACT(HOUR FROM created_at) AS hr,
COUNT(*) AS cnt
FROM orders
WHERE created_at >= CURRENT_DATE
GROUP BY hr
ORDER BY hr;
-- monthly revenue trend
SELECT
TO_CHAR(created_at, 'YYYY-MM') AS month,
SUM(total) AS revenue,
COUNT(*) AS orders,
AVG(total) AS avg_order
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY month
ORDER BY month;Subqueries & CTEs
Skalare & Spalten-Subqueries
Skalare Subqueries geben einen einzelnen Wert zurück und können überall verwendet werden, wo ein Wert erwartet wird. Spalten-Subqueries geben eine Spalte zurück und werden mit IN, ANY, ALL verwendet. Subqueries in SELECT (korreliert) führen einmal pro äußerer Zeile aus — können auf großen Datensätzen langsam sein. Als JOIN mit GROUP BY für bessere Performance umschreiben in Betracht ziehen.
-- scalar subquery (returns single value)
SELECT name, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);
-- column subquery (returns one column, multiple rows)
SELECT name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100);
-- subquery in SELECT
SELECT
u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;Korrelierte Subqueries & EXISTS
Korrelierte Subqueries referenzieren die äußere Query und führen einmal pro äußerer Zeile aus — potenziell langsam. EXISTS/NOT EXISTS sind effizient, weil sie kurzschließen (beim ersten Match stoppen). NOT EXISTS ist der bevorzugte Weg, um 'Zeilen ohne matchende Zeilen' zu finden — es behandelt NULLs korrekt und ist oft schneller als NOT IN. Die Datenbank kann korrelierte Subqueries in Joins optimieren.
-- correlated: subquery references outer query
SELECT u.name
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.total > 1000
);
-- NOT EXISTS: users without any orders
SELECT u.name
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- correlated subquery in SELECT (runs per row)
SELECT
u.name,
(SELECT MAX(o.total)
FROM orders o
WHERE o.user_id = u.id) AS max_order
FROM users u;Common Table Expressions (CTE)
CTEs (WITH-Klausel) erstellen benannte temporäre Result-Mengen, die komplexe Queries lesbar machen. Anders als Subqueries können CTEs mehrfach referenziert werden und von oben nach unten gelesen werden. In den meisten Datenbanken werden CTEs inlined (Optimierung passiert auf Query-Level). PostgreSQL 12+ unterstützt MATERIALIZED/NOT MATERIALIZED-Hinweise. CTEs sind auch für rekursive Queries erforderlich.
-- CTE: named temporary result set
WITH active_users AS (
SELECT id, name FROM users WHERE status = 'active'
),
user_orders AS (
SELECT user_id, COUNT(*) AS cnt, SUM(total) AS revenue
FROM orders
GROUP BY user_id
)
SELECT au.name, uo.cnt, uo.revenue
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY uo.revenue DESC NULLS LAST;
-- CTEs improve readability for complex queries
-- They are NOT materialized (just syntactic sugar) in most DBs
-- (PostgreSQL 12+ can materialize with MATERIALIZED keyword)Recursive CTE
Rekursive CTEs referenzieren sich selbst und ermöglichen Baum-/Graph-Traversierung und Sequenz-Generierung. Struktur: Base Case UNION ALL rekursiver Case. Der rekursive Case referenziert die CTE und muss terminieren (WHERE hinzufügen, um Endlosschleifen zu verhindern). Häufige Verwendungen: Org-Charts, Kategorie-Bäume, Abhängigkeits-Graphen, Datumsssequenzen. Jede Datenbank hat leicht unterschiedliche Syntax — DBMS-Doku prüfen.
-- hierarchical data: org chart
WITH RECURSIVE org_tree AS (
-- base case: top-level managers
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- recursive case: direct reports
SELECT e.id, e.name, e.manager_id, ot.level + 1
FROM employees e
JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT level, name FROM org_tree ORDER BY level, name;
-- generate a series of dates
WITH RECURSIVE dates AS (
SELECT DATE '2024-01-01' AS d
UNION ALL
SELECT d + 1 FROM dates WHERE d < '2024-01-31'
)
SELECT d FROM dates;
-- factorial
WITH RECURSIVE fact(n, result) AS (
SELECT 1, 1
UNION ALL
SELECT n + 1, result * (n + 1) FROM fact WHERE n < 10
)
SELECT * FROM fact;Subquery-Operatoren: ANY, ALL
ANY und ALL vergleichen einen Wert gegen ein Subquery-Resultat. > ANY bedeutet 'größer als mindestens einer'. > ALL bedeutet 'größer als jeder'. = ANY ist äquivalent zu IN. <> ALL ist äquivalent zu NOT IN, behandelt aber NULLs sicherer. Diese Operatoren werden seltener verwendet als IN/EXISTS, können aber bestimmte Queries natürlicher ausdrücken.
-- ANY: greater than ANY of the values (= at least one)
SELECT * FROM products
WHERE price > ANY (
SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds at least one tech product's price
-- ALL: greater than ALL values (= every one)
SELECT * FROM products
WHERE price > ALL (
SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds every tech product's price
-- = ANY is equivalent to IN
SELECT * FROM users
WHERE id = ANY (SELECT user_id FROM orders);
-- <> ALL is equivalent to NOT IN (but NULL-safe)
SELECT * FROM users
WHERE id <> ALL (SELECT user_id FROM orders WHERE total < 0);Window Functions
ROW_NUMBER, RANK, DENSE_RANK
ROW_NUMBER weist eindeutige sequenzielle Nummern zu (1, 2, 3...). RANK gibt bei Gleichstand denselben Rang, überspringt aber folgende Nummern (1, 1, 3). DENSE_RANK gibt bei Gleichstand denselben Rang ohne Überspringen (1, 1, 2). PARTITION BY teilt Zeilen in Gruppen; die Funktion setzt pro Partition zurück. Das 'Top N pro Gruppe'-Pattern (ROW_NUMBER + WHERE rn <= N) ist in Analytics extrem häufig.
SELECT
name,
salary,
dept,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
RANK() OVER (ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;
-- top 3 earners per department
SELECT * FROM (
SELECT
name,
dept,
salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
) ranked
WHERE rn <= 3;LAG & LEAD
LAG greift auf den Wert einer vorherigen Zeile zu; LEAD auf den einer zukünftigen Zeile. Beide akzeptieren einen optionalen Offset (Default 1) und Default-Wert (Default NULL). Essenziell für Zeitreihen-Analyse: Tag-zu-Tag-Änderungen, Moving-Vergleiche, Gap-Erkennung. NULLIF verhindert Division durch null bei Prozentberechnungen. Immer ORDER BY in der OVER-Klausel für deterministische Ergebnisse angeben.
-- compare each row to previous/next
SELECT
date,
revenue,
LAG(revenue) OVER (ORDER BY date) AS prev_day,
LEAD(revenue) OVER (ORDER BY date) AS next_day,
revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY date)) * 100.0
/ NULLIF(LAG(revenue) OVER (ORDER BY date), 0),
2
) AS pct_change
FROM daily_sales
ORDER BY date;
-- LAG with offset and default
SELECT
date,
revenue,
LAG(revenue, 7) OVER (ORDER BY date) AS revenue_7_days_ago
FROM daily_sales;Laufende Summen & Moving Averages
Window-Frames definieren, auf welchen Zeilen die Funktion operiert. ROWS BETWEEN verwendet physische Zeilen-Offsets; RANGE verwendet logische Wert-Bereiche (besser für Datumslücken). UNBOUNDED PRECEDING bedeutet 'von Anfang an'. Laufende Summen (kumulative SUM) und Moving Averages sind die häufigsten analytischen Patterns. Ohne Frame verwenden aggregierte Window-Funktionen den Default: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
-- cumulative sum (running total)
SELECT
date,
revenue,
SUM(revenue) OVER (ORDER BY date) AS running_total,
SUM(revenue) OVER (ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS last_7_days_sum,
AVG(revenue) OVER (ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales
ORDER BY date;
-- window frame options:
-- ROWS BETWEEN n PRECEDING AND n FOLLOWING -- physical rows
-- RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW -- logical range
-- ROWS UNBOUNDED PRECEDING = from start to current
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING = all rowsNTILE & PERCENT_RANK
NTILE(n) teilt geordnete Zeilen in n ungefähr gleich große Gruppen (Quartile, Dezile, Perzentile). PERCENT_RANK gibt den relativen Rang (0 bis 1). CUME_DIST gibt die kumulative Verteilung. FIRST_VALUE/LAST_VALUE geben Werte aus der ersten/letzten Zeile im Frame zurück — beachte, dass LAST_VALUE einen expliziten Frame braucht (UNBOUNDED FOLLOWING), weil der Default-Frame an der aktuellen Zeile endet.
-- divide into quartiles
SELECT
name,
salary,
NTILE(4) OVER (ORDER BY salary DESC) AS quartile,
PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees;
-- first/last value in a partition
SELECT
dept,
name,
salary,
FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner,
LAST_VALUE(name) OVER (
PARTITION BY dept ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS lowest_earner
FROM employees;Window-Aggregatfunktionen
Window-Aggregate (SUM, AVG, COUNT usw. mit OVER) berechnen aggregierte Werte OHNE Zeilen zu kollabieren — jede Zeile bekommt das Aggregat angehängt. Das ist der Schlüsselunterschied zu GROUP BY: man behält alle Detail-Zeilen und sieht auch die Zusammenfassung. Perfekt für den Vergleich individueller Werte mit Gruppen-Durchschnitten, Berechnen von Prozenten und Hinzufügen von Kontext-Spalten zu Detail-Berichten.
-- aggregates over windows (no row collapse!)
SELECT
name,
dept,
salary,
-- compare to department average
AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY dept) AS diff_from_avg,
-- percentage of department total
salary * 100.0 / SUM(salary) OVER (PARTITION BY dept) AS pct_of_dept,
-- count per department
COUNT(*) OVER (PARTITION BY dept) AS dept_size
FROM employees
ORDER BY dept, salary DESC;
-- key advantage: aggregates without GROUP BY
-- every row is preserved, with the aggregate value attachedDDL: Tabellen & Schema
CREATE TABLE & Datentypen
CREATE TABLE definiert das Schema. SERIAL (PostgreSQL) / AUTO_INCREMENT (MySQL) auto-generiert IDs. VARCHAR(n) hat ein Limit; TEXT ist unbegrenzt. DECIMAL(p,s) ist exakt (für Geld verwenden!), FLOAT ist approximativ. CHECK-Constraints erzwingen Business-Regeln. DEFAULT stellt Werte bereit, wenn nicht angegeben. JSONB (PostgreSQL) ermöglicht indizierte JSON-Queries. Immer TIMESTAMP WITH TIME ZONE für Zeitstempel verwenden, die Zeitzonen überspannen.
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- auto-increment (PostgreSQL)
-- MySQL: id INT AUTO_INCREMENT PRIMARY KEY
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
age INT CHECK (age >= 0 AND age <= 150),
salary DECIMAL(10, 2) DEFAULT 0.00, -- precision, scale
bio TEXT, -- unlimited length
avatar BYTEA, -- binary (PostgreSQL)
metadata JSONB, -- JSON (PostgreSQL)
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- common types:
-- INT, BIGINT, SMALLINT, DECIMAL(p,s), NUMERIC
-- VARCHAR(n), CHAR(n), TEXT
-- DATE, TIME, TIMESTAMP, INTERVAL
-- BOOLEAN, UUID, JSON/JSONB, BYTEA/BLOBConstraints: PRIMARY, FOREIGN, UNIQUE, CHECK
Constraints erzwingen Datenintegrität auf Datenbank-Level. PRIMARY KEY identifiziert Zeilen eindeutig (impliziert NOT NULL + UNIQUE). FOREIGN KEY wahrt referenzielle Integrität — ON DELETE CASCADE entfernt Kinder, wenn Eltern gelöscht werden. UNIQUE verhindert Duplikate. CHECK erzwingt benutzerdefinierte Regeln. Definieren von Constraints in der Datenbank (nicht nur App-Code) stellt Integrität unabhängig vom Datenzugriff sicher.
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total DECIMAL(10,2) NOT NULL CHECK (total >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- table-level constraints
CONSTRAINT valid_status CHECK (status IN ('pending','paid','shipped','cancelled')),
CONSTRAINT unique_user_order UNIQUE (user_id, created_at)
);
-- foreign key actions:
-- ON DELETE CASCADE : delete child rows when parent deleted
-- ON DELETE SET NULL : set FK to NULL (column must be nullable)
-- ON DELETE RESTRICT : prevent parent deletion (default)
-- ON UPDATE CASCADE : update FK when parent PK changes
-- add constraint to existing table
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);ALTER TABLE
ALTER TABLE modifiziert eine bestehende Tabellenstruktur. Hinzufügen von Spalten mit Defaults ist meist schnell (PostgreSQL 11+ schreibt die Tabelle nicht um). Droppen von Spalten kann die Tabelle sperren. Ändern von Spaltentypen kann einen vollständigen Tabellen-Umschrieb erfordern und fehlschlagen, wenn Daten nicht konvertieren. Schema-Migrationen immer zuerst an einer Kopie testen. Migrations-Tools (Flyway, Alembic, Rails migrations) für versionierte Schema-Änderungen verwenden.
-- add column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN verified BOOLEAN DEFAULT false;
-- drop column
ALTER TABLE users DROP COLUMN avatar;
-- rename column/table
ALTER TABLE users RENAME COLUMN username TO login;
ALTER TABLE users RENAME TO accounts;
-- change column type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
-- MySQL: ALTER TABLE users MODIFY COLUMN age BIGINT;
-- add/drop constraints
ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email);
ALTER TABLE users DROP CONSTRAINT email_unique;
-- set default
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';DROP, TRUNCATE & Indexes
DROP TABLE entfernt die Tabelle vollständig; TRUNCATE leert sie, behält aber die Struktur (viel schneller als DELETE, setzt Identity zurück). Indexes beschleunigen Queries, verlangsamen aber Writes — strategisch indizieren. Composite Indexes arbeiten von links nach rechts: idx(a,b,c) hilft WHERE a=?, WHERE a=? AND b=?, aber NICHT WHERE b=?. GIN-Indexes ermöglichen Full-Text-Search. Partial Indexes sparen Platz, indem nur matchende Zeilen indiziert werden.
-- DROP: permanently remove table (structure + data)
DROP TABLE IF EXISTS old_logs CASCADE;
-- CASCADE drops dependent objects (views, FKs)
-- TRUNCATE: remove all data, keep structure (faster than DELETE)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY; -- reset SERIAL counter
TRUNCATE TABLE orders, order_items CASCADE; -- multiple tables
-- CREATE INDEX for query performance
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_username ON users(username);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
CREATE INDEX idx_products_name ON products USING gin(to_tsvector('english', name));
-- partial index (PostgreSQL)
CREATE INDEX idx_active_users ON users(last_login)
WHERE status = 'active';
-- DROP INDEX
DROP INDEX IF EXISTS idx_users_email;Views & Materialized Views
Views sind gespeicherte Queries, die als virtuelle Tabellen fungieren — sie führen die zugrunde liegende Query jedes Mal aus. Views verwenden, um komplexe Queries zu vereinfachen, Sicherheit zu erzwingen (Spalten-Level-Zugriff) und stabile APIs bereitzustellen. Materialized Views speichern die tatsächlichen Ergebnisse — schneller abzufragen, müssen aber aktualisiert werden. Materialized Views für teure Aggregationen verwenden, die keine Echtzeit-Daten brauchen. CONCURRENTLY aktualisiert ohne Sperren (PostgreSQL).
-- VIEW: stored query (virtual table, runs on access)
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE status = 'active';
SELECT * FROM active_users WHERE name LIKE 'A%';
-- updatable view (simple views can be INSERTed/UPDATEd)
CREATE VIEW user_summary AS
SELECT id, name, email, age FROM users;
-- MATERIALIZED VIEW: stored result (must refresh)
CREATE MATERIALIZED VIEW monthly_stats AS
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS orders,
SUM(total) AS revenue
FROM orders
GROUP BY month;
REFRESH MATERIALIZED VIEW monthly_stats;
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats; -- no lock
-- drop
DROP VIEW IF EXISTS active_users;
DROP MATERIALIZED VIEW IF EXISTS monthly_stats;DML: Insert, Update, Delete
INSERT
INSERT fügt Zeilen hinzu. Mehrere VALUES in einer Anweisung sind effizienter als separate Inserts. INSERT...SELECT kopiert Daten zwischen Tabellen. RETURNING (PostgreSQL/Oracle) ruft auto-generierte Werte (wie SERIAL-IDs) in einem Round-Trip ab — essenziell für App-Code. DEFAULT VALUES verwenden, um eine Zeile mit allen Defaults einzufügen. Spaltennamen immer angeben, um Code robust gegen Schema-Änderungen zu machen.
-- single row
INSERT INTO users (name, email, age)
VALUES ('Alice', '[email protected]', 30);
-- multiple rows
INSERT INTO users (name, email) VALUES
('Bob', '[email protected]'),
('Carol', '[email protected]'),
('Dave', '[email protected]');
-- INSERT ... SELECT (copy data between tables)
INSERT INTO archive_users (name, email, deleted_at)
SELECT name, email, NOW()
FROM users
WHERE status = 'deleted';
-- INSERT with RETURNING (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('Eve', '[email protected]')
RETURNING id, created_at; -- returns the generated id
-- DEFAULT values
INSERT INTO users DEFAULT VALUES;UPDATE
UPDATE modifiziert bestehende Zeilen. IMMER eine WHERE-Klausel inkludieren, außer man beabsichtigt, jede Zeile zu aktualisieren. Die FROM-Klausel (PostgreSQL) erlaubt Joins in Updates. RETURNING zeigt, welche Zeilen modifiziert wurden. Transaktionen für Multi-Step-Updates verwenden, damit man ROLLBACK machen kann, wenn etwas schiefgeht. Ein häufiger Fehler ist das Vergessen von WHERE — in Betracht ziehen, zuerst eine SELECT mit derselben WHERE auszuführen, um die betroffenen Zeilen zu verifizieren.
-- basic update
UPDATE users
SET age = 31, status = 'verified', updated_at = NOW()
WHERE id = 1;
-- update based on another table
UPDATE products p
SET price = p.price * 1.1
FROM categories c
WHERE p.category_id = c.id AND c.name = 'electronics';
-- update with subquery
UPDATE users
SET status = 'premium'
WHERE id IN (
SELECT user_id FROM orders
GROUP BY user_id HAVING SUM(total) > 1000
);
-- UPDATE with RETURNING (PostgreSQL)
UPDATE users SET status = 'inactive'
WHERE last_login < '2023-01-01'
RETURNING id, name;
-- WARNING: UPDATE without WHERE affects ALL rows!DELETE & TRUNCATE
DELETE entfernt Zeilen einzeln (geloggt, kann zurückgerollt werden, langsamer). TRUNCATE entfernt alle Zeilen auf einmal (minimales Logging, viel schneller, setzt Auto-Increment zurück, kann in einigen DBs nicht zurückgerollt werden). Für Audit-Trails Soft Deletes (ein deleted_at-Zeitstempel) statt Hard Deletes verwenden. WHERE bei DELETE immer verwenden. Foreign-Key-Constraints in Betracht ziehen — ON DELETE CASCADE behandelt Child-Zeilen automatisch.
-- delete specific rows
DELETE FROM users WHERE status = 'inactive';
-- delete with subquery
DELETE FROM orders
WHERE user_id IN (
SELECT id FROM users WHERE status = 'deleted'
);
-- delete with RETURNING (PostgreSQL)
DELETE FROM users
WHERE last_login < '2020-01-01'
RETURNING id, name;
-- delete all rows (slow, logged, can be rolled back)
DELETE FROM logs;
-- TRUNCATE (fast, minimal logging, resets identity)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY CASCADE;
-- soft delete pattern (preferred for audit)
UPDATE users SET deleted_at = NOW() WHERE id = 1;
SELECT * FROM users WHERE deleted_at IS NULL; -- active usersUPSERT (INSERT ... ON CONFLICT)
UPSERT (Update oder Insert) behandelt Duplicate-Key-Konflikte atomar. PostgreSQL verwendet ON CONFLICT (column) DO UPDATE/DO NOTHING. MySQL verwendet ON DUPLICATE KEY UPDATE. EXCLUDED (PostgreSQL) / VALUES() (MySQL) referenziert die vorgeschlagenen Insert-Werte. Essenziell für idempotente Operationen und Vermeidung von Race-Conditions. Ohne Upsert bräuchte man SELECT-then-INSERT/UPDATE, was anfällig für Race-Conditions ist.
-- PostgreSQL: ON CONFLICT (upsert)
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id)
DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()
RETURNING *;
-- DO NOTHING on conflict
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id) DO NOTHING;
-- MySQL: ON DUPLICATE KEY UPDATE
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON DUPLICATE KEY UPDATE email = VALUES(email);
-- SQLite: ON CONFLICT
INSERT INTO users (id, name)
VALUES (1, 'Alice')
ON CONFLICT(id) DO UPDATE SET name = excluded.name;MERGE-Anweisung
MERGE (aka UPSERT on Steroids) kombiniert INSERT, UPDATE und DELETE in einer einzigen atomaren Anweisung basierend darauf, ob Zeilen matchen. Es ist der effizienteste Weg, Daten zwischen Quellen zu synchronisieren. WHEN MATCHED triggert UPDATE/DELETE für bestehende Zeilen; WHEN NOT MATCHED triggert INSERT für neue Zeilen. Verfügbar in SQL Server, Oracle, PostgreSQL 15+ und DB2. MySQL unterstützt MERGE nicht — INSERT...ON DUPLICATE KEY verwenden.
-- MERGE: conditional insert/update/delete in one statement
-- (SQL Server, Oracle, PostgreSQL 15+)
MERGE INTO products AS target
USING (VALUES
(1, 'Widget', 9.99),
(2, 'Gadget', 19.99),
(3, 'Gizmo', 29.99)
) AS source (id, name, price)
ON target.id = source.id
WHEN MATCHED THEN
UPDATE SET name = source.name, price = source.price
WHEN NOT MATCHED THEN
INSERT (id, name, price) VALUES (source.id, source.name, source.price)
WHEN MATCHED AND source.price < 0 THEN
DELETE;
-- useful for:
-- - syncing data from external sources
-- - bulk upsert with conditional logic
-- - ETL operationsTransaktionen & ACID
BEGIN, COMMIT, ROLLBACK
Transaktionen gruppieren Operationen in eine atomare Einheit — alle erfolgreich (COMMIT) oder alle fehlschlagen (ROLLBACK). Das ist das 'A' in ACID. BEGIN/START TRANSACTION startet eine Transaktion. SAVEPOINT erstellt einen benannten Rollback-Punkt innerhalb einer Transaktion — man kann zu ihm zurückrollen, ohne die ganze Transaktion abzubrechen. Immer committen oder zurückrollen — eine offene Transaktion hält Locks und kann Deadlocks verursachen.
-- basic transaction
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- both updates succeed or both fail (atomicity)
-- rollback on error
BEGIN;
INSERT INTO orders (user_id, total) VALUES (1, 50.00);
-- oops, something went wrong
ROLLBACK;
-- the insert is undone
-- transaction with savepoints
BEGIN;
INSERT INTO logs (msg) VALUES ('step 1');
SAVEPOINT my_savepoint;
INSERT INTO logs (msg) VALUES ('step 2');
ROLLBACK TO my_savepoint; -- undo step 2, keep step 1
INSERT INTO logs (msg) VALUES ('step 3');
COMMIT; -- commits step 1 and step 3Isolation Levels
Isolation Levels balancieren Konsistenz vs. Concurrency. READ COMMITTED (Default in PostgreSQL/Oracle) verhindert Dirty Reads, erlaubt aber Non-Repeatable Reads. REPEATABLE READ verhindert Non-Repeatable Reads, erlaubt aber Phantom Reads. SERIALIZABLE verhindert alle Anomalien, reduziert aber Concurrency. Höhere Isolation = mehr Locks = weniger Concurrency. Den niedrigsten Level wählen, der die Korrektheitsanforderungen erfüllt.
-- set isolation level for transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- can see committed data from other transactions
SELECT balance FROM accounts WHERE id = 1;
COMMIT;
-- isolation levels (from weakest to strongest):
-- READ UNCOMMITTED: can read uncommitted (dirty) data
-- READ COMMITTED: only committed data (PostgreSQL default)
-- REPEATABLE READ: same query returns same results within txn
-- SERIALIZABLE: transactions appear to run sequentially
-- PostgreSQL: set per transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- MySQL: set per session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- check current level
SHOW TRANSACTION ISOLATION LEVEL;Locking & SELECT FOR UPDATE
SELECT FOR UPDATE sperrt Zeilen, sodass andere Transaktionen sie nicht modifizieren können, bis man committet. Dies implementiert pessimistische Concurrency-Control. SKIP LOCKED ist essenziell für Job-Queues — mehrere Worker können Jobs greifen, ohne sich zu blockieren. NOWAIT schlägt sofort fehl statt zu warten. Locking sparsam verwenden — es reduziert Concurrency und kann Deadlocks verursachen. Optimistische Concurrency (Version-Spalten) für die meisten Anwendungsfälle bevorzugen.
-- pessimistic locking: lock rows for update
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- row is locked; other transactions must wait
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT; -- lock released
-- NOWAIT: don't wait if locked, error immediately
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- SKIP LOCKED: skip locked rows (useful for job queues)
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;
-- SHARE LOCK: allow reads but prevent updates
SELECT * FROM products WHERE id = 1 FOR SHARE;Deadlocks & Fehlerbehandlung
Deadlocks treten auf, wenn zwei Transaktionen Locks halten, die einander brauchen. Die Datenbank erkennt Deadlocks und bricht eine Transaktion ab (das Opfer). Deadlocks verhindern, indem man Locks in konsistenter Reihenfolge über alle Transaktionen akquiriert. Immer darauf vorbereitet sein, Transaktionen zu wiederholen, die wegen Deadlocks oder Serialisierungs-Fehlern fehlschlagen. Transaktionen kurz halten, um Lock-Contention zu reduzieren. App-Code sollte SQLSTATE 40P01 (Deadlock) abfangen und wiederholen.
-- deadlock example:
-- Transaction A:
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- locks row 1
UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- waits for row 2
-- Transaction B (concurrent):
BEGIN;
UPDATE accounts SET balance = balance - 30 WHERE id = 2; -- locks row 2
UPDATE accounts SET balance = balance + 30 WHERE id = 1; -- waits for row 1
-- DEADLOCK! Database detects and kills one transaction
-- prevention: always lock in consistent order
-- Transaction A and B both lock id=1 first, then id=2
-- PostgreSQL: error codes for handling
-- 40P01: deadlock_detected
-- 40001: serialization_failure
-- 40P02: transaction_integrity_constraint_violation
-- retry pattern (pseudocode):
-- for attempt in range(3):
-- try:
-- BEGIN; ... COMMIT; break
-- except deadlock:
-- ROLLBACK; continueACID-Eigenschaften
ACID ist die Grundlage zuverlässiger Datenbank-Transaktionen. Atomarität: alle Operationen in einer Transaktion erfolgreich oder fehlgeschlagen zusammen. Konsistenz: Transaktionen bewegen die Datenbank von einem gültigen Zustand zum anderen (Constraints werden erzwungen). Isolation: gleichzeitige Transaktionen stören sich nicht (kontrolliert durch Isolation Level). Durability: einmal committet, überlebt Daten Abstürze (erreicht via Write-Ahead-Logging). NoSQL-Datenbanken opfern oft einige ACID-Eigenschaften für Skalierbarkeit.
-- ACID guarantees for transactions:
-- A: Atomicity (all or nothing)
BEGIN;
INSERT INTO orders (id, total) VALUES (1, 100);
INSERT INTO order_items (order_id, product_id) VALUES (1, 5);
-- if either fails, both are rolled back
COMMIT;
-- C: Consistency (valid state to valid state)
-- constraints are checked at commit
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative
CHECK (balance >= 0);
-- a transaction that would make balance negative fails
-- I: Isolation (concurrent transactions don't interfere)
-- controlled by isolation level (see previous section)
-- D: Durability (committed data survives crashes)
-- achieved via WAL (Write-Ahead Logging) + fsync
-- synchronous_commit = on (default) ensures durabilityIndexes, Views & Stored Procedures
Index-Typen & Strategien
B-Tree-Indexes (Default) handhaben Gleichheits- (=) und Bereichs- (<, >, BETWEEN)-Queries. Composite Indexes folgen der Leftmost-Prefix-Regel — ein (a,b,c)-Index hilft WHERE a=?, WHERE a=? AND b=?, aber nicht WHERE b=?. Partial Indexes sparen Platz, indem nur eine Teilmenge indiziert wird. Expression Indexes ermöglichen indizierte Queries auf Funktionen (LOWER, berechnete Spalten). EXPLAIN ANALYZE verwenden, um zu verifizieren, dass Indexes genutzt werden — ein ungenutzter Index verschwendet Platz und verlangsamt Writes.
-- B-tree index (default): equality and range queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_date ON orders(created_at);
-- composite index (order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- helps: WHERE user_id = 1
-- helps: WHERE user_id = 1 AND status = 'paid'
-- does NOT help: WHERE status = 'paid' (leftmost prefix rule)
-- partial index: smaller, faster for common filters
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';
-- expression index
CREATE INDEX idx_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- unique index
CREATE UNIQUE INDEX idx_unique_email ON users(email);
-- EXPLAIN: see if index is used
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';Stored Procedures & Functions
Funktionen geben Werte zurück und können in SELECT verwendet werden; Procedures führen Aktionen aus und werden mit CALL aufgerufen. Stored Procedures kapseln Business-Logik in der Datenbank — reduzieren Netzwerk-Round-Trips und zentralisieren Logik. Sie können jedoch Skalierung erschweren (Logik aufgeteilt zwischen App und DB) und sind datenbankspezifisch. Für datenintensive Operationen verwenden, die von Nähe zu den Daten profitieren. PostgreSQL verwendet PL/pgSQL; MySQL verwendet seine eigene prozedurale SQL.
-- PostgreSQL function
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id INT)
RETURNS TABLE(order_id INT, total DECIMAL) AS $$
BEGIN
RETURN QUERY
SELECT id, total FROM orders WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;
-- call function
SELECT * FROM get_user_orders(1);
-- PostgreSQL procedure (can manage transactions, PostgreSQL 11+)
CREATE PROCEDURE transfer_money(
from_id INT, to_id INT, amount DECIMAL
) LANGUAGE plpgsql AS $$
BEGIN
UPDATE accounts SET balance = balance - amount WHERE id = from_id;
UPDATE accounts SET balance = balance + amount WHERE id = to_id;
COMMIT;
END;
$$;
CALL transfer_money(1, 2, 100.00);
-- MySQL stored procedure
DELIMITER //
CREATE PROCEDURE GetActiveUsers()
BEGIN
SELECT * FROM users WHERE status = 'active';
END //
DELIMITER ;
CALL GetActiveUsers();Trigger
Trigger führen automatisch bei Datenänderungen aus. BEFORE-Trigger können eingehende Daten modifizieren (z.B. Zeitstempel setzen, validieren). AFTER-Trigger führen Side Effects aus (z.B. Audit-Logging, Denormalisierung). Trigger sparsam verwenden — sie sind vor App-Code verborgen, was Debuggen erschwert. Häufige Verwendungen: Audit-Trails, berechnete Spalten, Erzwingen komplexer Constraints und Synchronisieren denormalisierter Daten. Trigger immer klar dokumentieren.
-- PostgreSQL trigger: audit log on update
CREATE OR REPLACE FUNCTION audit_user_change()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO user_audit (user_id, old_name, new_name, changed_at)
VALUES (OLD.id, OLD.name, NEW.name, NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_user_audit
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_change();
-- trigger timing: BEFORE / AFTER / INSTEAD OF
-- trigger events: INSERT / UPDATE / DELETE / TRUNCATE
-- granularity: FOR EACH ROW / FOR EACH STATEMENT
-- MySQL trigger
CREATE TRIGGER before_user_insert
BEFORE INSERT ON users
FOR EACH ROW
SET NEW.created_at = NOW();
-- drop trigger
DROP TRIGGER IF EXISTS trg_user_audit ON users;JSON-Operationen (PostgreSQL)
JSONB (PostgreSQL) speichert JSON in einem binären Format, das Indexierung und effiziente Queries ermöglicht. -> gibt JSON zurück, ->> gibt Text zurück. @> prüft Containment (enthält das JSON dies?). GIN-Indexes machen JSON-Queries schnell. JSON-Spalten für flexible/halb-strukturierte Daten (Event-Logs, API-Antworten, Konfiguration) verwenden, während relationale Daten in normalen Spalten bleiben. JSONB ist JSON vorzuziehen (schneller, indizierbar, keine doppelten Schlüssel).
-- JSONB columns (PostgreSQL)
CREATE TABLE events (
id SERIAL PRIMARY KEY,
data JSONB NOT NULL
);
INSERT INTO events (data) VALUES
('{"type": "click", "user": {"id": 1, "name": "Alice"}, "tags": ["web", "mobile"]}');
-- extract fields (-> for JSON, ->> for text)
SELECT data->'type' AS type, -- "click" (JSON)
data->'user'->>'name' AS name, -- Alice (text)
data->'tags'->0 AS first_tag -- "web"
FROM events;
-- filter by JSON field
SELECT * FROM events WHERE data->>'type' = 'click';
SELECT * FROM events WHERE data @> '{"type": "click"}'; -- containment
-- GIN index for JSON queries
CREATE INDEX idx_events_data ON events USING gin(data);
SELECT * FROM events WHERE data @> '{"user": {"id": 1}}';
-- modify JSON
UPDATE events SET data = jsonb_set(data, '{user,name}', '"Bob"');Full-Text-Search
Full-Text-Search ermöglicht natürlichsprachliche Queries (Stemming, Ranking, Stopwords). to_tsvector konvertiert Text zu durchsuchbaren Tokens; to_tsquery erstellt eine Search-Query; @@ matcht. ts_rank bewertet Ergebnisse; ts_headline hebt Matches hervor. GIN-Indexes machen dies schnell. Für groß angelegte Suche dedizierte Engines (Elasticsearch, Solr) in Betracht ziehen, aber PostgreSQL FTS ist exzellent für moderate Datensätze und vermeidet Infrastruktur-Komplexität.
-- PostgreSQL full-text search
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title VARCHAR(200),
body TEXT
);
-- create a full-text search index
CREATE INDEX idx_articles_search ON articles
USING gin(to_tsvector('english', title || ' ' || body));
-- search with ranking
SELECT
title,
ts_rank(to_tsvector('english', body), query) AS rank,
ts_headline('english', body, query) AS snippet
FROM articles, to_tsquery('english', 'database & performance') query
WHERE to_tsvector('english', title || ' ' || body) @@ query
ORDER BY rank DESC
LIMIT 10;
-- simplified with generated column
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search_vec ON articles USING gin(search_vector);
SELECT * FROM articles WHERE search_vector @@ to_tsquery('database');Performance & Query-Optimierung
EXPLAIN & Query-Pläne
EXPLAIN zeigt den Query-Plan — wie die Datenbank die Query ausführen wird. EXPLAIN ANALYZE führt sie tatsächlich aus und zeigt echte Timings. Suchen nach Sequential Scans auf großen Tabellen (Indexes hinzufügen), teuren Sorts (Indexes hinzufügen) und Row-Estimate-Mismatches (ANALYZE ausführen, um Statistiken zu aktualisieren). Die Cost-Zahlen sind relativ, nicht absolut. Query-Pläne zu verstehen ist die #1-Fähigkeit für SQL-Performance-Tuning.
-- EXPLAIN: show query plan without running
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
-- EXPLAIN ANALYZE: run the query and show actual timing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';
-- EXPLAIN ANALYZE with buffers (I/O stats)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users
JOIN orders ON users.id = orders.user_id;
-- key things to look for in the plan:
-- Seq Scan: full table scan (bad for large tables — add index)
-- Index Scan: using an index (good)
-- Bitmap Index Scan: index + heap lookup (good for many rows)
-- Hash Join: builds hash table (good for large joins)
-- Nested Loop: good for small result sets
-- Sort: explicit sort (consider index to avoid)
-- cost: estimated cost (first row, all rows)
-- rows: estimated vs actual rows (big mismatch = stale stats)Häufige Performance-Fallstricke
Sargability (Search Argument Able) bedeutet, dass die Datenbank Indexes verwenden kann. Funktionen auf Spalten (DATE(col), UPPER(col)) verhindern Index-Nutzung — als Bereichs-Queries umschreiben oder Expression-Indexes verwenden. SELECT * verschwendet I/O und verhindert Covering-Indexes. OFFSET-Pagination ist O(n) — Keyset-Pagination (WHERE id > last_id) für O(1) verwenden. Große Batch-Operationen sollten in Chunks aufgeteilt werden, um lange Locks und Replication-Lag zu vermeiden.
-- 1. Sargability: avoid functions on indexed columns
-- BAD: function prevents index usage
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- GOOD: range query uses index
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';
-- 2. Avoid SELECT * (more I/O, prevents covering indexes)
-- BAD
SELECT * FROM users WHERE status = 'active';
-- GOOD
SELECT id, name, email FROM users WHERE status = 'active';
-- 3. Use LIMIT with ORDER BY for pagination
-- BAD: loads all rows
SELECT * FROM products ORDER BY id;
-- GOOD: keyset pagination
SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20;
-- 4. Batch large updates
-- BAD: one giant transaction
DELETE FROM logs WHERE date < '2023-01-01';
-- GOOD: batch in chunks
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 10000;
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 20000;UNION, INTERSECT & EXCEPT
Set-Operationen kombinieren Result-Mengen. UNION entfernt Duplikate (teure Sort); UNION ALL behält sie (schneller — bevorzugen, wenn man weiß, dass es keine Duplikate gibt oder man sie will). INTERSECT gibt Zeilen in beiden zurück. EXCEPT gibt Zeilen in der ersten, aber nicht der zweiten zurück. Alle erfordern kompatible Spalten-Typen. UNION ALL kann komplexe OR-Bedingungen ersetzen und performt oft besser, weil es unterschiedliche Indexes für jeden Branch verwenden kann.
-- UNION: combine results, remove duplicates
SELECT name FROM customers
UNION
SELECT name FROM suppliers;
-- UNION ALL: faster, keeps duplicates
SELECT name FROM customers
UNION ALL
SELECT name FROM suppliers;
-- INTERSECT: rows in BOTH results
SELECT product_id FROM sales_2023
INTERSECT
SELECT product_id FROM sales_2024;
-- EXCEPT (MINUS in Oracle): rows in first but not second
SELECT product_id FROM all_products
EXCEPT
SELECT product_id FROM discontinued_products;
-- rules:
-- - same number of columns
-- - compatible types
-- - column names come from first query
-- - UNION is often faster than OR conditionsDatenbank-spezifische Tipps
VACUUM (PostgreSQL) gibt Speicherplatz von gelöschten Zeilen zurück (MVCC hinterlässt 'Dead Tuples'). ANALYZE aktualisiert Tabellen-Statistiken für den Query-Planner — nach Bulk-Loads ausführen. OPTIMIZE TABLE (MySQL) defragmentiert Tabellen. Foreign Keys explizit indizieren (PostgreSQL macht das nicht automatisch). Index-Nutzung mit pg_stat_user_indexes überwachen und ungenutzte droppen. Connection Pooling (PgBouncer, ProxySQL) ist essenziell für High-Traffic-Apps — Verbindungen zu öffnen ist teuer.
-- PostgreSQL: VACUUM to reclaim space
VACUUM ANALYZE users; -- update stats, reclaim dead rows
VACUUM FULL users; -- rewrites table (locks, but reclaims all space)
-- PostgreSQL: ANALYZE to update statistics
ANALYZE users; -- helps query planner make better decisions
-- MySQL: OPTIMIZE TABLE
OPTIMIZE TABLE users;
-- Common indexing rules across databases:
-- 1. Index foreign keys (not automatic in all DBs)
-- 2. Index columns used in WHERE, JOIN, ORDER BY, GROUP BY
-- 3. Composite indexes: high selectivity column first
-- 4. Don't over-index (slows writes, uses disk)
-- 5. Drop unused indexes (check with pg_stat_user_indexes)
-- Connection pooling (application level):
-- - Use PgBouncer (PostgreSQL) or ProxySQL (MySQL)
-- - Reuse connections instead of reconnecting per request
-- - Set appropriate pool size (not too high!)Datentypen & NULL-Behandlung
NULL repräsentiert unbekannte/fehlende Daten, nicht null oder leer. NULL-Vergleiche ergeben immer NULL (unbekannt), was in WHERE falsy ist. IS NULL / IS NOT NULL zum Testen verwenden. COALESCE stellt Fallbacks bereit. NULLIF konvertiert spezifische Werte zu NULL (nützlich für Division durch null). Aggregate überspringen NULLs — COUNT(col) zählt nicht-NULLs, COUNT(*) zählt alle Zeilen. In LEFT JOINs COUNT(right_table.col) verwenden, um 0 für nicht-matchende Zeilen zu erhalten.
-- NULL is not zero or empty string — it's "unknown"
SELECT NULL = NULL; -- NULL (not true!)
SELECT NULL IS NULL; -- true
SELECT NULL <> 1; -- NULL (unknown)
-- COALESCE: first non-NULL value
SELECT COALESCE(nickname, first_name, 'Anonymous') FROM users;
-- NULLIF: return NULL if two values are equal
SELECT NULLIF(score, 0) FROM tests; -- NULL instead of 0
-- useful for avoiding division by zero:
SELECT total / NULLIF(count, 0) FROM stats;
-- aggregate functions ignore NULL
SELECT AVG(score) FROM tests; -- avg of non-NULL scores only
SELECT COUNT(score) FROM tests; -- count of non-NULL
SELECT COUNT(*) FROM tests; -- count of all rows
-- use LEFT JOIN + COUNT carefully
SELECT u.name, COUNT(o.id) AS orders -- COUNT(o.id) = 0 for no orders
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;Recursive CTEs
Grundstruktur rekursiver CTEs
Eine rekursive CTE referenziert sich selbst, um hierarchische oder sequenzielle Daten zu generieren. Sie hat zwei Teile, verbunden durch UNION ALL: eine Anchor-Query (der Base Case/Startpunkt) und eine rekursive Query (die die CTE referenziert und zum Ergebnis hinzufügt). Die Rekursion läuft weiter, bis die rekursive Query keine Zeilen zurückgibt. Rekursive CTEs für Baum-Traversierung (Org-Charts, Dateisysteme), Sequenz-Generierung und Graph-Pathfinding verwenden. Immer eine Termination-Bedingung in der WHERE-Klausel inkludieren, um Endlosschleifen zu verhindern.
-- Recursive CTE: a CTE that references itself
-- Three parts: anchor, UNION ALL, recursive member
WITH RECURSIVE countdown(n) AS (
-- Anchor: starting point
SELECT 1 AS n
UNION ALL
-- Recursive member: references the CTE itself
SELECT n + 1 FROM countdown WHERE n < 10
)
SELECT n FROM countdown;
-- Result: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
-- PostgreSQL uses WITH RECURSIVE
-- SQL Server/MySQL 8+: WITH (RECURSIVE optional in MySQL)
-- SQLite: WITH RECURSIVEHierarchische Daten (Org-Chart)
Rekursive CTEs eignen sich hervorragend zum Traversieren hierarchischer Daten wie Org-Charts, Kategorie-Bäume oder Dateisysteme. Der Anchor selektiert den Root-Knoten; das rekursive Member joint die Tabelle mit der CTE auf der Parent-Child-Beziehung (manager_id = id). Eine depth-Spalte verfolgt, wie viele Ebenen tief jede Zeile ist, und eine path-Spalte (String-Konkatenation) zeigt die vollständige Ancestry-Kette. Dies ersetzt die Notwendigkeit für multiple Self-Joins oder App-seitige Rekursion. Der CAST auf path verhindert Typfehler während der Rekursion.
-- Employee hierarchy with manager relationships
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(100),
manager_id INT REFERENCES employees(id)
);
-- Find all direct and indirect reports of employee 1 (CEO)
WITH RECURSIVE org_chain AS (
-- Anchor: the starting employee
SELECT id, name, manager_id, 0 AS depth, CAST(name AS VARCHAR(500)) AS path
FROM employees WHERE id = 1
UNION ALL
-- Recursive: find employees whose manager is in the chain
SELECT e.id, e.name, e.manager_id, oc.depth + 1,
CAST(oc.path || ' > ' || e.name AS VARCHAR(500))
FROM employees e
JOIN org_chain oc ON e.manager_id = oc.id
)
SELECT id, name, depth, path FROM org_chain
ORDER BY depth, name;
-- depth shows hierarchy level, path shows the management chainSequenzen & Daten generieren
Rekursive CTEs können Sequenzen und Datums-Bereiche generieren — nützlich zum Füllen von Lücken in Zeitreihen-Berichten. Durch Generieren aller Daten in einem Bereich und LEFT JOINen zu Daten stellt man sicher, dass jedes Datum in der Ausgabe erscheint, auch wenn es keine Datensätze gibt. Dies ist ein häufiges Pattern für Dashboards und Charts. PostgreSQL hat auch generate_series() als einfachere Alternative. Immer eine Termination-Bedingung setzen (WHERE n < 100), um Endlosrekursion zu verhindern. Einige Datenbanken limitieren die Rekursionstiefe (z.B. 100 standardmäßig in MySQL via cte_max_recursion_depth).
-- Generate a series of numbers
WITH RECURSIVE numbers(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;
-- Generate a date range (all days in January 2024)
WITH RECURSIVE dates(d) AS (
SELECT DATE '2024-01-01'
UNION ALL
SELECT d + INTERVAL '1 day' FROM dates
WHERE d < DATE '2024-01-31'
)
SELECT d FROM dates;
-- Generate a time series for gap-filling in reports
-- (ensures every date appears even if no data exists)
SELECT d.date, COALESCE(SUM(o.amount), 0) AS daily_total
FROM dates d
LEFT JOIN orders o ON o.order_date = d.date
GROUP BY d.date ORDER BY d.date;Graph-Pathfinding (BFS)
Rekursive CTEs können Breadth-First-Search (BFS) auf Graph-Strukturen durchführen. Der Anchor findet Kanten vom Startknoten; das rekursive Member erweitert Pfade, indem Kanten zum Endpunkt des aktuellen Pfads gejoint werden. Cycle-Prevention ist in zyklischen Graphen kritisch — prüfen, dass der Zielknoten nicht bereits im Pfad ist (mit LIKE oder String-Suche). Das Hops-Limit ist ein Sicherheitsnetz gegen Endlosrekursion. Dieser Ansatz funktioniert für Routenfindung, Abhängigkeitsauflösung und Netzwerkanalyse. Für gewichtete kürzeste Pfade Dijkstras Algorithmus in App-Code in Betracht ziehen.
-- Find all paths in a directed graph
CREATE TABLE edges (src VARCHAR(10), dst VARCHAR(10));
-- Find all reachable nodes from 'A' with the path taken
WITH RECURSIVE paths AS (
-- Anchor: start from node A
SELECT src, dst, CAST(src || '->' || dst AS VARCHAR(1000)) AS path,
1 AS hops
FROM edges WHERE src = 'A'
UNION ALL
-- Recursive: extend the path
SELECT p.src, e.dst,
CAST(p.path || '->' || e.dst AS VARCHAR(1000)),
p.hops + 1
FROM paths p
JOIN edges e ON p.dst = e.src
WHERE p.hops < 10 -- prevent infinite loops in cyclic graphs
AND p.path NOT LIKE '%' || e.dst || '%'
)
SELECT DISTINCT path, hops FROM paths ORDER BY hops;
-- Cycle prevention: check the path doesn't already contain the nodeFakultät & Aggregation mit Rekursion
Rekursive CTEs können mathematische Berechnungen wie Fakultäten durchführen, indem sie Zustand (n, fact) durch jede Iteration tragen. Der Anchor setzt den Base Case (0! = 1), und das rekursive Member berechnet den nächsten Wert aus dem vorherigen. Laufende Summen können auch so berechnet werden, obwohl Window-Funktionen (SUM(amount) OVER (ORDER BY id)) für kumulative Aggregate effizienter und idiomatischer sind. Rekursive CTEs für Berechnung sind hauptsächlich lehrreich — verwenden, wenn Window-Funktionen oder prozeduraler Code die Logik nicht ausdrücken können. Jede Rekursionsstufe fügt eine Zeile hinzu, sodass die Result-Menge mit der Tiefe wächst.
-- Compute factorial using recursive CTE
WITH RECURSIVE factorial(n, fact) AS (
-- Anchor: 0! = 1
SELECT 0, 1
UNION ALL
-- Recursive: n! = n * (n-1)!
SELECT n + 1, fact * (n + 1) FROM factorial WHERE n < 10
)
SELECT n, fact FROM factorial;
-- Running accumulation: cumulative sum
WITH RECURSIVE running_total AS (
SELECT id, amount, amount AS cumulative
FROM transactions WHERE id = 1
UNION ALL
SELECT t.id, t.amount, rt.cumulative + t.amount
FROM transactions t
JOIN running_total rt ON t.id = rt.id + 1
)
SELECT * FROM running_total ORDER BY id;
-- Note: window functions (SUM OVER) are usually better for thisPIVOT & UNPIVOT
PIVOT (Zeilen zu Spalten)
PIVOT transformiert Zeilen in Spalten — perfekt für Kreuztabellen-Berichte, wo man Kategorien als Spaltenüberschriften will. Die IN-Liste gibt an, welche Werte zu Spalten werden. SQL Server und Oracle haben native PIVOT-Syntax. Die innere Query liefert die Quelldaten, und PIVOT wendet ein Aggregat (SUM, AVG, COUNT) für jede Spaltengruppe an. Dies ist äquivalent zu bedingter Aggregation, aber lesbarer für breite Pivots. PIVOT verwenden, wenn man eine feste, bekannte Menge von Werten hat, auf die gepivottet wird.
-- Convert rows to columns (cross-tabulation)
-- Source: sales data with rows per quarter
CREATE TABLE sales (quarter VARCHAR(10), region VARCHAR(50), amount DECIMAL(10,2));
-- SQL Server PIVOT syntax:
SELECT region, [Q1], [Q2], [Q3], [Q4]
FROM (
SELECT quarter, region, amount FROM sales
) AS src
PIVOT (
SUM(amount) FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS pvt;
-- Result:
-- region | Q1 | Q2 | Q3 | Q4
-- North | 1000 | 1500 | 1200 | 1800
-- South | 800 | 1100 | 900 | 1300Bedingte Aggregation (Universeller PIVOT)
Bedingte Aggregation (SUM + CASE) ist die universelle Pivot-Technik, die in jeder SQL-Datenbank funktioniert. Jeder CASE-Ausdruck filtert für eine Kategorie, und SUM aggregiert die matchenden Werte. Dies ist oft schneller als PIVOT und flexibler. Das ELSE 0 stellt sicher, dass nicht-matchende Zeilen null beitragen. PostgreSQLs crosstab()-Funktion (aus der tablefunc-Erweiterung) ist prägnanter, erfordert aber feste Ausgabespalten. Bedingte Aggregation verwenden, wenn man Cross-Datenbank-Kompatibilität braucht oder PIVOT-Syntax nicht verfügbar ist.
-- Works in ALL databases (MySQL, PostgreSQL, SQLite, etc.)
SELECT
region,
SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END) AS Q1,
SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END) AS Q2,
SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END) AS Q3,
SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) AS Q4,
SUM(amount) AS total
FROM sales
GROUP BY region
ORDER BY region;
-- PostgreSQL-specific: crosstab() from tablefunc extension
-- SELECT * FROM crosstab('SELECT region, quarter, amount FROM sales ORDER BY 1,2')
-- AS ct(region VARCHAR, Q1 DECIMAL, Q2 DECIMAL, Q3 DECIMAL, Q4 DECIMAL);Dynamischer PIVOT (Dynamic SQL)
Dynamic SQL baut einen Query-String zur Laufzeit, wenn Pivot-Spalten nicht im Voraus bekannt sind (z.B. Pivotieren nach Monat, wenn Monate variieren). Der Prozess: distinct Werte abfragen, eine Spaltenliste bauen, die PIVOT-Anweisung konstruieren und mit sp_executesql (SQL Server) oder PREPARE/EXECUTE (MySQL) ausführen. Immer mit QUOTENAME() oder quote_ident() sanitizen, um SQL-Injection zu verhindern. Dynamic SQL ist mächtig, fügt aber Komplexität und Sicherheitsrisiken hinzu — sparsam verwenden und feste Pivots bevorzugen, wenn möglich. App-seitiges Pivotieren ist oft eine sicherere Alternative.
-- When pivot columns are unknown at write time, use dynamic SQL
-- SQL Server example:
DECLARE @cols AS NVARCHAR(MAX),
@query AS NVARCHAR(MAX);
-- Build the column list dynamically
SELECT @cols = STUFF((
SELECT DISTINCT ',' + QUOTENAME(quarter)
FROM sales FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 1, '');
-- Build and execute the pivot query
SET @query = 'SELECT region, ' + @cols + '
FROM (SELECT quarter, region, amount FROM sales) x
PIVOT (SUM(amount) FOR quarter IN (' + @cols + ')) p';
EXEC sp_executesql @query;
-- WARNING: dynamic SQL is vulnerable to SQL injection
-- — always use QUOTENAME() to sanitize column namesUNPIVOT (Spalten zu Zeilen)
UNPIVOT kehrt PIVOT um — es transformiert Spalten in Zeilen. Nützlich zum Normalisieren denormalisierter Daten, Konvertieren breiter Import-Dateien in langes Format oder Vorbereiten von Daten für Charting. SQL Server hat native UNPIVOT-Syntax. Der UNION ALL-Ansatz funktioniert überall: Jedes SELECT extrahiert eine Spalte und labelt sie mit einem festen Wert. UNION ALL (nicht UNION) bewahrt Duplikate und ist schneller. UNPIVOT ist häufig in ETL-Pipelines, wenn Quelldaten im Tabellenkalkulationsformat (breit) ankommen, aber normalisiert (lang) gespeichert werden müssen.
-- Convert columns back to rows
-- Source: wide table with Q1-Q4 columns
-- Target: narrow table with quarter/amount rows
-- SQL Server UNPIVOT:
SELECT region, quarter, amount
FROM quarterly_sales
UNPIVOT (
amount FOR quarter IN (Q1, Q2, Q3, Q4)
) AS unpvt;
-- Universal UNION ALL approach (all databases):
SELECT region, 'Q1' AS quarter, Q1 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q2' AS quarter, Q2 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q3' AS quarter, Q3 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q4' AS quarter, Q4 AS amount FROM quarterly_sales;
-- Result: one row per region/quarter combinationPraktisches Pivot-Bericht-Beispiel
Dieser realweltliche Pivot-Bericht kombiniert monatliche Aufschlüsselungen mit Jahr-zu-Jahr-Vergleich in einer einzigen Query. Bedingte Aggregation (SUM + CASE) erstellt sowohl monatliche Spalten als auch jährliche Summen. Die yoy_change-Spalte berechnet die Differenz inline. HAVING filtert Produkte ohne Verkäufe aus. Dieses Pattern ist häufig in BI-Dashboards und Finanzberichten. Die EXTRACT-Funktion funktioniert in den meisten Datenbanken (DATEPART in SQL Server, strftime in SQLite verwenden). Für wirklich dynamische Spalten mit Dynamic SQL kombinieren oder Pivotieren in der App-Schicht handhaben.
-- Monthly sales pivot with year-over-year comparison
SELECT
product_name,
SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 1 THEN amount ELSE 0 END) AS jan,
SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 2 THEN amount ELSE 0 END) AS feb,
SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 3 THEN amount ELSE 0 END) AS mar,
SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 4 THEN amount ELSE 0 END) AS apr,
SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 5 THEN amount ELSE 0 END) AS may,
SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 6 THEN amount ELSE 0 END) AS jun,
SUM(CASE WHEN EXTRACT(YEAR FROM order_date) = 2024 THEN amount ELSE 0 END) AS total_2024,
SUM(CASE WHEN EXTRACT(YEAR FROM order_date) = 2023 THEN amount ELSE 0 END) AS total_2023,
SUM(CASE WHEN EXTRACT(YEAR FROM order_date) = 2024 THEN amount ELSE 0 END) -
SUM(CASE WHEN EXTRACT(YEAR FROM order_date) = 2023 THEN amount ELSE 0 END) AS yoy_change
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE order_date BETWEEN '2023-01-01' AND '2024-12-31'
GROUP BY product_name
HAVING SUM(amount) > 0
ORDER BY total_2024 DESC;Full-Text-Search
LIKE vs. Full-Text-Search
LIKE '%pattern%' führt einen Full-Table-Scan durch — es kann keine Indexes verwenden und ist O(n) auf großen Tabellen. Full-Text-Search verwendet einen Inverted Index (Wort → Dokument-Mapping), was Suchen O(1) pro Term macht. Full-Text-Search unterstützt auch Relevance-Ranking, Stemming und Boolean-Operatoren. LIKE für einfache Präfix-Matches verwenden (LIKE 'prefix%' kann einen B-Tree-Index verwenden) oder kleine Tabellen. Full-Text-Search für das Suchen von Artikeln, Produktbeschreibungen oder jedem textlastigen Inhalt verwenden. Jede Datenbank hat ihre eigene Full-Text-Implementierung (MySQL FULLTEXT, PostgreSQL tsvector, SQL Server CONTAINS).
-- LIKE: simple pattern matching (slow on large tables)
SELECT * FROM articles WHERE title LIKE '%database%';
SELECT * FROM articles WHERE title LIKE 'data%'; -- prefix match (can use index)
-- Full-Text Search: indexed, fast, supports ranking
-- MySQL:
CREATE FULLTEXT INDEX ft_title_body ON articles(title, body);
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('database performance' IN NATURAL LANGUAGE MODE);
-- With relevance score:
SELECT id, title,
MATCH(title, body) AGAINST('database performance') AS relevance
FROM articles
WHERE MATCH(title, body) AGAINST('database performance')
ORDER BY relevance DESC;
-- LIKE '%word%' scans every row (no index usage)
-- Full-text search uses an inverted index — much fasterPostgreSQL Full-Text-Search (tsvector)
PostgreSQL hat die mächtigste eingebaute Full-Text-Search. tsvector ist eine pre-tokenisierte, normalisierte Dokument-Repräsentation (lowercased, gestemmt, Stopwords entfernt). tsquery ist der Search-Ausdruck, der & (AND), | (OR), ! (NOT) unterstützt. Der @@-Operator matcht einen tsvector gegen einen tsquery. GIN-Indexes machen Suchen extrem schnell. ts_rank bewertet Ergebnisse nach Term-Frequenz. to_tsvector für Indexierung verwenden, plainto_tsquery für User-Input (handhabt einfache Wörter), und phraseto_tsquery für exaktes Phrase-Matching. Dies rivalisiert mit dedizierten Search-Engines für viele Anwendungsfälle.
-- PostgreSQL uses tsvector (document) and tsquery (search)
-- Create a search-optimized column
ALTER TABLE articles ADD COLUMN search_vector tsvector;
CREATE INDEX idx_search ON articles USING GIN(search_vector);
-- Populate the search vector (tokenize + normalize)
UPDATE articles SET search_vector =
to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''));
-- Search with tsquery
SELECT id, title,
ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'database & performance') query
WHERE search_vector @@ query
ORDER BY rank DESC;
-- ts_rank_cd gives coverage density ranking
-- plainto_tsquery: plain text → tsquery (user-friendly)
-- phraseto_tsquery: exact phrase matchingBoolean-Mode-Suche
Boolean-Modus gibt präzise Kontrolle über Suchkriterien. In MySQL: + erfordert einen Term, - schließt ihn aus, * ist ein Präfix-Wildcard, und Anführungszeichen matchen exakte Phrasen. In PostgreSQL sind & | ! die Operatoren, und <-> matcht adjazente Wörter (Phrase-Search). Boolean-Search ist ideal für fortgeschrittene Search-Interfaces, wo User exakte Anforderungen spezifizieren. Beachten, dass MySQLs Boolean-Modus standardmäßig keine Relevance-Scores berechnet — mit NATURAL LANGUAGE MODE für Ranking kombinieren. Wildcard-Suchen (data*) können langsamer sein, da sie viele Terme matchen.
-- MySQL boolean full-text search
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('+database +performance' IN BOOLEAN MODE);
-- + = must contain, - = must NOT contain, * = wildcard
-- "" = exact phrase, () = grouping, ~ = fuzzy (negation)
-- Examples:
-- '+MySQL -Oracle' → must have MySQL, must NOT have Oracle
-- '"full text"' → exact phrase "full text"
-- 'data*' → words starting with "data" (database, dataset)
-- '+database >performance' → database required, performance boosts rank
-- PostgreSQL boolean operators in tsquery:
SELECT * FROM articles
WHERE search_vector @@ to_tsquery('english', 'database & !oracle');
-- & = AND, | = OR, ! = NOT, <-> = follows (phrase)Suche mit Highlighting & Snippets
Search-Result-Highlighting zeigt Usern, warum ein Dokument matchte, indem ein Snippet mit den Search-Terms hervorgehoben angezeigt wird. PostgreSQLs ts_headline generiert einen kontextbewussten Auszug mit konfigurierbarer Länge (MaxWords, MinWords) und Highlight-Tags. MySQL fehlt eingebautes Highlighting — SUBSTRING mit LOCATE verwenden, um Kontext manuell zu extrahieren. SQL Servers CONTAINSTABLE gibt eine Ranking-Tabelle zurück, die man zurück zur Quelle joint. Highlighting verbessert UX in Search-Interfaces durch Anzeige von Relevance-Kontext. Für produktive Suche dedizierte Tools wie Elasticsearch oder Meilisearch für fortgeschrittene Features in Betracht ziehen.
-- PostgreSQL: highlight matching terms
SELECT id, title,
ts_headline('english', body, query, 'MaxWords=35, MinWords=15') AS snippet
FROM articles, to_tsquery('english', 'database') query
WHERE search_vector @@ query;
-- ts_headline returns a text snippet with matches highlighted
-- (wrapped in <b> tags by default, customizable)
-- MySQL: no built-in highlighting, but you can use SUBSTRING:
SELECT id,
SUBSTRING(body, GREATEST(1, LOCATE('database', body) - 50), 100) AS snippet
FROM articles
WHERE MATCH(body) AGAINST('database' IN NATURAL LANGUAGE MODE);
-- SQL Server CONTAINSTABLE with ranking:
-- SELECT a.id, a.title, KEY_TBL.RANK
-- FROM articles a
-- INNER JOIN CONTAINSTABLE(articles, body, 'database') AS KEY_TBL
-- ON a.id = KEY_TBL.[KEY]
-- ORDER BY KEY_TBL.RANK DESC;Trigram-Suche (Fuzzy Matching)
Trigram-Suche zerlegt Text in 3-Zeichen-Sequenzen und vergleicht Überlappung — ermöglicht Fuzzy, Tippfehler-tolerantes Matching, das LIKE nicht kann. Die pg_trgm-Erweiterung (PostgreSQL) bietet similarity() (0-1 Score) und den %-Operator (matcht über einem Schwellwert). Dies treibt Autocomplete, 'Meintest du?'-Vorschläge und Tippfehler-Korrektur. GIN-Indexes mit gin_trgm_ops machen Fuzzy-Suchen schnell. Trigramme handhaben Tippfehler ('databse' → 'database'), die exakte Full-Text-Search verfehlen würde. Der similarity_threshold steuert, wie locker das Matching ist — niedrigere Werte matchen mehr, aber mit mehr False Positives.
-- PostgreSQL pg_trgm extension: fuzzy/typo-tolerant search
CREATE EXTENSION pg_trgm;
-- similarity() returns 0-1 based on trigram overlap
SELECT name, similarity(name, 'database') AS sim
FROM products
WHERE name % 'database' -- % operator: trigram similarity threshold
ORDER BY sim DESC;
-- Set similarity threshold (0-1)
SET pg_trgm.similarity_threshold = 0.3;
-- word_similarity: partial word matching
SELECT name, word_similarity('database', name) AS sim
FROM products WHERE name <% 'database';
-- Create a GIN index for fast fuzzy search
CREATE INDEX idx_products_trgm ON products USING GIN(name gin_trgm_ops);
-- Great for: autocomplete, typo correction, "did you mean?" featuresTrigger
Trigger-Grundlagen (AFTER/BEFORE)
Trigger sind Datenbank-Level-Code, der automatisch läuft, wenn sich Daten ändern. AFTER-Trigger loggen oder propagieren Änderungen (können NEW nicht modifizieren). BEFORE-Trigger validieren oder transformieren Daten, bevor sie geschrieben werden (können NEW modifizieren). FOR EACH ROW feuert einmal pro betroffener Zeile; FOR EACH STATEMENT einmal pro Anweisung. Trigger für Audit-Logging, Erzwingen komplexer Constraints und Auto-Update abgeleiteter Spalten verwenden. Trigger für Business-Logik vermeiden — sie sind verborgen, schwer zu debuggen und können kaskadierende Effekte verursachen. Jede Datenbank hat unterschiedliche Trigger-Syntax; PostgreSQL verwendet Funktionen als Trigger-Bodies.
-- A trigger fires automatically on INSERT/UPDATE/DELETE
-- MySQL syntax:
DELIMITER //
CREATE TRIGGER audit_log
AFTER INSERT ON employees
FOR EACH ROW
BEGIN
INSERT INTO audit_table (table_name, action, row_id, changed_at)
VALUES ('employees', 'INSERT', NEW.id, NOW());
END //
DELIMITER ;
-- BEFORE triggers can modify the NEW values:
CREATE TRIGGER validate_email
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
IF NEW.email NOT LIKE '%@%.%' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid email';
END IF;
END //
-- PostgreSQL uses CREATE FUNCTION + CREATE TRIGGER:
-- CREATE TRIGGER audit AFTER INSERT ON employees
-- FOR EACH ROW EXECUTE FUNCTION audit_func();Audit-Logging-Trigger
Audit-Trigger erfassen jede Datenänderung für Compliance und Debugging. Die Audit-Tabelle speichert den Action-Typ, alte und neue Werte, wer die Änderung gemacht hat (CURRENT_USER), und wann (CURRENT_TIMESTAMP). Separate Trigger für INSERT, UPDATE und DELETE benötigt. OLD referenziert pre-Change-Werte (verfügbar in UPDATE/DELETE), NEW referenziert post-Change-Werte (verfügbar in INSERT/UPDATE). Audit-Tabellen wachsen unbegrenzt — nach Datum partitionieren oder alte Daten archivieren. Dieses Pattern erfüllt SOX-, HIPAA- und GDPR-Anforderungen an Datenänderungs-Tracking.
-- Track all changes to a critical table
CREATE TABLE employee_audit (
audit_id INT AUTO_INCREMENT PRIMARY KEY,
action VARCHAR(10), -- INSERT, UPDATE, DELETE
employee_id INT,
old_name VARCHAR(100),
new_name VARCHAR(100),
old_salary DECIMAL(10,2),
new_salary DECIMAL(10,2),
changed_by VARCHAR(50) DEFAULT CURRENT_USER,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER trg_audit_insert
AFTER INSERT ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, new_name, new_salary)
VALUES ('INSERT', NEW.id, NEW.name, NEW.salary);
CREATE TRIGGER trg_audit_update
AFTER UPDATE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, new_name, old_salary, new_salary)
VALUES ('UPDATE', NEW.id, OLD.name, NEW.name, OLD.salary, NEW.salary);
CREATE TRIGGER trg_audit_delete
AFTER DELETE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, old_salary)
VALUES ('DELETE', OLD.id, OLD.name, OLD.salary);Berechnete/Abgeleitete Spalten-Trigger
Trigger können abgeleitete Spalten auto-berechnen und stellen Konsistenz ohne App-Code sicher. BEFORE INSERT/UPDATE-Trigger setzen NEW.final_price basierend auf anderen Spalten. Moderne Datenbanken unterstützen jedoch GENERATED (berechnete) Spalten nativ — diese sind immer korrekt, können nicht manuell überschrieben werden und sind indizierbar. GENERATED-Spalten gegenüber Triggern für berechnete Werte bevorzugen. Trigger nur verwenden, wenn die Berechnung externe Daten, bedingte Logik oder Cross-Tabellen-Abhängigkeiten beinhaltet, die GENERATED-Spalten nicht handhaben können. Denken daran, dass Trigger Overhead zu jeder Schreib-Operation hinzufügen.
-- Auto-update a derived column when source data changes
CREATE TABLE products (
id INT PRIMARY KEY,
price DECIMAL(10,2),
discount_percent DECIMAL(5,2),
final_price DECIMAL(10,2) -- computed: price * (1 - discount/100)
);
-- BEFORE INSERT: compute final_price
CREATE TRIGGER calc_final_price_insert
BEFORE INSERT ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);
-- BEFORE UPDATE: recompute if price or discount changes
CREATE TRIGGER calc_final_price_update
BEFORE UPDATE ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);
-- Alternative: use GENERATED columns (MySQL 5.7+, PostgreSQL 12+)
-- final_price DECIMAL(10,2) GENERATED ALWAYS AS
-- (price * (1 - discount_percent / 100)) STOREDLöschungen mit Triggern verhindern
Trigger können Datenschutzregeln erzwingen, die CHECK-Constraints nicht ausdrücken können. BEFORE DELETE-Trigger können Löschungen komplett blockieren (mit SIGNAL/RAISE) oder Soft Deletes implementieren (Datensätze als gelöscht markieren statt entfernen). SIGNAL SQLSTATE '45000' ist der MySQL-Weg, einen benutzerdefinierten Fehler zu raisen. PostgreSQL verwendet RAISE EXCEPTION. Nützlich zum Schützen von Referenzdaten, Verhindern des Löschens von Eltern-Datensätzen mit Kindern oder Implementieren unveränderlicher Audit-Trails. Vorsicht: Trigger, die Operationen verhindern, können Entwickler überraschen — klar dokumentieren und App-Level-Checks in Betracht ziehen.
-- Prevent deletion of critical records
CREATE TRIGGER prevent_delete_admin
BEFORE DELETE ON users
FOR EACH ROW
BEGIN
IF OLD.role = 'admin' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Cannot delete admin users';
END IF;
END //
-- Soft delete instead of hard delete
CREATE TRIGGER soft_delete
BEFORE DELETE ON articles
FOR EACH ROW
BEGIN
-- Prevent actual deletion, mark as deleted instead
INSERT INTO articles (id, title, body, deleted_at)
VALUES (OLD.id, OLD.title, OLD.body, NOW())
ON DUPLICATE KEY UPDATE deleted_at = NOW();
-- Still need to prevent the DELETE — use SIGNAL or a flag
END //
-- PostgreSQL: raise exception in trigger function
-- IF OLD.role = 'admin' THEN
-- RAISE EXCEPTION 'Cannot delete admin users';
-- END IF;Trigger-Management & Debugging
Trigger-Management ist essenziell für Wartung. SHOW TRIGGERS (MySQL) und information_schema-Views listen alle Trigger. Trigger mit DROP TRIGGER IF EXISTS droppen. Temporäres Deaktivieren von Triggern ist nützlich für Bulk-Daten-Loads (die teure Audit/Logging für jede Zeile triggern würden). PostgreSQL verwendet ALTER TABLE ... DISABLE/ENABLE TRIGGER; SQL Server verwendet DISABLE/ENABLE TRIGGER. Trigger nach Wartung immer wieder aktivieren. Trigger-Debugging ist schwer — sie laufen still. Logging zu einer Debug-Tabelle hinzufügen oder Trigger-Logik zuerst isoliert testen. Übermäßige Trigger erstellen verborgene Komplexität und Performance-Probleme.
-- View existing triggers
-- MySQL:
SHOW TRIGGERS;
SELECT * FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'mydb';
-- PostgreSQL:
SELECT tgname, tgrelid::regclass, tgtype FROM pg_trigger;
-- SQL Server:
SELECT name, type_desc FROM sys.triggers WHERE parent_id = OBJECT_ID('employees');
-- Drop a trigger
DROP TRIGGER IF EXISTS audit_log; -- MySQL
DROP TRIGGER IF EXISTS audit_log ON employees; -- PostgreSQL
-- Disable/enable triggers (PostgreSQL)
ALTER TABLE employees DISABLE TRIGGER ALL;
ALTER TABLE employees ENABLE TRIGGER ALL;
-- Disable for bulk operations (SQL Server)
DISABLE TRIGGER trg_audit ON employees;
-- ... bulk operations ...
ENABLE TRIGGER trg_audit ON employees;User-Defined Functions (UDFs)
Skalar-Funktionen (Einzelnen Wert zurückgeben)
Skalar-UDFs geben einen einzelnen Wert zurück und können in SELECT, WHERE und berechneten Spalten verwendet werden. DETERMINISTIC bedeutet, die Ausgabe hängt nur von Eingaben ab (ermöglicht Caching). READS SQL DATA deklariert, dass die Funktion aus Tabellen liest. UDFs kapseln wiederverwendbare Logik (Rabatte, Formatierung, Berechnungen), sodass sie über Queries hinweg konsistent ist. Skalar-UDFs in SQL Server können jedoch Performance-Probleme verursachen (Zeile-für-Zeile-Ausführung) — Inline Table-Valued Functions oder berechnete Spalten verwenden, wenn möglich. MySQL 8.0+ optimiert deterministische Funktionen besser. Zweck und Parameter der Funktion immer dokumentieren.
-- MySQL: a function that returns one value
CREATE FUNCTION calculate_discount(
price DECIMAL(10,2),
customer_tier VARCHAR(20)
) RETURNS DECIMAL(10,2)
DETERMINISTIC
READS SQL DATA
BEGIN
DECLARE discount_rate DECIMAL(5,2);
SET discount_rate = CASE customer_tier
WHEN 'gold' THEN 0.20
WHEN 'silver' THEN 0.10
WHEN 'bronze' THEN 0.05
ELSE 0.00
END;
RETURN price * (1 - discount_rate);
END //
-- Usage in queries:
SELECT name, price, calculate_discount(price, tier) AS final_price
FROM orders;
-- DETERMINISTIC: same inputs always give same output (cacheable)
-- READS SQL DATA: function reads but doesn't modify tablesTable-Valued Functions (Zeilen zurückgeben)
Table-Valued Functions (TVFs) geben eine Result-Menge (Zeilen) zurück, die man wie eine Tabelle abfragen kann. Inline TVFs (SQL Server) sind so schnell wie Views — der Query-Optimizer inlined sie. Multi-Statement TVFs materialisieren Ergebnisse zuerst in eine Temp-Tabelle, was langsamer sein kann. PostgreSQL-Funktionen, die TABLE oder SETOF zurückgeben, sind äquivalent. TVFs sind parametrisierte Views — verwenden, wenn man einen View mit Parametern braucht. Großartig zum Kapseln komplexer JOINs und Filter. Inline TVFs gegenüber Multi-Statement TVFs für Performance bevorzugen. In PostgreSQL auch parametrisierte Views mit WHERE-Klauseln in Betracht ziehen.
-- SQL Server: Inline table-valued function (fast, like a view)
CREATE FUNCTION fn_OrdersByCustomer(@cust_id INT)
RETURNS TABLE
AS
RETURN (
SELECT o.id, o.order_date, o.total
FROM orders o
WHERE o.customer_id = @cust_id
);
-- Usage: SELECT * FROM fn_OrdersByCustomer(42);
-- PostgreSQL: function returning a table
CREATE OR REPLACE FUNCTION get_orders_by_customer(cust_id INT)
RETURNS TABLE(order_id INT, order_date DATE, total DECIMAL) AS $$
SELECT id, order_date, total
FROM orders
WHERE customer_id = cust_id;
$$ LANGUAGE SQL;
-- Usage:
SELECT * FROM get_orders_by_customer(42);
-- Multi-statement TVF (SQL Server) — slower, materializes result
-- CREATE FUNCTION fn_ComplexReport(@date DATE)
-- RETURNS @result TABLE (...)
-- AS BEGIN ... INSERT INTO @result ... RETURN ENDString-Manipulations-Funktionen
Benutzerdefinierte String-Funktionen kapseln Textverarbeitungs-Logik, die eingebaute Funktionen nicht abdecken. Die get_first_name-Funktion verwendet LOCATE und SUBSTRING, um das erste Wort zu extrahieren. Die make_slug-Funktion verkettet LOWER, REPLACE und REGEXP_REPLACE, um URL-freundliche Slugs zu erstellen. Als DETERMINISTIC markieren, da dieselbe Eingabe immer dieselbe Ausgabe produziert. String-Funktionen in SQL sind datenbankspezifisch — PostgreSQL hat split_part(), MySQL hat SUBSTRING_INDEX(). UDFs zu erstellen standardisiert Verhalten über die App hinweg. Beachten, dass komplexe String-Manipulation in SQL oft sauberer in App-Code ist.
-- Create a function to split full name into parts
CREATE FUNCTION get_first_name(full_name VARCHAR(200))
RETURNS VARCHAR(100)
DETERMINISTIC
BEGIN
DECLARE space_pos INT;
SET space_pos = LOCATE(' ', full_name);
IF space_pos > 0 THEN
RETURN SUBSTRING(full_name, 1, space_pos - 1);
ELSE
RETURN full_name;
END IF;
END //
-- Function to generate slug from a title
CREATE FUNCTION make_slug(title VARCHAR(500))
RETURNS VARCHAR(500)
DETERMINISTIC
BEGIN
DECLARE slug VARCHAR(500);
SET slug = LOWER(title);
SET slug = REPLACE(slug, ' ', '-');
SET slug = REGEXP_REPLACE(slug, '[^a-z0-9-]', '');
RETURN slug;
END //
-- Usage:
SELECT get_first_name('John Doe Smith') AS first_name; -- John
SELECT make_slug('Hello World! 2024') AS slug; -- hello-world-2024Aggregatfunktionen (Benutzerdefiniert)
Benutzerdefinierte Aggregatfunktionen lassen dich neue Aggregations-Logik jenseits von SUM, AVG, COUNT definieren. PostgreSQLs CREATE AGGREGATE erfordert eine State-Transition-Funktion (SFUNC, pro Zeile aufgerufen) und eine Final-Funktion (FINALFUNC, einmal am Ende aufgerufen). Dieses Beispiel berechnet das geometrische Mittel (die n-te Wurzel des Produkts). Benutzerdefinierte Aggregate sind mächtig für statistische, finanzielle oder domänenspezifische Berechnungen. Der State akkumuliert über Zeilen; die Final-Funktion berechnet das Ergebnis. MySQL und SQL Server unterstützen benutzerdefinierte Aggregate nicht direkt — Stored Procedures oder App-seitige Berechnung verwenden.
-- PostgreSQL: custom aggregate function
-- Step 1: Define a state transition function
CREATE OR REPLACE FUNCTION geom_mean_state(state numeric[], val numeric)
RETURNS numeric[] AS $$
BEGIN
IF val IS NULL THEN RETURN state; END IF;
IF state IS NULL THEN
RETURN ARRAY[val];
ELSE
RETURN state || val;
END IF;
END;
$$ LANGUAGE plpgsql;
-- Step 2: Define the final function
CREATE OR REPLACE FUNCTION geom_mean_final(vals numeric[])
RETURNS numeric AS $$
BEGIN
IF vals IS NULL OR array_length(vals, 1) IS NULL THEN
RETURN NULL;
END IF;
RETURN exp(avg(ln(v)) FROM unnest(vals) AS v);
END;
$$ LANGUAGE plpgsql;
-- Step 3: Create the aggregate
CREATE AGGREGATE geometric_mean(numeric) (
SFUNC = geom_mean_state,
STYPE = numeric[],
FINALFUNC = geom_mean_final,
INITCOND = '{}'
);
-- Usage:
SELECT category, geometric_mean(price) FROM products GROUP BY category;Funktion vs. Stored Procedure
Funktionen und Stored Procedures dienen unterschiedlichen Zwecken. Funktionen geben einen Wert zurück und können in SELECT/WHERE eingebettet werden — sie müssen deterministisch-ish sein (keine Side Effects in den meisten Datenbanken). Stored Procedures können Daten modifizieren, Transaktionen verwalten und mehrere Result-Mengen zurückgeben — können aber nicht in Queries verwendet werden (mit CALL/EXEC aufrufen). Funktionen für Berechnungen und Datenabruf verwenden; Procedures für Multi-Step-Operationen (Transfers, Batch-Processing, ETL). Funktionen sind komponierbar; Procedures sind imperativ. In PostgreSQL können Funktionen fast alles, was Procedures können (inklusive Datenmodifikation), was die Unterscheidung verwischt.
-- FUNCTIONS: return values, usable in queries, no side effects
CREATE FUNCTION get_full_name(fname VARCHAR, lname VARCHAR)
RETURNS VARCHAR(200)
DETERMINISTIC
RETURN CONCAT(fname, ' ', lname);
-- Can be used in SELECT:
SELECT get_full_name(first, last) FROM users; -- OK
SELECT * FROM users WHERE get_full_name(first, last) LIKE 'J%'; -- OK
-- STORED PROCEDURES: can modify data, use transactions, return result sets
CREATE PROCEDURE transfer_funds(
IN from_acct INT, IN to_acct INT, IN amount DECIMAL(10,2)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
UPDATE accounts SET balance = balance - amount WHERE id = from_acct;
UPDATE accounts SET balance = balance + amount WHERE id = to_acct;
INSERT INTO transfers (from_id, to_id, amount) VALUES (from_acct, to_acct, amount);
COMMIT;
END //
-- Call procedure (can't use in SELECT):
CALL transfer_funds(1, 2, 100.00);Datenbankdesign & Normalisierung
Erste Normalform (1NF)
Erste Normalform erfordert atomare Werte — jede Zelle hält ein Stück Daten, nicht Listen oder Arrays. Komma-separierte Werte in einer Spalte verletzen 1NF, weil man einzelne Items nicht abfragen, indizieren oder aktualisieren kann. Die Lösung: eine Zeile pro Item (mit einem Composite Primary Key) erstellen oder in eine separate Detail-Tabelle aufspalten. 1NF erfordert auch einen Primary Key, um jede Zeile eindeutig zu identifizieren. 1NF-Verletzung macht Queries wie 'finde alle Bestellungen, die eine Maus enthalten' String-Parsing erforderlich — langsam und fehleranfällig. Immer mit 1NF-Compliance beginnen.
-- 1NF: each column contains atomic (indivisible) values
-- no repeating groups, each row is unique
-- VIOLATION: comma-separated values in one column
CREATE TABLE bad_orders (
id INT,
customer_name VARCHAR(100),
products VARCHAR(500) -- "laptop, mouse, keyboard" — BAD!
);
-- 1NF COMPLIANT: separate row per product
CREATE TABLE orders (
order_id INT,
customer_name VARCHAR(100),
product_name VARCHAR(100), -- one product per row
PRIMARY KEY (order_id, product_name) -- composite key for uniqueness
);
-- Better: normalize further with separate tables
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_name VARCHAR(100));
CREATE TABLE order_items (
order_id INT REFERENCES orders(order_id),
product_name VARCHAR(100),
quantity INT,
PRIMARY KEY (order_id, product_name)
);Zweite & Dritte Normalform (2NF, 3NF)
2NF eliminiert partielle Abhängigkeiten — jede Non-Key-Spalte muss vom GESAMTEN Primary Key abhängen, nicht nur von einem Teil. Dies matters nur mit Composite Keys. 3NF eliminiert transitive Abhängigkeiten — Non-Key-Spalten müssen nur vom Primary Key abhängen, nicht von anderen Non-Key-Spalten. Zum Beispiel hängt customer_name von customer_id ab, die von order_id abhängt (transitiv). Normalisierung reduziert Datenredundanz (jeden Fakt einmal speichern) und Anomalien (Kundenname an einem Ort aktualisieren, nicht in jeder Bestellung). Die meisten praktischen Datenbanken streben 3NF oder BCNF an.
-- 2NF: 1NF + no partial dependencies (non-key attrs depend on FULL key)
-- Problem: order_items has composite key (order_id, product_id)
-- but product_name depends only on product_id (partial dependency)
-- VIOLATION of 2NF:
CREATE TABLE bad_order_items (
order_id INT,
product_id INT,
product_name VARCHAR(100), -- depends on product_id only!
quantity INT,
PRIMARY KEY (order_id, product_id)
);
-- 2NF COMPLIANT: move product_name to products table
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100)
);
CREATE TABLE order_items (
order_id INT,
product_id INT REFERENCES products(product_id),
quantity INT,
PRIMARY KEY (order_id, product_id)
);
-- 3NF: 2NF + no transitive dependencies
-- (non-key attrs don't depend on other non-key attrs)
-- Problem: orders table has customer_name that depends on customer_id
-- Solution: separate customers table (see above)Denormalisierung (Wann Regeln brechen)
Denormalisierung verletzt absichtlich Normalformen, um Lese-Performance auf Kosten von Schreib-Komplexität und Speicher zu verbessern. In normalisierten Datenbanken erfordert das Abrufen einer kompletten Bestellung 4 JOINs — teuer für High-Traffic-Dashboards. Denormalisierte Tabellen pre-joinen und pre-computen Daten für schnelle Lesezugriffe. Der Trade-off: Writes müssen mehrere Orte aktualisieren (Inkonsistenz-Risiko), und Speicher nimmt zu. Denormalisierung für Lese-intensive Systeme (Analytics, Reporting, Data Warehouses) verwenden. Materialized Views bieten gemanagte Denormalisierung — die Datenbank handhabt den Refresh. OLTP-Systeme sollten normalisiert bleiben; OLAP-Systeme sind typischerweise denormalisiert (Star/Snowflake-Schemas).
-- Denormalization: intentionally adding redundancy for performance
-- Normalized (3NF): requires JOINs to get full order info
SELECT o.order_id, c.name, p.product_name, oi.quantity, p.price
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.id;
-- Denormalized: store redundant data for read speed
CREATE TABLE order_summary (
order_id INT PRIMARY KEY,
customer_id INT,
customer_name VARCHAR(100), -- redundant (also in customers)
customer_email VARCHAR(200), -- redundant
total_amount DECIMAL(10,2), -- pre-calculated
item_count INT, -- pre-calculated
order_date TIMESTAMP
);
-- Trade-off: faster reads, slower writes, risk of inconsistency
-- Use for: reporting tables, read-heavy dashboards, data warehouses
-- Materialized views are a managed form of denormalization:
CREATE MATERIALIZED VIEW sales_summary AS
SELECT date, region, SUM(amount) AS total FROM sales GROUP BY date, region;
REFRESH MATERIALIZED VIEW sales_summary;Primary Keys, Foreign Keys & Constraints
Constraints erzwingen Datenintegrität auf Datenbank-Level. PRIMARY KEY identifiziert Zeilen eindeutig und erstellt einen Clustered Index. UNIQUE verhindert Duplikate (erlaubt mehrere NULLs in den meisten Datenbanken). CHECK erzwingt benutzerdefinierte Regeln (salary > 0). FOREIGN KEY wahrt referenzielle Integrität — ON DELETE SET NULL/CASCADE/RESTRICT steuert, was passiert, wenn eine Eltern-Zeile gelöscht wird. ON UPDATE CASCADE propagiert PK-Änderungen zu FKs. Constraints sind die letzte Verteidigungslinie gegen schlechte Daten — selbst wenn App-Code Bugs hat, lehnt die Datenbank ungültige Daten ab. Constraints immer definieren; sie sind Dokumentation und Erzwingung kombiniert.
CREATE TABLE departments (
dept_id INT PRIMARY KEY AUTO_INCREMENT,
dept_name VARCHAR(100) NOT NULL UNIQUE,
budget DECIMAL(12,2) CHECK (budget >= 0),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE employees (
emp_id INT PRIMARY KEY AUTO_INCREMENT,
emp_name VARCHAR(100) NOT NULL,
email VARCHAR(200) UNIQUE,
dept_id INT,
salary DECIMAL(10,2) CHECK (salary > 0 AND salary < 1000000),
hire_date DATE NOT NULL,
manager_id INT REFERENCES employees(emp_id), -- self-reference
FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
ON DELETE SET NULL -- don't delete dept if employees exist
ON UPDATE CASCADE, -- update FK if dept_id changes
INDEX idx_dept (dept_id),
INDEX idx_email (email)
);
-- Constraint types:
-- PRIMARY KEY: unique + not null (clustered index)
-- UNIQUE: no duplicates (allows NULLs)
-- NOT NULL: required field
-- CHECK: custom condition
-- FOREIGN KEY: referential integrity
-- DEFAULT: value when not specifiedIndexierungs-Strategie
Indexes beschleunigen Lesezugriffe drastisch, verlangsamen aber Writes (jeder Index muss bei INSERT/UPDATE/DELETE aktualisiert werden). B-Tree-Indexes unterstützen Gleichheit, Bereich und Sortierung. Composite Indexes folgen der Leftmost-Prefix-Regel — man kann (a, b) für Queries auf a oder a+b verwenden, aber nicht b allein. Covering Indexes (INCLUDE-Klausel) speichern zusätzliche Spalten, sodass die Query die Tabelle nie berührt — extrem schnell. Partial Indexes indizieren nur eine Teilmenge von Zeilen und sparen Platz. Index-Nutzung überwachen (pg_stat_user_indexes in PostgreSQL) und ungenutzte droppen. Eine gute Regel: Foreign Keys und Spalten in WHERE/JOIN-Klauseln indizieren. Über-Indexierung verletzt Write-Performance und verschwendet Speicher.
-- B-tree index (default): good for =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_last_name ON employees(last_name);
-- Composite index: order matters! (leftmost prefix rule)
CREATE INDEX idx_dept_salary ON employees(dept_id, salary);
-- Usable for: WHERE dept_id = 5
-- Usable for: WHERE dept_id = 5 AND salary > 50000
-- NOT usable for: WHERE salary > 50000 (skips dept_id)
-- Covering index: includes all columns a query needs
CREATE INDEX idx_covering ON orders(customer_id, order_date)
INCLUDE (total_amount, status);
-- Query can be satisfied from index alone (no table lookup)
-- Partial/partial index: index only matching rows
CREATE INDEX idx_active_users ON users(last_login)
WHERE active = true; -- PostgreSQL
-- Don't over-index: every index slows writes
-- Index columns used in: WHERE, JOIN, ORDER BY, GROUP BY
-- Drop unused indexes: SELECT * FROM pg_stat_user_indexes;EXPLAIN & Query-Optimierung
EXPLAIN-Output lesen
EXPLAIN offenbart, wie die Datenbank eine Query ausführt — welche Indexes verwendet werden, wie Tabellen gejoint werden und wie viele Zeilen untersucht werden. EXPLAIN ANALYZE (PostgreSQL) oder EXPLAIN mit Ausführung (MySQL 8.0+) führt die Query tatsächlich aus und zeigt echtes Timing. Suchen nach: Seq Scan / ALL (Full-Table-Scan — schlecht für große Tabellen), Index Scan (gut), Rows-Estimate (hoch = teuer). 'Using filesort' oder 'Using temporary' in MySQL zeigt zusätzliche Arbeit. Wenn EXPLAIN einen Full-Table-Scan auf einer großen Tabelle zeigt, wird ein Index benötigt. Immer EXPLAIN vor dem Optimieren — nicht raten.
-- EXPLAIN shows the query execution plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- EXPLAIN ANALYZE actually runs the query (PostgreSQL)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- PostgreSQL output:
-- Index Scan using idx_customer on orders (cost=0.29..8.31 rows=1 width=74)
-- Index Cond: (customer_id = 42)
-- Execution Time: 0.042 ms
-- Key columns to check:
-- - type: scan method (const > eq_ref > ref > range > index > ALL)
-- - rows: estimated rows examined (lower is better)
-- - key: which index is used (NULL = no index, bad!)
-- - Extra: "Using filesort" or "Using temporary" = warning signs
-- MySQL: EXPLAIN FORMAT=JSON for detailed output
-- SQL Server: SET SHOWPLAN_TEXT ON; or Actual Execution Plan in SSMSHäufige Performance-Probleme
Mehrere häufige Patterns verhindern Index-Nutzung und verursachen Full-Table-Scans. Funktionen auf indizierten Spalten (YEAR(date), UPPER(name)) verhindern Index-Nutzung — als Bereichs-Bedingungen umschreiben. Führende Wildcards in LIKE ('%pattern') können keine B-Tree-Indexes verwenden — stattdessen Full-Text-Search verwenden. SELECT * verschwendet Bandbreite und verhindert Covering-Index-Optimierung. Implizite Typ-Konvertierungen (String-Spalte mit Integer vergleichen) können Indexes deaktivieren. OR-Bedingungen sind manchmal weniger effizient als IN. Immer mit EXPLAIN verifizieren, dass Indexes tatsächlich verwendet werden — ein ungenutzter Index ist verschwendeter Speicher und Write-Overhead.
-- 1. Missing index → full table scan
-- Bad: SELECT * FROM orders WHERE customer_id = 42; (no index)
-- Fix: CREATE INDEX idx_customer ON orders(customer_id);
-- 2. Index not used due to function on column
-- Bad: WHERE YEAR(order_date) = 2024 (function prevents index use)
-- Good: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'
-- 3. SELECT * instead of specific columns
-- Bad: SELECT * FROM large_table WHERE id = 1;
-- Good: SELECT id, name, email FROM large_table WHERE id = 1;
-- 4. OR conditions preventing index use
-- Bad: WHERE dept = 'A' OR dept = 'B' OR dept = 'C'
-- Good: WHERE dept IN ('A', 'B', 'C')
-- 5. LIKE with leading wildcard
-- Bad: WHERE name LIKE '%son' (can't use index)
-- OK: WHERE name LIKE 'John%' (can use index)
-- 6. Implicit type conversion
-- Bad: WHERE string_column = 123 (converts to string, skips index)
-- Good: WHERE string_column = '123'JOIN-Optimierung
JOIN-Optimierung ist kritisch für Multi-Tabellen-Queries. Sicherstellen, dass Join-Spalten (normalerweise Foreign Keys) indiziert sind — unindizierte Joins verursachen Nested-Loop-Scans (O(n*m)). Der Query-Optimizer wählt normalerweise die beste Join-Reihenfolge, aber man kann helfen, indem man früh filtert (WHERE konzeptionell vor JOIN). INNER JOIN ist schneller als OUTER JOIN, wenn man nicht-matchende Zeilen nicht braucht. EXISTS ist oft effizienter als IN für korrelierte Subqueries, weil es beim ersten Match kurzschließt. Das Joinen von Tabellen vermeiden, die man nicht braucht — jeder Join multipliziert die Arbeit. Für komplexe Berichte Materialized Views oder pre-aggregierte Summary-Tabellen in Betracht ziehen.
-- Join order matters: smallest table first (optimizer usually handles this)
-- Ensure join columns are indexed (usually foreign keys)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);
-- Use INNER JOIN when you don't need unmatched rows (faster than OUTER)
SELECT c.name, COUNT(o.order_id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;
-- Avoid joining unnecessary tables — fetch details lazily if needed
-- Bad: join 5 tables when you only need 2 columns
-- Good: split into simpler queries or use a covering index
-- EXISTS vs IN for subqueries
-- EXISTS is often faster for large subquery results:
SELECT name FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- IN is better for small lists:
SELECT * FROM orders WHERE customer_id IN (1, 2, 3);Paginierungs-Optimierung
OFFSET-basierte Paginierung (LIMIT 10 OFFSET 10000) ist O(n) — die Datenbank muss alle übersprungenen Zeilen scannen und verwerfen, was tiefe Seiten extrem langsam macht. Keyset-(Cursor-)Paginierung verwendet WHERE last_value < cursor, um direkt zu seeken — O(1) unabhängig von Seitentiefe. Dies erfordert einen Index auf der Sort-Spalte. Bei Gleichständen (gleicher Zeitstempel) einen Composite Cursor (created_at, id) verwenden. COUNT(*) für Gesamtanzahlen auf großen Tabellen vermeiden — es scannt die gesamte Tabelle. Approximative Counts (pg_class.reltuples in PostgreSQL) verwenden oder Gesamtanzahlen gar nicht anzeigen (Infinite Scroll). Keyset-Paginierung ist der Standard für High-Performance-APIs.
-- Bad: OFFSET pagination (slow for large offsets)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10000;
-- Must scan and discard 10000 rows — gets slower as you page deeper
-- Good: Keyset (cursor) pagination using WHERE
SELECT * FROM orders
WHERE created_at < '2024-01-15 10:30:00' -- last seen value
ORDER BY created_at DESC
LIMIT 10;
-- Uses index efficiently — constant time regardless of page depth
-- For composite keyset pagination (handles ties):
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- Count total (expensive on large tables — avoid if possible)
-- Instead of COUNT(*), use an approximate count:
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';Query-Umschreibung & Optimierungs-Checkliste
Query-Optimierung ist ein iterativer Prozess: EXPLAIN, Bottlenecks identifizieren, umschreiben, wiederholen. Schlüsseltechniken: IN-Subqueries durch JOINs ersetzen (oft schneller), UNION ALL statt UNION verwenden (überspringt Dedup-Sort), INSERTs batchen (1 Query vs 1000), und Prepared Statements verwenden (cacht den Query-Plan). CTEs verbessern Lesbarkeit, aber in älteren PostgreSQL-Versionen werden sie materialisiert (können nicht optimiert werden) — PostgreSQL 12+ inlined sie. Tabellen-Statistiken aktuell halten (ANALYZE), sodass der Planner gute Entscheidungen trifft. Die goldene Regel: mit EXPLAIN ANALYZE messen, nicht raten. Was auf einer Datenbank/Version schnell ist, kann auf einer anderen langsam sein.
-- 1. Replace subqueries with JOINs when possible
-- Slow: SELECT * FROM orders WHERE customer_id IN
-- (SELECT id FROM customers WHERE active = true);
-- Fast: SELECT o.* FROM orders o
-- JOIN customers c ON o.customer_id = c.id WHERE c.active = true;
-- 2. Use UNION ALL instead of UNION (avoids dedup sort)
SELECT 'A' UNION ALL SELECT 'B'; -- fast
SELECT 'A' UNION SELECT 'B'; -- sorts to remove duplicates
-- 3. Batch operations instead of row-by-row
-- Slow: 1000 individual INSERTs
-- Fast: INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c'), ...;
-- 4. Use CTEs for readability, but know they may not optimize well
-- (PostgreSQL 12+ inlines CTEs; older versions materialize them)
-- 5. Avoid SELECT DISTINCT when you can use GROUP BY or EXISTS
-- 6. Use prepared statements for repeated queries (plan caching)
PREPARE get_user AS SELECT * FROM users WHERE id = $1;
EXECUTE get_user(42);
-- 7. Analyze tables for up-to-date statistics
ANALYZE orders; -- PostgreSQL: updates planner statisticsNoSQL vs. SQL Vergleich
SQL vs. NoSQL: Wann was verwenden
Die SQL-vs.-NoSQL-Wahl hängt vom Datenmodell, Konsistenzanforderungen und Skalierung ab. SQL-Datenbanken erzwingen Schema, unterstützen ACID-Transaktionen und exzellieren bei komplexen Queries mit JOINs — ideal für Finanzsysteme und jede App, wo Datenintegrität paramount ist. NoSQL-Datenbanken tauschen Konsistenz gegen Skalierbarkeit und Flexibilität: Document Stores (MongoDB) für evolving Schemas, Key-Value-Stores (Redis) für Caching, Column-Family (Cassandra) für massive Write-Throughput, und Graph-Datenbanken (Neo4j) für beziehungslastige Daten. Moderne SQL-Datenbanken unterstützen jetzt JSON, Full-Text-Search und Skalierung, was die Notwendigkeit für NoSQL in vielen Fällen reduziert.
-- SQL (Relational): structured, consistent, queryable
-- Best for: financial systems, e-commerce, CRM, any ACID-requiring app
-- Examples: PostgreSQL, MySQL, SQL Server, Oracle
-- NoSQL: flexible schema, horizontal scaling, specific data models
-- Document: MongoDB, CouchDB — JSON-like documents, flexible schema
-- Key-Value: Redis, DynamoDB — fast lookups, caching, sessions
-- Column-Family: Cassandra, HBase — wide-column, time-series, write-heavy
-- Graph: Neo4j, ArangoDB — relationships, social networks, recommendations
-- Decision factors:
-- 1. Data structure: fixed → SQL, evolving/varied → NoSQL
-- 2. Consistency: strict ACID → SQL, eventual consistency OK → NoSQL
-- 3. Scale: vertical (bigger server) → SQL, horizontal (more servers) → NoSQL
-- 4. Queries: complex JOINs → SQL, simple lookups → NoSQL
-- 5. Team expertise: SQL is universal, NoSQL varies
-- Many modern databases blur the line:
-- PostgreSQL: JSON columns, full-text search, pub/sub
-- MongoDB: transactions (multi-document ACID since 4.0)Document-Store-Patterns (MongoDB-Stil)
Document Stores betten verwandte Daten in einem einzelnen Dokument ein, anstatt über Tabellen zu normalisieren. Dies eliminiert JOINs für Lese-intensive Zugriffsmuster, dupliziert aber Daten (Kundeninfo in jeder Bestellung). Embedding funktioniert, wenn Daten zusammen abgerufen werden und eine begrenzte Größe haben. Für unbegrenzte Beziehungen (ein Kunde mit Tausenden Bestellungen) Referencing verwenden (customer_id speichern, separat abrufen). PostgreSQLs JSONB-Spalten geben Document-Store-Flexibilität innerhalb einer relationalen Datenbank — man bekommt ACID-Transaktionen, Indexierung (GIN) und SQL-Queries auf JSON. Dieser hybride Ansatz wird zunehmend populär und reduziert die Notwendigkeit für eine separate NoSQL-Datenbank.
-- In SQL, you'd normalize this into 3 tables:
-- customers, orders, order_items
-- In a document store (MongoDB), you might embed everything:
-- (pseudo-code, not SQL)
// db.orders.insertOne({
// _id: 1,
// customer: { name: "Alice", email: "[email protected]" },
// items: [
// { product: "Laptop", price: 999, qty: 1 },
// { product: "Mouse", price: 25, qty: 2 }
// ],
// total: 1049,
// status: "shipped",
// created_at: ISODate("2024-01-15")
// })
-- SQL equivalent with JSON column (PostgreSQL):
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
data JSONB NOT NULL -- stores the entire document
);
INSERT INTO orders (data) VALUES ('{
"customer": {"name": "Alice", "email": "[email protected]"},
"items": [{"product": "Laptop", "price": 999, "qty": 1}],
"total": 999
}');
-- Query JSON data:
SELECT data->'customer'->>'name' AS name FROM orders
WHERE data->'total' > 500;Key-Value-Store-Patterns (Redis-Stil)
Key-Value-Stores wie Redis exzellieren bei Ultra-schnellen Lookups (Sub-Millisekunde), weil Daten im Memory leben. Häufige Verwendungen: teure Query-Ergebnisse cachen, Session-Storage (mit TTL-Expiration), Real-Time-Counter (atomares INCR), und Leaderboards (Sorted Sets). Redis-Datenstrukturen (Lists, Sets, Sorted Sets, Hashes) gehen über einfaches Key-Value hinaus. Der Trade-off: Daten sind In-Memory (limitiert durch RAM) und Persistenz ist optional. Redis als Cache-Layer vor SQL verwenden — Write-Through- oder Cache-Aside-Patterns. Für Session-Daten ist Redis' automatische Expiration (TTL) ideal. SQL-Datenbanken können Caching mit einer Cache-Tabelle emulieren, können aber nicht Redis' Geschwindigkeit für Hot Data matchen.
-- Redis is an in-memory key-value store — not SQL
-- Common patterns (Redis commands, not SQL):
-- Caching: store expensive query results
// SET user:42:profile '{"name":"Alice","age":30}' EX 3600
// GET user:42:profile -- returns cached data, expires in 1 hour
-- Session storage: fast, ephemeral
// SET session:abc123 '{"user_id":42}' EX 1800 -- 30 min TTL
-- Counters & leaderboards:
// INCR page:home:views -- atomic counter
// ZADD leaderboard 1500 "alice" -- sorted set for rankings
// ZREVRANGE leaderboard 0 9 -- top 10 players
-- SQL equivalent for caching (materialized/precomputed):
CREATE TABLE cache (
cache_key VARCHAR(200) PRIMARY KEY,
cache_value TEXT,
expires_at TIMESTAMP,
INDEX idx_expires (expires_at)
);
-- Periodically: DELETE FROM cache WHERE expires_at < NOW();
-- When to use Redis vs SQL cache:
-- Redis: sub-millisecond reads, data structures (sets, sorted sets)
-- SQL: when you need ACID or already have a database connectionPolyglot Persistence (Datenbanken mischen)
Polyglot Persistence verwendet unterschiedliche Datenbanken für unterschiedliche Datenbedürfnisse innerhalb einer App. PostgreSQL handhabt Transaktionen, Redis handhabt Caching, Elasticsearch handhabt Suche, S3 handhabt Dateien. Die Herausforderung ist, Daten über Stores konsistent zu halten — die Lösung ist Event-Driven-Architektur: in die primäre Datenbank (Source of Truth) schreiben, dann Änderungen asynchron an andere Stores via Change Data Capture (CDC) oder Message Queues (Kafka, RabbitMQ) propagieren. Dies gibt Eventual Consistency — Lesezugriffe von sekundären Stores können leicht verzögert sein. Der Vorteil: jeder Store ist für seine Workload optimiert. Die Kosten: operationelle Komplexität. Mit einer einzelnen SQL-Datenbank beginnen; spezialisierte Stores nur hinzufügen, wenn man klare Performance-Limits trifft.
-- Modern applications often use MULTIPLE database types:
-- Each data store handles what it does best
-- Typical architecture:
-- 1. PostgreSQL: core transactional data (users, orders, payments)
-- ACID guarantees, complex queries, foreign keys
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR UNIQUE);
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));
-- 2. Redis: session storage, caching, rate limiting
-- Fast in-memory access, auto-expiring keys
-- 3. Elasticsearch: full-text search, log analytics
-- Inverted index, faceted search, aggregations
-- 4. S3/Object storage: files, images, backups
-- Cheap, unlimited, HTTP-accessible
-- 5. TimescaleDB/InfluxDB: time-series metrics
-- Optimized for timestamped data, downsampling
-- Challenge: data consistency across stores
-- Solution: event-driven architecture (CDC, message queues)
-- 1. Write to PostgreSQL (source of truth)
-- 2. Publish event to Kafka
-- 3. Consumers update Redis cache, Elasticsearch index, etc.
-- 4. eventual consistency — reads may be slightly staleACID vs. BASE Konsistenzmodelle
ACID (Atomicity, Consistency, Isolation, Durability) garantiert strikte Konsistenz — Transaktionen sind All-or-Nothing, und Daten erfüllen immer Constraints. Dies ist essenziell für Finanzsysteme, wo partielle Updates Fehler verursachen würden. BASE (Basically Available, Soft state, Eventually consistent) tauscht sofortige Konsistenz gegen Verfügbarkeit und Partition-Tolerance — Daten können temporär inkonsistent sein, konvergieren aber über Zeit. Das CAP-Theorem besagt, dass man nicht alle drei (Consistency, Availability, Partition tolerance) gleichzeitig während Netzwerk-Partitionen haben kann. SQL-Datenbanken priorisieren C+A (Single-Node) oder C+P (verteilt). Viele NoSQL-Datenbanken priorisieren A+P (Cassandra, DynamoDB). ACID wählen, wenn Korrektheit kritisch ist; BASE, wenn Verfügbarkeit und Skalierung wichtiger sind.
-- ACID (SQL databases):
-- Atomicity: all operations in a transaction succeed or fail together
-- Consistency: data always satisfies constraints (FK, CHECK, etc.)
-- Isolation: concurrent transactions don't interfere
-- Durability: committed data survives crashes
-- SQL transaction example:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If either fails, ROLLBACK undoes both
COMMIT; -- both updates are permanent
-- BASE (NoSQL databases):
-- Basically Available: system responds (may be stale)
-- Soft state: state changes without input (eventual consistency)
-- Eventually consistent: data converges over time
-- NoSQL trade-off: higher availability & partition tolerance
-- at the cost of immediate consistency
-- CAP Theorem: in a network partition, choose:
-- - CP (consistency): refuse writes (some NoSQL: HBase, MongoDB)
-- - AP (availability): accept writes, reconcile later (Cassandra, DynamoDB)
-- SQL databases are typically CA (consistent + available, no partition tolerance
-- in single-node setups; distributed SQL like CockroachDB adds P)CTE & Recursive CTE
Basic CTE
CTE (Common Table Expression) ist eine temporäre benannte Result-Menge. Verbessert Lesbarkeit durch Aufbrechen komplexer Queries. Mehrere CTEs können mit Kommas verkettet werden. CTEs sind nur für die einzelne Anweisung gültig.
WITH high_earners AS (
SELECT * FROM employees WHERE salary > 80000
), by_dept AS (
SELECT department, COUNT(*) AS cnt FROM high_earners GROUP BY department
)
SELECT * FROM by_dept ORDER BY cnt DESC;Recursive CTE
Rekursive CTEs referenzieren sich selbst. Der Anchor ist der Base Case. UNION ALL verbindet zum rekursiven Teil. Verwendet für hierarchische Daten: Org-Charts, Dateisysteme, Graph-Traversierung. Muss eine Termination-Bedingung haben.
WITH RECURSIVE org_chart AS (
-- Anchor: top-level managers
SELECT id, name, manager_id, 1 AS level
FROM employees WHERE manager_id IS NULL
UNION ALL
-- Recursive: subordinates
SELECT e.id, e.name, e.manager_id, oc.level + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart;Fibonacci mit CTE
Rekursive CTEs können Sequenzen generieren. Der Anchor liefert den ersten Wert. Jede Iteration berechnet den nächsten. Die WHERE-Klausel verhindert Endlosrekursion. Nützlich für mathematische Sequenzen.
WITH RECURSIVE fib(n, a, b) AS (
SELECT 1, 0, 1
UNION ALL
SELECT n + 1, b, a + b FROM fib WHERE n < 10
)
SELECT n, a FROM fib;
-- Result: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34Baum-Traversierung
Pfade durch Konkatenieren von Namen in jeder Rekursion aufbauen. Der CAST stellt sicher, dass die path-Spalte breit genug ist. Nützlich für Breadcrumbs, Dateipfade und Kategorie-Hierarchien. ORDER BY path sortiert hierarchisch.
WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, CAST(name AS VARCHAR(1000)) AS path
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, ct.path || ' > ' || c.name
FROM categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, path FROM category_tree ORDER BY path;CTE vs. Subquery
CTEs verbessern Lesbarkeit und können mehrfach referenziert werden. Subqueries sind inline und können nicht wiederverwendet werden. CTEs werden nicht immer materialisiert; der Optimizer kann sie inlinen. CTEs für Klarheit verwenden.
-- CTE: more readable, can be referenced multiple times
WITH active_users AS (SELECT * FROM users WHERE active = 1)
SELECT * FROM active_users WHERE age > 18
UNION ALL
SELECT * FROM active_users WHERE age <= 18;
-- Subquery: inline, cannot be reused
SELECT * FROM (SELECT * FROM users WHERE active = 1) au WHERE au.age > 18;Indexes Deep Dive
B-Tree-Index
B-Tree ist der Default-Index-Typ. Composite Indexes folgen der Leftmost-Prefix-Regel: eine Query kann den Index verwenden, wenn sie auf führenden Spalten filtert. Spalten nach Selektivität und Query-Patterns ordnen.
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
-- Composite index: useful for
-- WHERE last_name = 'Smith' AND first_name = 'John'
-- WHERE last_name = 'Smith' (leftmost prefix)Partial Index
Partial Indexes inkludieren nur Zeilen, die der WHERE-Klausel entsprechen. Kleiner und schneller als Full Indexes. Ideal für Queries, die immer auf eine Bedingung filtern. Reduziert Write-Overhead.
CREATE INDEX idx_active_users ON users(last_login) WHERE active = 1;
-- Only indexes active users, saving space
-- Useful when queries always filter on active = 1Covering Index
Ein Covering Index inkludiert alle Spalten, die von einer Query benötigt werden, und ermöglicht Index-Only-Scans. PostgreSQL verwendet INCLUDE für Non-Key-Spalten. Beschleunigt SELECT-Queries drastisch durch Vermeiden von Table-Lookups.
-- PostgreSQL: INCLUDE clause
CREATE INDEX idx_users_covering ON users(last_name) INCLUDE (first_name, email);
-- The query is "covered" if all columns are in the index:
SELECT first_name, email FROM users WHERE last_name = 'Smith';
-- No table lookup needed (index-only scan)Index-Typen
Unterschiedliche Index-Typen dienen unterschiedlichen Bedürfnissen. B-Tree für allgemeine Verwendung. Hash nur für Gleichheit. GIN für Full-Text und JSON. GiST für geometrische Daten. Basierend auf Query-Patterns wählen.
-- B-Tree: default, good for equality and range
CREATE INDEX idx_btree ON users(email);
-- Hash: equality only (PostgreSQL)
CREATE INDEX idx_hash ON users(email) USING HASH;
-- GIN: full-text search, arrays, JSON
CREATE INDEX idx_gin ON docs USING GIN (tsv);
-- GiST: geometric, range types
CREATE INDEX idx_gist ON places USING GIST (location);Index-Wartung
Index-Nutzung überwachen, um ungenutzte Indexes zu entfernen, die Writes verlangsamen. REINDEX baut fragmentierte Indexes neu auf. ANALYZE aktualisiert Statistiken für den Query-Planner. Regelmäßige Wartung hält Performance optimal.
-- Check index usage (PostgreSQL)
SELECT * FROM pg_stat_user_indexes;
-- Find unused indexes
SELECT relname, indexrelname FROM pg_stat_user_indexes WHERE idx_scan = 0;
-- Rebuild fragmented index
REINDEX INDEX idx_users_email;
-- Analyze for query planner
ANALYZE users;Transaktionen
ACID-Eigenschaften
ACID: Atomicity (Alles oder nichts), Consistency (gültiger Zustand), Isolation (gleichzeitige Transaktionen stören sich nicht), Durability (committete Daten persistieren). BEGIN startet, COMMIT speichert, ROLLBACK macht rückgängig.
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Or: ROLLBACK to undoSavepoints
Savepoints erstellen partielle Rollback-Punkte innerhalb einer Transaktion. ROLLBACK TO macht bis zum Savepoint rückgängig, ohne die Transaktion zu beenden. Nützlich für Fehlerbehandlung in Multi-Step-Operationen ohne Neustart.
BEGIN;
INSERT INTO orders VALUES (1);
SAVEPOINT sp1;
INSERT INTO orders VALUES (2);
-- Oops, rollback to savepoint
ROLLBACK TO sp1;
-- Only order 1 is inserted
INSERT INTO orders VALUES (3);
COMMIT;Isolation Levels
Isolation Levels balancieren Konsistenz vs. Performance. READ COMMITTED (Default) verhindert Dirty Reads. REPEATABLE READ verhindert Non-Repeatable Reads. SERIALIZABLE verhindert Phantom Reads, ist aber am langsamsten.
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Levels (increasing isolation):
-- READ UNCOMMITTED: dirty reads
-- READ COMMITTED: no dirty reads (default)
-- REPEATABLE READ: no non-repeatable reads
-- SERIALIZABLE: full isolationDeadlocks
Deadlocks treten auf, wenn Transaktionen Locks halten, die einander brauchen. Datenbanken erkennen Deadlocks und brechen eine Transaktion ab. Verhindern durch Zugreifen auf Tabellen in konsistenter Reihenfolge. Transaktionen kurz halten.
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
UPDATE accounts SET balance = 0 WHERE id = 2; -- Waits
-- Transaction 2
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 2;
UPDATE accounts SET balance = 0 WHERE id = 1; -- Waits
-- Deadlock! Database aborts one transactionOptimistisches Locking
Optimistisches Locking nimmt an, dass Konflikte selten sind. Die Version-Spalte trackt Änderungen. Wenn das UPDATE 0 Zeilen betrifft, wurden die Daten von einer anderen Transaktion modifiziert. Wiederholen oder User benachrichtigen. Vermeidet lange Lock-Holds.
-- Add version column
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- Update with version check
UPDATE products SET price = 100, version = version + 1
WHERE id = 1 AND version = 5;
-- If 0 rows affected, someone else updated firstJSON in SQL
PostgreSQL JSONB
JSONB speichert JSON in einem binären Format, das Indexierung und schnelle Queries ermöglicht. ->> extrahiert als Text, -> extrahiert als JSON. JSONB ist für Queries JSON vorzuziehen. GIN-Indexes für JSONB-Spalten verwenden.
CREATE TABLE events (id SERIAL, data JSONB);
INSERT INTO events (data) VALUES ('{"user": "alice", "action": "login"}');
SELECT data->>'user' AS user_name FROM events;
SELECT * FROM events WHERE data->>'action' = 'login';JSON-Queries
-> navigiert JSON, ->> gibt Text zurück. @> prüft Containment. jsonb_set aktualisiert verschachtelte Werte. jsonb_object_keys gibt Top-Level-Schlüssel zurück. Diese Operatoren ermöglichen mächtige JSON-Queries.
SELECT data->'address'->'city' AS city FROM users;
SELECT * FROM users WHERE data @> '{"role": "admin"}';
SELECT jsonb_object_keys(data) FROM users;
-- Update JSON
UPDATE users SET data = jsonb_set(data, '{last_login}', '"2024-01-01"');JSON-Aggregation
json_agg aggregiert Zeilen in ein JSON-Array. json_build_object konstruiert JSON-Objekte aus Spalten. Nützlich für das Generieren von API-Antworten direkt aus SQL. Kombiniert relationale und Dokumentdaten.
SELECT department,
json_agg(json_build_object('name', name, 'salary', salary)) AS employees
FROM employees
GROUP BY department;
-- Result: {"department": "Eng", "employees": [{"name": "Alice", "salary": 90000}, ...]}MySQL JSON
MySQL verwendet $.path-Syntax für JSON. JSON_EXTRACT bekommt Werte, JSON_SET aktualisiert. ->> ist eine Abkürzung für JSON_EXTRACT mit Text-Ergebnis. MySQL JSON wird beim Insert validiert.
CREATE TABLE config (id INT, settings JSON);
INSERT INTO config VALUES (1, '{"theme": "dark", "lang": "en"}');
SELECT settings->>'$.theme' FROM config;
SELECT * FROM config WHERE JSON_EXTRACT(settings, '$.lang') = 'en';
-- Update
UPDATE config SET settings = JSON_SET(settings, '$.theme', 'light');JSON-Indexes
GIN-Indexes auf JSONB ermöglichen schnelles Abfragen beliebiger Schlüssel. Expression-Indexes auf spezifische Pfade sind kleiner und schneller für gezielte Queries. Häufig abgefragte JSON-Pfade für Performance indizieren.
-- PostgreSQL GIN index on JSONB
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Index specific path
CREATE INDEX idx_events_user ON events ((data->>'user'));
-- MySQL functional index
CREATE INDEX idx_theme ON config ((CAST(settings->>'$.theme' AS CHAR(50))));Performance-Tuning
EXPLAIN ANALYZE
EXPLAIN zeigt den Query-Plan; ANALYZE führt ihn mit Timing aus. Seq Scan zeigt fehlenden Index. Index Scan ist ideal. Nach hohen Cost-Zahlen und langsamen Operationen suchen. Immer EXPLAIN vor dem Optimieren.
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = '[email protected]';
-- Shows: scan type, cost, rows, actual time
-- Seq Scan: full table scan (slow)
-- Index Scan: uses index (fast)
-- Bitmap Heap Scan: index + table lookupQuery-Optimierung
Nur benötigte Spalten selektieren, um I/O zu reduzieren. Funktionen auf indizierten Spalten vermeiden (non-sargable). Sargable (Search Argument Able) Queries können Indexes verwenden. Bereichs-Bedingungen statt Funktionen verwenden.
-- BAD: SELECT * fetches all columns
SELECT * FROM users;
-- GOOD: select only needed columns
SELECT id, name FROM users;
-- BAD: function on indexed column
WHERE YEAR(created_at) = 2024
-- GOOD: sargable
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'JOIN-Optimierung
Alle Join-Spalten indizieren. Der Optimizer wählt Join-Reihenfolge basierend auf Statistiken. INNER JOIN ist meist am schnellsten. Joinen auf Ausdrücken vermeiden. Für große Datensätze Denormalisierung oder Materialized Views in Betracht ziehen.
-- Use INNER JOIN for required relationships
SELECT u.name, o.total FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Index join columns
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- For large joins, ensure both columns are indexedPaginierung
OFFSET-Paginierung ist O(n) — sie scannt alle übersprungenen Zeilen. Keyset-(Cursor-)Paginierung ist O(1) — sie verwendet einen Index. Tuple-Vergleich für stabile Sortierung verwenden. Viel schneller für tiefe Paginierung.
-- BAD: OFFSET scans all skipped rows
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- GOOD: keyset pagination
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
-- Stable pagination with cursor
SELECT * FROM users WHERE (created_at, id) > ('2024-01-01', 100) ORDER BY created_at, id LIMIT 10;Materialized Views
Materialized Views speichern Query-Ergebnisse physisch. Schneller als Views für teure Aggregationen. REFRESH aktualisiert die Daten (mit CONCURRENTLY-Option). Sie für schnelle Queries indizieren.
CREATE MATERIALIZED VIEW sales_summary AS
SELECT product_id, SUM(quantity) AS total, AVG(price) AS avg_price
FROM sales GROUP BY product_id;
-- Refresh periodically
REFRESH MATERIALIZED VIEW sales_summary;
-- Create index on materialized view
CREATE INDEX ON sales_summary (total);Advanced JOINs
Self Join
Ein Self Join fragt eine Tabelle gegen sich selbst ab. Aliase verwenden zur Unterscheidung. Häufig für hierarchische Daten (Mitarbeiter-Manager) und das Finden von Paaren. Der a.id < b.id-Trick vermeidet doppelte Paare.
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- Find pairs in same department
SELECT a.name, b.name FROM employees a, employees b
WHERE a.department = b.department AND a.id < b.id;Cross Join
CROSS JOIN produziert ein kartesisches Produkt: jede Zeile in A kombiniert mit jeder Zeile in B. Nützlich zum Generieren von Kombinationen. Vorsicht: kann riesige Result-Mengen produzieren. Oft implizit mit Komma-Syntax verwendet.
-- Cartesian product: every combination
SELECT s.size, c.color FROM sizes s CROSS JOIN colors c;
-- Generate all size/color combinations
-- Useful for generating test data or matricesFULL OUTER JOIN
FULL OUTER JOIN gibt alle Zeilen beider Tabellen zurück. NULLs füllen nicht-matchende Seiten. Nützlich zum Finden nicht-matchender Datensätze in beide Richtungen. In MySQL nicht unterstützt (mit UNION aus LEFT und RIGHT Joins emulieren).
SELECT u.name, o.order_id
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- Returns all users and all orders
-- NULLs where there is no matchAnti-Join
Anti-Join findet Zeilen in A, die nicht zu B matchen. NOT EXISTS ist meist am klarsten und oft am schnellsten. LEFT JOIN mit IS NULL ist eine Alternative. Zum Finden fehlender Beziehungen verwenden.
-- Users who have never ordered
SELECT u.* FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: LEFT JOIN ... WHERE IS NULL
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;Semi-Join
Semi-Join gibt Zeilen aus A zurück, die zu mindestens einer Zeile in B matchen. EXISTS ist effizient, weil es beim ersten Match stoppt. IN ist äquivalent, kann aber unterschiedlich performen. EXISTS für korrelierte Subqueries verwenden.
-- Users who have at least one order
SELECT u.* FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: IN
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);Häufige Fallstricke
NULL-Vergleiche
NULL ist unbekannt, kein Wert. = NULL gibt immer NULL zurück (als false behandelt). IS NULL und IS NOT NULL verwenden. NULL propagiert durch Arithmetik. COALESCE für Defaults verwenden.
-- NULL = NULL is NULL (not true!)
SELECT * FROM users WHERE phone = NULL; -- Returns nothing
SELECT * FROM users WHERE phone IS NULL; -- Correct
SELECT * FROM users WHERE phone IS NOT NULL;
-- NULL in arithmetic yields NULL
SELECT 5 + NULL; -- NULLSQL-Injection
SQL-Injection erlaubt Angreifern, beliebiges SQL auszuführen. Niemals User-Input in Queries konkatenieren. Immer parametrisierte Queries/Prepared Statements verwenden. Alle Eingaben validieren und sanitizen. ORM-Parameter-Binding verwenden.
-- BAD: string concatenation
query = "SELECT * FROM users WHERE name = '" + input + "'"
-- GOOD: parameterized queries
SELECT * FROM users WHERE name = ?;
-- PostgreSQL: $1
-- MySQL: ?
-- Always use parameters, never concatenateGROUP BY-Fallstricke
Bei Verwendung von GROUP BY müssen alle nicht-aggregierten Spalten in SELECT in GROUP BY sein. Sonst ist das Ergebnis ambigu. MySQL erlaubt dies (gibt beliebigen Wert zurück), aber es ist inkorrekt. Immer dem Standard folgen.
-- BAD: non-aggregated column not in GROUP BY
SELECT department, name, COUNT(*) FROM employees GROUP BY department;
-- Error: which name to show?
-- GOOD: aggregate or include in GROUP BY
SELECT department, COUNT(*) FROM employees GROUP BY department;
SELECT department, MAX(name) FROM employees GROUP BY department;Fließkomma
FLOAT und DOUBLE sind approximative Typen. DECIMAL/NUMERIC für exakte Präzision verwenden (Geld, Messungen). DECIMAL(10,2) erlaubt 10 Ziffern mit 2 nach dem Komma. Niemals FLOAT für Finanzdaten verwenden.
-- Floating point precision issues
SELECT 0.1 + 0.2; -- 0.30000000000000004
-- Use DECIMAL for money
CREATE TABLE accounts (balance DECIMAL(10, 2));
SELECT 0.10 + 0.20; -- 0.30 (exact)Implizite Typ-Konvertierung
Implizite Typ-Konvertierung kann Indexes deaktivieren und Full-Table-Scans verursachen. Immer passende Typen vergleichen. Falls nötig, explizit casten. Spaltentypen prüfen und sicherstellen, dass Query-Parameter matchen.
-- BAD: comparing string to number
SELECT * FROM users WHERE phone = 1234567890;
-- May cause full table scan due to type conversion
-- GOOD: compare same types
SELECT * FROM users WHERE phone = '1234567890';
-- Or cast explicitly
SELECT * FROM users WHERE CAST(phone AS BIGINT) = 1234567890;Verwandte SQL-Snippets
Copy-paste ready code for common tasks.
SELECT mit WHERE und ORDER BY
Zeilen mit SELECT, WHERE und ORDER BY in SQL filtern, sortieren und begrenzen.
JOIN-Abfragen
Multi-Tabellen-Join-Abfragen.
Subqueries
Verschachtelte Abfragen.
Window-Funktionen
Ranking- und Aggregat-Window-Funktionen.
Aggregatfunktionen
GROUP BY und HAVING.
CTE
Common Table Expressions.
Rekursive Abfragen
Hierarchische Daten mit rekursiver CTE abfragen.
Indizes
Indizes erstellen und verwalten.
Transaktionen
Transaktionskontrolle und Isolationsstufen.
Stored Procedures
Stored Procedures und Funktionen erstellen.
Trigger
Automatisch ausgeführte Trigger.
Views
Views erstellen und verwalten.
Materialized Views
Materialized Views und Aktualisierung.
Partitionierte Tabellen
Tabellen-Partitionierungsstrategien.
Backup und Wiederherstellung
Daten-Backup, Import und Export.
Leistungsoptimierung
Abfrageleistungs-Analyse und -Optimierung.
JSON-Operationen
PostgreSQL JSON/JSONB-Operationen.
Volltextsuche
PostgreSQL-Volltextsuche.
Pivot/Unpivot
PIVOT und Crosstab.
Datumsabfragen
Datums- und Zeitoperationen.
Paginierungsabfragen
LIMIT/OFFSET und Cursor-Paginierung.
Was this helpful?