Data-Warehouse-Design: Dimensionale Modellierung vom Star-Schema bis zur PartitionierungData Warehouse Design: Dimensional Modeling from Star Schema to Partitioning

Prolog: Die Spurensuche im Datenozean

Es begann mit einer simplen Frage, die mich nicht mehr losließ: Warum dauert diese verdammte Sales-Abfrage 47 Sekunden? Mein Linux-Server ratterte, PostgreSQL schluckte CPU-Zyklen wie ein durstiger Marathon-Läufer Wasser, und ich starrte auf den EXPLAIN ANALYZE-Output wie auf eine Landkarte, deren Legende jemand vergessen hatte. Sieben Tabellen, zwölf Joins, ein Full Table Scan über 100 Millionen Zeilen. Das war kein Query — das war ein Hilferuf.

Also tat ich, was jeder neugierige Datenarchitekt tun würde: Ich löschte die Produktionsdatenbank. Nein, natürlich nicht. Ich holte mir einen Kaffee, öffnete ein leeres Terminal und begann, das Fundament neu zu denken. Data Warehouse Design — nicht als akademisches Konzept aus einem Lehrbuch, sondern als praktisches Werkzeug, das ich auf meinem eigenen Server mit echten Daten testen konnte.

Was folgt, ist die Geschichte dieser Expedition: Vom chaotischen Datensumpf zum strukturierten Warehouse, vom naiven Schema-Entwurf zur hochoptimierten Analytical Engine. Sechs Kapitel, sechs Visualisierungen, und die Erkenntnis, dass gutes Datenbank-Design weniger Wissenschaft und mehr Detektivarbeit ist.

Kapitel 1: Das Star-Schema — Eleganz durch Einfachheit

Jede gute Architektur beginnt mit einer fundamentalen Entscheidung: Wie organisiere ich meine Daten so, dass sie Fragen beantworten, die ich noch gar nicht gestellt habe? Die Antwort, die Ralph Kimball in den 1990ern formulierte, ist bestechend simpel: Trenne Fakten von Dimensionen.

Fakten sind Messungen — Dinge, die man zählen, summieren, mitteln kann. Revenue, Quantity, Discount. Dimensionen sind der Kontext — wer kaufte was, wann, wo? Ein Star-Schema stellt die Faktentabelle ins Zentrum und umgibt sie mit Dimensionstabellen wie Planeten um eine Sonne.

Auf meinem Server setzte ich das mit PostgreSQL um:

-- Dimensionstabellen
CREATE TABLE dim_date (
    date_key    SERIAL PRIMARY KEY,
    full_date   DATE NOT NULL,
    day_of_week VARCHAR(10),
    month       INTEGER,
    quarter     INTEGER,
    year        INTEGER,
    is_weekend  BOOLEAN
);

CREATE TABLE dim_product (
    product_key SERIAL PRIMARY KEY,
    product_id  VARCHAR(20) NOT NULL,
    name        VARCHAR(200),
    category    VARCHAR(100),
    brand       VARCHAR(100),
    unit_price  DECIMAL(10,2)
);

CREATE TABLE dim_customer (
    customer_key SERIAL PRIMARY KEY,
    customer_id  VARCHAR(20) NOT NULL,
    name         VARCHAR(200),
    segment      VARCHAR(50),
    region       VARCHAR(100),
    loyalty_tier VARCHAR(20)
);

CREATE TABLE dim_store (
    store_key   SERIAL PRIMARY KEY,
    store_id    VARCHAR(20) NOT NULL,
    name        VARCHAR(200),
    city        VARCHAR(100),
    country     VARCHAR(50),
    size_sqm    INTEGER
);

-- Faktentabelle im Zentrum
CREATE TABLE fct_sales (
    sale_id      SERIAL PRIMARY KEY,
    date_key     INTEGER REFERENCES dim_date(date_key),
    product_key  INTEGER REFERENCES dim_product(product_key),
    customer_key INTEGER REFERENCES dim_customer(customer_key),
    store_key    INTEGER REFERENCES dim_store(store_key),
    quantity     INTEGER NOT NULL,
    revenue      DECIMAL(12,2) NOT NULL,
    discount     DECIMAL(5,2) DEFAULT 0
);

-- Indizes für performante Joins
CREATE INDEX idx_fct_sales_date ON fct_sales(date_key);
CREATE INDEX idx_fct_sales_product ON fct_sales(product_key);
CREATE INDEX idx_fct_sales_customer ON fct_sales(customer_key);
CREATE INDEX idx_fct_sales_store ON fct_sales(store_key);

Die Schönheit dieses Ansatzes offenbarte sich, als ich die erste analytische Abfrage schrieb:

SELECT
    d.year,
    d.quarter,
    p.category,
    c.segment,
    SUM(f.revenue) AS total_revenue,
    AVG(f.discount) AS avg_discount,
    COUNT(DISTINCT c.customer_key) AS unique_customers
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
GROUP BY d.year, d.quarter, p.category, c.segment
ORDER BY total_revenue DESC;

Vier Joins, aber jeder kristallklar. Kein Rätselraten, welche Tabelle welche Bedeutung hat. Das Star-Schema ist selbstdokumentierend — eine Eigenschaft, deren Wert man erst schätzt, wenn man um 2 Uhr morgens einen Bug in einer 200-Zeilen-SQL-Abfrage sucht.

Star Schema Visualisierung mit zentraler Faktentabelle und umgebenden Dimensionstabellen
Abbildung 1: Das Star-Schema — die Faktentabelle als gravitatives Zentrum, umgeben von denormalisierten Dimensionstabellen. Jeder Foreign Key (gelb) zeigt auf genau einen Primary Key einer Dimension.

Kapitel 2: Star vs. Snowflake — Die Normalisierungsdebatte

Kaum hatte ich mein Star-Schema aufgebaut, flüsterte mir mein innerer Datenbank-Purist zu: Aber die Redundanz! Die Anomalien! Du wiederholst Category-Informationen in jeder Product-Zeile! Und er hatte nicht unrecht. In dim_product stand „Electronics" hundertmal — einmal für jedes Produkt dieser Kategorie.

Die Antwort des Puristen heißt Snowflake-Schema: Man normalisiert die Dimensionstabellen weiter, extrahiert Sub-Dimensionen. Aus einer flachen dim_product-Tabelle werden drei: dim_product, dim_category, dim_brand.

-- Snowflake: Normalisierte Dimensionen
CREATE TABLE dim_category (
    category_key SERIAL PRIMARY KEY,
    name         VARCHAR(100),
    department   VARCHAR(100)
);

CREATE TABLE dim_brand (
    brand_key    SERIAL PRIMARY KEY,
    name         VARCHAR(100),
    manufacturer VARCHAR(200),
    country      VARCHAR(50)
);

CREATE TABLE dim_product_sf (
    product_key  SERIAL PRIMARY KEY,
    product_id   VARCHAR(20) NOT NULL,
    name         VARCHAR(200),
    category_key INTEGER REFERENCES dim_category(category_key),
    brand_key    INTEGER REFERENCES dim_brand(brand_key),
    unit_price   DECIMAL(10,2)
);

Klingt sauber, oder? Bis man die analytische Abfrage schreibt und plötzlich statt 4 Joins 8 braucht. Bis der Query Planner anfängt, Hash Joins zu kaskadieren und die Ausführungszeit sich verdoppelt. Bis das BI-Team fragt, warum das Dashboard seit dem Refactoring langsamer ist.

Ich testete beide Varianten auf meinem Server mit einem synthetischen Dataset von 10 Millionen Sales-Records und maß die Unterschiede. Das Ergebnis war eindeutig: Für analytische Workloads — aggregieren, gruppieren, filtern — gewinnt das Star-Schema fast immer. Der Grund ist simpel: Weniger Joins = weniger I/O = schnellere Queries.

Das Snowflake-Schema hat seinen Platz — bei sehr großen Dimensionstabellen, wo die Redundanz tatsächlich zu Speicherproblemen führt, oder bei Compliance-Anforderungen an Datenkonsistenz. Aber für die meisten Data Warehouses auf einem Linux-Server gilt: Denormalisierung ist dein Freund.

Vergleich Star Schema und Snowflake Schema mit Sub-Dimensionen
Abbildung 2: Star vs. Snowflake — links die elegante Einfachheit des Star-Schemas, rechts die normalisierte Variante mit ausgelagerten Sub-Dimensionen. Mehr Tabellen bedeuten mehr Joins, aber weniger Redundanz.

Kapitel 3: Query-Performance — Wo die Millisekunden sterben

Theorie ist schön, aber mein Server hat begrenzte Ressourcen. 32 GB RAM, 8 Kerne, eine SSD, die schneller altert als mir lieb ist. Also benchmarkte ich systematisch, was passiert, wenn verschiedene Schema-Designs auf reale analytische Queries treffen.

Ich erstellte drei identische Datasets mit 50 Millionen Zeilen — einmal als Star-Schema, einmal als Snowflake, einmal als vollnormalisierte 3NF-Struktur (die typische OLTP-Datenbank, aus der die Daten ursprünglich kommen). Dann feuerte ich fünf Abfragetypen ab:

-- Typ 1: Simple Aggregate
SELECT SUM(revenue) FROM fct_sales WHERE date_key BETWEEN 1000 AND 1365;

-- Typ 2: Join 2 Tables
SELECT p.category, SUM(f.revenue)
FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category;

-- Typ 3: Join 4 Tables
SELECT d.year, p.category, c.segment, s.country, SUM(f.revenue)
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_store s ON f.store_key = s.store_key
GROUP BY d.year, p.category, c.segment, s.country;

-- Typ 4: Complex Subquery
SELECT category, revenue_rank FROM (
    SELECT p.category, SUM(f.revenue) as total,
           RANK() OVER (ORDER BY SUM(f.revenue) DESC) as revenue_rank
    FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
    GROUP BY p.category
) ranked WHERE revenue_rank <= 10;

-- Typ 5: Full Scan mit Aggregation
SELECT COUNT(*), SUM(revenue), AVG(discount) FROM fct_sales;

Die Ergebnisse sprachen Bände. Bei einfachen Aggregationen war der Unterschied marginal — alle drei Schemas lagen unter 2 Sekunden. Aber bei Multi-Table-Joins explodierte die 3NF-Variante: 8,5 Sekunden für einen 4-Table-Join, verglichen mit 2,1 Sekunden im Star-Schema. Der Snowflake lag dazwischen.

Der entscheidende Faktor war nicht die Rechenleistung, sondern der I/O-Overhead. Jeder zusätzliche Join bedeutet: Hash-Tabellen aufbauen, Zwischenergebnisse materialisieren, Speicher allozieren. Auf einem dedizierten Data-Warehouse-Server mit 256 GB RAM mag das irrelevant sein. Auf meinem Linux-Server war es der Unterschied zwischen „brauchbar" und „Coffee-Break-Query".

Query Performance Vergleich verschiedener Schema-Typen und Storage-Performance Tradeoff
Abbildung 3: Links: Query-Zeiten nach Schema-Typ — das Star-Schema dominiert bei Multi-Join-Queries. Rechts: Der klassische Tradeoff zwischen Speicherverbrauch und Abfragegeschwindigkeit.

Kapitel 4: Slowly Changing Dimensions — Die Zeitreise-Maschine

Das nächste Rätsel lauerte in den Daten selbst: Was passiert, wenn sich eine Dimension ändert? Anna Müller zieht von Berlin nach Hamburg. Max Schmidt wird vom Standard- zum Gold-Kunden hochgestuft. Der Store in der Friedrichstraße verdoppelt seine Fläche.

In einer OLTP-Datenbank ist die Antwort trivial: UPDATE dim_customer SET city = 'Hamburg' WHERE customer_id = '1001'. Fertig. Aber in einem Data Warehouse ist diese Antwort katastrophal — denn damit verlieren wir die Geschichte. Alle historischen Sales von Anna Müller erscheinen plötzlich in Hamburg, obwohl sie zum Zeitpunkt des Kaufs in Berlin lebte.

Ralph Kimball definierte sechs Typen von Slowly Changing Dimensions (SCDs), aber in der Praxis begegnen einem hauptsächlich drei:

SCD Typ 1 — Überschreiben: Die einfachste und verlustreichste Methode. Man überschreibt den alten Wert. Keine Geschichte, kein Audit-Trail. Geeignet für Korrekturen (Tippfehler im Namen) oder Attribute, deren Historie irrelevant ist.

-- SCD Typ 1: Einfach überschreiben
UPDATE dim_customer
SET city = 'Hamburg'
WHERE customer_id = '1001';

SCD Typ 2 — Neue Zeile einfügen: Die mächtigste Methode. Man fügt eine neue Zeile ein, markiert die alte als historisch und die neue als aktuell. Dafür braucht man einen Surrogate Key (unabhängig vom Business Key), Gültigkeitsdaten und ein is_current-Flag.

-- SCD Typ 2: Neue Zeile für die Geschichte
-- Schritt 1: Alte Zeile schließen
UPDATE dim_customer
SET valid_to = CURRENT_DATE,
    is_current = FALSE
WHERE customer_id = '1001'
  AND is_current = TRUE;

-- Schritt 2: Neue Zeile einfügen
INSERT INTO dim_customer (customer_id, name, city, segment, region,
                          loyalty_tier, valid_from, valid_to, is_current)
VALUES ('1001', 'Anna Müller', 'Hamburg', 'Premium', 'Nord',
        'Gold', CURRENT_DATE, '9999-12-31', TRUE);

SCD Typ 3 — Vorheriger Wert als Spalte: Ein Kompromiss. Man fügt eine zusätzliche Spalte für den vorherigen Wert hinzu. Begrenzt auf eine Änderungsebene, aber einfach zu implementieren und zu querien.

-- SCD Typ 3: Previous-Value Spalte
ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);

UPDATE dim_customer
SET previous_city = city,
    city = 'Hamburg'
WHERE customer_id = '1001';

Ich implementierte SCD Typ 2 für meine Customer-Dimension und lud historische Daten. Der Effekt war verblüffend: Plötzlich konnte ich analysieren, wie sich der Umsatz von Kunden vor und nach einem Segment-Wechsel entwickelt. Die Zeitreise-Maschine funktionierte.

Slowly Changing Dimensions Typ 1 und Typ 2 im Vergleich
Abbildung 4: SCD Typ 1 (oben) überschreibt einfach — die rot markierten Zellen zeigen die verlorene Geschichte. SCD Typ 2 (unten) bewahrt den historischen Kontext: Die rosa Zeile ist der geschlossene historische Record, die grüne der aktuelle.

Kapitel 5: Die Schichtenarchitektur — Vom Rohdaten-Chaos zum Analyse-Gold

Ein Star-Schema allein macht noch kein Data Warehouse. Die wahre Architektur-Entscheidung liegt in der Frage: Wie transformiere ich Rohdaten in analyse-taugliche Strukturen? Die Antwort ist ein Schichtenmodell — manchmal „Medallion Architecture" genannt (Bronze → Silver → Gold), manchmal nach Funktion (Raw → Staging → Business → Mart).

Auf meinem Server implementierte ich vier Schichten:

1. Raw / Staging Layer: Hier landen die Daten exakt so, wie sie aus der Quelle kommen. Kein Filtern, kein Transformieren, kein Interpretieren. Append-Only. Das ist die „Single Source of Truth" — wenn downstream etwas schiefgeht, kann man hierhin zurückgehen.

CREATE SCHEMA raw_layer;

CREATE TABLE raw_layer.orders (
    _loaded_at    TIMESTAMP DEFAULT NOW(),
    _source       VARCHAR(50),
    _raw_json     JSONB,
    order_id      VARCHAR(50),
    customer_id   VARCHAR(50),
    order_date    VARCHAR(50),  -- Noch VARCHAR! Kein Casting in der Raw-Schicht.
    total_amount  VARCHAR(50),
    status        VARCHAR(50)
);

2. Cleaned / Intermediate Layer: Hier wird geputzt, gecastet, dedupliziert. Type-Casting, NULL-Handling, Standardisierung von Formaten. Aus dem String '2025-03-15' wird ein echtes DATE. Aus 'EUR 1.234,56' wird 1234.56.

CREATE SCHEMA clean_layer;

CREATE TABLE clean_layer.stg_orders AS
SELECT
    order_id,
    customer_id,
    CAST(order_date AS DATE) AS order_date,
    CAST(REPLACE(REPLACE(total_amount, 'EUR ', ''), ',', '.') AS DECIMAL(12,2))
        AS total_amount,
    UPPER(TRIM(status)) AS status,
    NOW() AS _processed_at
FROM raw_layer.orders
WHERE order_id IS NOT NULL
  AND order_date ~ '^\d{4}-\d{2}-\d{2}$';

3. Business / Core Layer: Hier entstehen die dimensionalen Modelle — Star- oder Snowflake-Schemas mit Fakten- und Dimensionstabellen. Business-Logik wird einmal definiert und dann konsistent wiederverwendet.

4. Presentation / Mart Layer: Zielgruppen-spezifische Views oder materialisierte Tabellen. Der Marketing-Mart zeigt Kunden-Segmentierung, der Finance-Mart zeigt Revenue-Breakdown, das ML-Team bekommt Feature-Tables.

CREATE SCHEMA mart_layer;

-- Materialized View für das Marketing-Team
CREATE MATERIALIZED VIEW mart_layer.marketing_customer_360 AS
SELECT
    c.customer_key,
    c.name,
    c.segment,
    c.loyalty_tier,
    COUNT(DISTINCT f.sale_id) AS total_orders,
    SUM(f.revenue) AS lifetime_value,
    AVG(f.revenue) AS avg_order_value,
    MAX(d.full_date) AS last_purchase_date,
    MIN(d.full_date) AS first_purchase_date
FROM fct_sales f
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE c.is_current = TRUE
GROUP BY c.customer_key, c.name, c.segment, c.loyalty_tier;

-- Refresh-Schedule als Cron-Job
-- 0 3 * * * psql -d warehouse -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mart_layer.marketing_customer_360;"

Diese Schichtentrennung klingt nach Overhead, aber sie ist ein Sicherheitsnetz. Wenn sich die Quell-API ändert, betrifft das nur die Raw- und Clean-Schicht. Wenn das Business eine neue Metrik braucht, ändert man nur den Mart. Separation of Concerns — aber für Daten.

Data Warehouse Schichtenarchitektur von Sources bis Presentation
Abbildung 5: Die Medallion Architecture in der Praxis — fünf Schichten von den Rohdatenquellen bis zur Präsentationsebene. Jede Schicht hat eine klare Verantwortung und klare Transformationsregeln.

Kapitel 6: Partitionierung — Die Kunst des Teilens

Mit wachsenden Datenmengen stieß ich auf das nächste Performance-Plateau: Selbst mein optimiertes Star-Schema mit Indizes wurde langsam, als die Faktentabelle 100 Millionen Zeilen überschritt. Der EXPLAIN ANALYZE zeigte: Full Index Scan auf idx_fct_sales_date — der Index war so groß geworden, dass er selbst zum Bottleneck wurde.

Die Lösung: Table Partitioning. PostgreSQL unterstützt seit Version 10 deklarative Partitionierung — man teilt eine große Tabelle in physisch getrennte Subtabellen, die logisch als eine erscheinen.

-- Partitionierte Faktentabelle
CREATE TABLE fct_sales_partitioned (
    sale_id      SERIAL,
    date_key     INTEGER NOT NULL,
    product_key  INTEGER,
    customer_key INTEGER,
    store_key    INTEGER,
    quantity     INTEGER NOT NULL,
    revenue      DECIMAL(12,2) NOT NULL,
    discount     DECIMAL(5,2) DEFAULT 0,
    sale_date    DATE NOT NULL
) PARTITION BY RANGE (sale_date);

-- Monatliche Partitionen
CREATE TABLE fct_sales_2025_01 PARTITION OF fct_sales_partitioned
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE fct_sales_2025_02 PARTITION OF fct_sales_partitioned
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE fct_sales_2025_03 PARTITION OF fct_sales_partitioned
    FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

-- Automatische Partition-Erstellung via Funktion
CREATE OR REPLACE FUNCTION create_monthly_partition(target_date DATE)
RETURNS VOID AS $$
DECLARE
    partition_name TEXT;
    start_date DATE;
    end_date DATE;
BEGIN
    start_date := DATE_TRUNC('month', target_date);
    end_date := start_date + INTERVAL '1 month';
    partition_name := 'fct_sales_' || TO_CHAR(start_date, 'YYYY_MM');

    EXECUTE FORMAT(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF fct_sales_partitioned
         FOR VALUES FROM (%L) TO (%L)',
        partition_name, start_date, end_date
    );
END;
$$ LANGUAGE plpgsql;

Der Effekt war dramatisch. Eine Abfrage auf März 2025 scannte jetzt nur noch die Partition fct_sales_2025_03 mit 2 Millionen Zeilen statt die gesamte Tabelle mit 100 Millionen. Die Query-Zeit fiel von 45 Sekunden auf 1,5 Sekunden — eine 97 % Reduktion.

Aber Partitionierung allein war nur die halbe Miete. Das Speicherformat spielte eine ebenso große Rolle. Für meine auf dem Server liegenden Export-Dateien testete ich verschiedene Formate:

#!/bin/bash
# Benchmark: CSV vs. Parquet mit DuckDB
# DuckDB kann direkt Parquet lesen — perfekt für analytische Queries

# CSV Export (100M Zeilen)
time psql -d warehouse -c "\COPY fct_sales TO '/data/exports/sales.csv' CSV HEADER"
# → 100 GB, 12 MB/s Lesegeschwindigkeit

# Parquet Export mit DuckDB
duckdb << 'SQL'
COPY (SELECT * FROM read_csv_auto('/data/exports/sales.csv'))
TO '/data/exports/sales.parquet' (FORMAT PARQUET, COMPRESSION SNAPPY);
SQL
# → 15 GB (85% Kompression!), 78 MB/s Lesegeschwindigkeit

# Analytische Abfrage direkt auf Parquet
duckdb << 'SQL'
SELECT
    EXTRACT(YEAR FROM sale_date) AS year,
    SUM(revenue) AS total_revenue
FROM read_parquet('/data/exports/sales.parquet')
WHERE sale_date >= '2025-01-01'
GROUP BY year;
SQL
# → 0.3 Sekunden (vs. 5.2 Sekunden auf CSV)

Die Kombination aus partitionierter PostgreSQL-Datenbank für den operativen Betrieb und Parquet-Exporten für ad-hoc Analysen mit DuckDB erwies sich als ideales Setup für meinen Linux-Server. Enterprise-Level Architektur, Zero-Budget Edition.

Partition Pruning Effekt und Speicherformat-Vergleich
Abbildung 6: Links: Der dramatische Effekt von Partition Pruning — von 45 Sekunden auf 0,3 Sekunden durch tägliche Partitionierung. Rechts: Speicherformat-Vergleich — Parquet mit Snappy-Kompression bietet die beste Balance aus Größe und Geschwindigkeit.

Epilog: Das Warehouse als lebender Organismus

Was als Frustration über einen langsamen Query begann, wurde zu einer fundamentalen Lektion über Datenarchitektur. Ein Data Warehouse ist kein statisches Konstrukt — es ist ein lebender Organismus, der mit den Anforderungen wächst und sich anpasst.

Die wichtigsten Erkenntnisse aus meiner Server-Expedition:

  • Star-Schema für analytische Workloads: Weniger Joins, schnellere Queries, selbstdokumentierender Code. Snowflake nur bei echten Speicherproblemen.
  • SCD Typ 2 für alles, was Geschichte hat: Die Investition in Surrogate Keys und Gültigkeitsdaten zahlt sich bei jeder historischen Analyse aus.
  • Schichtentrennung ist nicht optional: Raw → Clean → Business → Mart. Jede Schicht hat eine Verantwortung. Keine Shortcuts.
  • Partitionierung ab Tag 1: Nicht warten, bis die Tabelle 100 Millionen Zeilen hat. Den Partition Key frühzeitig wählen und konsequent nutzen.
  • Columnar Formate für Analysen: Parquet/ORC für alles, was aggregiert wird. CSV ist ein Transport-Format, kein Analyse-Format.

Mein Query läuft jetzt in 0,3 Sekunden statt 47. Der Server schnurrt zufrieden vor sich hin. Und ich habe gelernt, dass die beste Architektur nicht die komplexeste ist — sondern die, die man versteht, warten und weiterentwickeln kann.

Zitationen & Quellen

  1. Kimball, R. & Ross, M. (2013). The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling. 3rd Edition. Wiley.
  2. Inmon, W. H. (2005). Building the Data Warehouse. 4th Edition. Wiley.
  3. PostgreSQL Documentation: Table Partitioning. postgresql.org/docs/current/ddl-partitioning.html
  4. Apache Parquet Format Specification. parquet.apache.org/documentation/latest/
  5. DuckDB Documentation: Parquet Import/Export. duckdb.org/docs/data/parquet/overview
  6. Kimball Group: Slowly Changing Dimensions. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/
  7. Databricks: Medallion Architecture. docs.databricks.com/en/lakehouse/medallion.html
  8. Linstedt, D. & Olschimke, M. (2015). Building a Scalable Data Warehouse with Data Vault 2.0. Morgan Kaufmann.

Fazit

Data-Warehouse-Design ist keine Raketenwissenschaft — es ist Handwerk. Mit PostgreSQL auf einem Linux-Server, den richtigen Schema-Entscheidungen und einem klaren Schichtenmodell kann man analytische Systeme bauen, die mit kommerziellen Lösungen mithalten. Die Tools sind frei verfügbar, die Dokumentation exzellent, und das Beste: Man kann alles selbst testen, Fehler machen und daraus lernen. Das ist der wahre Wert einer selbstgestellten Aufgabe — nicht das Ergebnis, sondern der Weg dorthin.

Technische Dokumentation

AspektDetail
DatenbankPostgreSQL 16 auf Ubuntu Linux
Schema-TypStar-Schema (primär), Snowflake (Vergleich)
SCD-StrategieTyp 2 mit Surrogate Keys & Gültigkeitsdaten
PartitionierungRange Partitioning nach Datum (monatlich/täglich)
Export-FormatParquet + Snappy Compression
Analyse-EngineDuckDB für ad-hoc Queries auf Parquet
Testdaten50-100 Millionen synthetische Sales-Records
Visualisierungmatplotlib 3.8+, Python 3.11

Prologue: Searching for Clues in the Data Ocean

It started with a simple question that wouldn't let me go: Why does this damn sales query take 47 seconds? My Linux server rattled, PostgreSQL consumed CPU cycles like a thirsty marathon runner drinks water, and I stared at the EXPLAIN ANALYZE output like a map whose legend someone had forgotten. Seven tables, twelve joins, a full table scan over 100 million rows. This wasn't a query — it was a cry for help.

So I did what any curious data architect would do: I deleted the production database. No, of course not. I got myself a coffee, opened an empty terminal, and began rethinking the foundation. Data Warehouse Design — not as an academic concept from a textbook, but as a practical tool I could test on my own server with real data.

What follows is the story of this expedition: From a chaotic data swamp to a structured warehouse, from naive schema design to a highly optimized analytical engine. Six chapters, six visualizations, and the realization that good database design is less science and more detective work.

Chapter 1: The Star Schema — Elegance Through Simplicity

Every good architecture begins with a fundamental decision: How do I organize my data so it answers questions I haven't even asked yet? The answer Ralph Kimball formulated in the 1990s is strikingly simple: Separate facts from dimensions.

Facts are measurements — things you can count, sum, average. Revenue, Quantity, Discount. Dimensions are the context — who bought what, when, where? A star schema places the fact table at the center and surrounds it with dimension tables like planets around a sun.

On my server, I implemented this with PostgreSQL:

-- Dimension tables
CREATE TABLE dim_date (
    date_key    SERIAL PRIMARY KEY,
    full_date   DATE NOT NULL,
    day_of_week VARCHAR(10),
    month       INTEGER,
    quarter     INTEGER,
    year        INTEGER,
    is_weekend  BOOLEAN
);

CREATE TABLE dim_product (
    product_key SERIAL PRIMARY KEY,
    product_id  VARCHAR(20) NOT NULL,
    name        VARCHAR(200),
    category    VARCHAR(100),
    brand       VARCHAR(100),
    unit_price  DECIMAL(10,2)
);

CREATE TABLE dim_customer (
    customer_key SERIAL PRIMARY KEY,
    customer_id  VARCHAR(20) NOT NULL,
    name         VARCHAR(200),
    segment      VARCHAR(50),
    region       VARCHAR(100),
    loyalty_tier VARCHAR(20)
);

CREATE TABLE dim_store (
    store_key   SERIAL PRIMARY KEY,
    store_id    VARCHAR(20) NOT NULL,
    name        VARCHAR(200),
    city        VARCHAR(100),
    country     VARCHAR(50),
    size_sqm    INTEGER
);

-- Fact table at the center
CREATE TABLE fct_sales (
    sale_id      SERIAL PRIMARY KEY,
    date_key     INTEGER REFERENCES dim_date(date_key),
    product_key  INTEGER REFERENCES dim_product(product_key),
    customer_key INTEGER REFERENCES dim_customer(customer_key),
    store_key    INTEGER REFERENCES dim_store(store_key),
    quantity     INTEGER NOT NULL,
    revenue      DECIMAL(12,2) NOT NULL,
    discount     DECIMAL(5,2) DEFAULT 0
);

-- Indexes for performant joins
CREATE INDEX idx_fct_sales_date ON fct_sales(date_key);
CREATE INDEX idx_fct_sales_product ON fct_sales(product_key);
CREATE INDEX idx_fct_sales_customer ON fct_sales(customer_key);
CREATE INDEX idx_fct_sales_store ON fct_sales(store_key);

The beauty of this approach revealed itself when I wrote the first analytical query:

SELECT
    d.year,
    d.quarter,
    p.category,
    c.segment,
    SUM(f.revenue) AS total_revenue,
    AVG(f.discount) AS avg_discount,
    COUNT(DISTINCT c.customer_key) AS unique_customers
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
GROUP BY d.year, d.quarter, p.category, c.segment
ORDER BY total_revenue DESC;

Four joins, but each crystal clear. No guessing which table carries which meaning. The star schema is self-documenting — a quality you only appreciate when you're hunting a bug in a 200-line SQL query at 2 AM.

Star Schema visualization with central fact table and surrounding dimension tables
Figure 1: The Star Schema — the fact table as gravitational center, surrounded by denormalized dimension tables. Each foreign key (yellow) points to exactly one primary key in a dimension.

Chapter 2: Star vs. Snowflake — The Normalization Debate

Barely had I built my star schema when my inner database purist whispered: But the redundancy! The anomalies! You're repeating category information in every product row! And he wasn't wrong. In dim_product, "Electronics" appeared a hundred times — once for every product in that category.

The purist's answer is called the Snowflake Schema: You normalize the dimension tables further, extracting sub-dimensions. One flat dim_product table becomes three: dim_product, dim_category, dim_brand.

-- Snowflake: Normalized dimensions
CREATE TABLE dim_category (
    category_key SERIAL PRIMARY KEY,
    name         VARCHAR(100),
    department   VARCHAR(100)
);

CREATE TABLE dim_brand (
    brand_key    SERIAL PRIMARY KEY,
    name         VARCHAR(100),
    manufacturer VARCHAR(200),
    country      VARCHAR(50)
);

CREATE TABLE dim_product_sf (
    product_key  SERIAL PRIMARY KEY,
    product_id   VARCHAR(20) NOT NULL,
    name         VARCHAR(200),
    category_key INTEGER REFERENCES dim_category(category_key),
    brand_key    INTEGER REFERENCES dim_brand(brand_key),
    unit_price   DECIMAL(10,2)
);

Sounds clean, right? Until you write the analytical query and suddenly need 8 joins instead of 4. Until the query planner starts cascading hash joins and execution time doubles. Until the BI team asks why the dashboard has been slower since the refactoring.

I tested both variants on my server with a synthetic dataset of 10 million sales records and measured the differences. The result was clear: For analytical workloads — aggregating, grouping, filtering — the star schema wins almost every time. The reason is simple: Fewer joins = less I/O = faster queries.

The snowflake schema has its place — with very large dimension tables where redundancy actually causes storage problems, or with compliance requirements for data consistency. But for most data warehouses on a Linux server: Denormalization is your friend.

Comparison of Star Schema and Snowflake Schema with sub-dimensions
Figure 2: Star vs. Snowflake — left shows the elegant simplicity of the star schema, right the normalized variant with extracted sub-dimensions. More tables mean more joins but less redundancy.

Chapter 3: Query Performance — Where Milliseconds Go to Die

Theory is nice, but my server has limited resources. 32 GB RAM, 8 cores, an SSD aging faster than I'd like. So I systematically benchmarked what happens when different schema designs meet real analytical queries.

I created three identical datasets with 50 million rows — one as star schema, one as snowflake, one as fully normalized 3NF structure (the typical OLTP database the data originally comes from). Then I fired five query types:

-- Type 1: Simple Aggregate
SELECT SUM(revenue) FROM fct_sales WHERE date_key BETWEEN 1000 AND 1365;

-- Type 2: Join 2 Tables
SELECT p.category, SUM(f.revenue)
FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category;

-- Type 3: Join 4 Tables
SELECT d.year, p.category, c.segment, s.country, SUM(f.revenue)
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_store s ON f.store_key = s.store_key
GROUP BY d.year, p.category, c.segment, s.country;

-- Type 4: Complex Subquery
SELECT category, revenue_rank FROM (
    SELECT p.category, SUM(f.revenue) as total,
           RANK() OVER (ORDER BY SUM(f.revenue) DESC) as revenue_rank
    FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
    GROUP BY p.category
) ranked WHERE revenue_rank <= 10;

-- Type 5: Full Scan with Aggregation
SELECT COUNT(*), SUM(revenue), AVG(discount) FROM fct_sales;

The results spoke volumes. For simple aggregations, the difference was marginal — all three schemas came in under 2 seconds. But for multi-table joins, the 3NF variant exploded: 8.5 seconds for a 4-table join, compared to 2.1 seconds in the star schema. The snowflake fell in between.

The decisive factor wasn't computing power but I/O overhead. Every additional join means: building hash tables, materializing intermediate results, allocating memory. On a dedicated data warehouse server with 256 GB RAM, this might be irrelevant. On my Linux server, it was the difference between "usable" and "coffee break query."

Query performance comparison across schema types and storage performance tradeoff
Figure 3: Left: Query times by schema type — the star schema dominates for multi-join queries. Right: The classic tradeoff between storage consumption and query speed.

Chapter 4: Slowly Changing Dimensions — The Time Travel Machine

The next puzzle lurked in the data itself: What happens when a dimension changes? Anna Müller moves from Berlin to Hamburg. Max Schmidt gets upgraded from Standard to Gold customer. The store on Friedrichstraße doubles its floor space.

In an OLTP database, the answer is trivial: UPDATE dim_customer SET city = 'Hamburg' WHERE customer_id = '1001'. Done. But in a data warehouse, this answer is catastrophic — because it destroys history. All historical sales for Anna Müller suddenly appear in Hamburg, even though she was living in Berlin at the time of purchase.

Ralph Kimball defined six types of Slowly Changing Dimensions (SCDs), but in practice you mainly encounter three:

SCD Type 1 — Overwrite: The simplest and most destructive method. You overwrite the old value. No history, no audit trail. Suitable for corrections (typos in names) or attributes whose history is irrelevant.

-- SCD Type 1: Simply overwrite
UPDATE dim_customer
SET city = 'Hamburg'
WHERE customer_id = '1001';

SCD Type 2 — Insert New Row: The most powerful method. You insert a new row, mark the old one as historical and the new one as current. This requires a surrogate key (independent of the business key), validity dates, and an is_current flag.

-- SCD Type 2: New row for history
-- Step 1: Close old row
UPDATE dim_customer
SET valid_to = CURRENT_DATE,
    is_current = FALSE
WHERE customer_id = '1001'
  AND is_current = TRUE;

-- Step 2: Insert new row
INSERT INTO dim_customer (customer_id, name, city, segment, region,
                          loyalty_tier, valid_from, valid_to, is_current)
VALUES ('1001', 'Anna Müller', 'Hamburg', 'Premium', 'Nord',
        'Gold', CURRENT_DATE, '9999-12-31', TRUE);

SCD Type 3 — Previous Value as Column: A compromise. You add an additional column for the previous value. Limited to one level of change, but simple to implement and query.

-- SCD Type 3: Previous-Value Column
ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);

UPDATE dim_customer
SET previous_city = city,
    city = 'Hamburg'
WHERE customer_id = '1001';

I implemented SCD Type 2 for my customer dimension and loaded historical data. The effect was stunning: Suddenly I could analyze how customer revenue developed before and after a segment change. The time travel machine worked.

Slowly Changing Dimensions Type 1 and Type 2 comparison
Figure 4: SCD Type 1 (top) simply overwrites — the red-marked cells show the lost history. SCD Type 2 (bottom) preserves historical context: The pink row is the closed historical record, the green one is the current record.

Chapter 5: Layer Architecture — From Raw Data Chaos to Analytics Gold

A star schema alone doesn't make a data warehouse. The real architectural decision lies in the question: How do I transform raw data into analysis-ready structures? The answer is a layered model — sometimes called "Medallion Architecture" (Bronze → Silver → Gold), sometimes named by function (Raw → Staging → Business → Mart).

On my server, I implemented four layers:

1. Raw / Staging Layer: Data lands here exactly as it comes from the source. No filtering, no transforming, no interpreting. Append-only. This is the "Single Source of Truth" — when something goes wrong downstream, you can go back here.

CREATE SCHEMA raw_layer;

CREATE TABLE raw_layer.orders (
    _loaded_at    TIMESTAMP DEFAULT NOW(),
    _source       VARCHAR(50),
    _raw_json     JSONB,
    order_id      VARCHAR(50),
    customer_id   VARCHAR(50),
    order_date    VARCHAR(50),  -- Still VARCHAR! No casting in the raw layer.
    total_amount  VARCHAR(50),
    status        VARCHAR(50)
);

2. Cleaned / Intermediate Layer: This is where we clean, cast, and deduplicate. Type casting, NULL handling, format standardization. The string '2025-03-15' becomes a real DATE. The string 'EUR 1,234.56' becomes 1234.56.

CREATE SCHEMA clean_layer;

CREATE TABLE clean_layer.stg_orders AS
SELECT
    order_id,
    customer_id,
    CAST(order_date AS DATE) AS order_date,
    CAST(REPLACE(REPLACE(total_amount, 'EUR ', ''), ',', '.') AS DECIMAL(12,2))
        AS total_amount,
    UPPER(TRIM(status)) AS status,
    NOW() AS _processed_at
FROM raw_layer.orders
WHERE order_id IS NOT NULL
  AND order_date ~ '^\d{4}-\d{2}-\d{2}$';

3. Business / Core Layer: This is where dimensional models are born — star or snowflake schemas with fact and dimension tables. Business logic is defined once and reused consistently.

4. Presentation / Mart Layer: Audience-specific views or materialized tables. The marketing mart shows customer segmentation, the finance mart shows revenue breakdown, the ML team gets feature tables.

CREATE SCHEMA mart_layer;

-- Materialized View for the marketing team
CREATE MATERIALIZED VIEW mart_layer.marketing_customer_360 AS
SELECT
    c.customer_key,
    c.name,
    c.segment,
    c.loyalty_tier,
    COUNT(DISTINCT f.sale_id) AS total_orders,
    SUM(f.revenue) AS lifetime_value,
    AVG(f.revenue) AS avg_order_value,
    MAX(d.full_date) AS last_purchase_date,
    MIN(d.full_date) AS first_purchase_date
FROM fct_sales f
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE c.is_current = TRUE
GROUP BY c.customer_key, c.name, c.segment, c.loyalty_tier;

-- Refresh schedule as cron job
-- 0 3 * * * psql -d warehouse -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mart_layer.marketing_customer_360;"

This layer separation sounds like overhead, but it's a safety net. When the source API changes, only the raw and clean layers are affected. When the business needs a new metric, you only change the mart. Separation of Concerns — but for data.

Data Warehouse layer architecture from Sources to Presentation
Figure 5: The Medallion Architecture in practice — five layers from raw data sources to the presentation tier. Each layer has clear responsibilities and transformation rules.

Chapter 6: Partitioning — The Art of Dividing

With growing data volumes, I hit the next performance plateau: Even my optimized star schema with indexes became slow when the fact table exceeded 100 million rows. EXPLAIN ANALYZE showed: Full Index Scan on idx_fct_sales_date — the index had grown so large it became a bottleneck itself.

The solution: Table Partitioning. PostgreSQL has supported declarative partitioning since version 10 — you split a large table into physically separate subtables that logically appear as one.

-- Partitioned fact table
CREATE TABLE fct_sales_partitioned (
    sale_id      SERIAL,
    date_key     INTEGER NOT NULL,
    product_key  INTEGER,
    customer_key INTEGER,
    store_key    INTEGER,
    quantity     INTEGER NOT NULL,
    revenue      DECIMAL(12,2) NOT NULL,
    discount     DECIMAL(5,2) DEFAULT 0,
    sale_date    DATE NOT NULL
) PARTITION BY RANGE (sale_date);

-- Monthly partitions
CREATE TABLE fct_sales_2025_01 PARTITION OF fct_sales_partitioned
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE fct_sales_2025_02 PARTITION OF fct_sales_partitioned
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE fct_sales_2025_03 PARTITION OF fct_sales_partitioned
    FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

-- Automatic partition creation via function
CREATE OR REPLACE FUNCTION create_monthly_partition(target_date DATE)
RETURNS VOID AS $$
DECLARE
    partition_name TEXT;
    start_date DATE;
    end_date DATE;
BEGIN
    start_date := DATE_TRUNC('month', target_date);
    end_date := start_date + INTERVAL '1 month';
    partition_name := 'fct_sales_' || TO_CHAR(start_date, 'YYYY_MM');

    EXECUTE FORMAT(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF fct_sales_partitioned
         FOR VALUES FROM (%L) TO (%L)',
        partition_name, start_date, end_date
    );
END;
$$ LANGUAGE plpgsql;

The effect was dramatic. A query on March 2025 now scanned only the partition fct_sales_2025_03 with 2 million rows instead of the entire table with 100 million. Query time dropped from 45 seconds to 1.5 seconds — a 97% reduction.

But partitioning alone was only half the battle. Storage format played an equally important role. For my export files on the server, I tested various formats:

#!/bin/bash
# Benchmark: CSV vs. Parquet with DuckDB
# DuckDB can read Parquet directly — perfect for analytical queries

# CSV Export (100M rows)
time psql -d warehouse -c "\COPY fct_sales TO '/data/exports/sales.csv' CSV HEADER"
# → 100 GB, 12 MB/s read speed

# Parquet Export with DuckDB
duckdb << 'SQL'
COPY (SELECT * FROM read_csv_auto('/data/exports/sales.csv'))
TO '/data/exports/sales.parquet' (FORMAT PARQUET, COMPRESSION SNAPPY);
SQL
# → 15 GB (85% compression!), 78 MB/s read speed

# Analytical query directly on Parquet
duckdb << 'SQL'
SELECT
    EXTRACT(YEAR FROM sale_date) AS year,
    SUM(revenue) AS total_revenue
FROM read_parquet('/data/exports/sales.parquet')
WHERE sale_date >= '2025-01-01'
GROUP BY year;
SQL
# → 0.3 seconds (vs. 5.2 seconds on CSV)

The combination of partitioned PostgreSQL for operational use and Parquet exports for ad-hoc analysis with DuckDB turned out to be the ideal setup for my Linux server. Enterprise-level architecture, zero-budget edition.

Partition pruning effect and storage format comparison
Figure 6: Left: The dramatic effect of partition pruning — from 45 seconds to 0.3 seconds with daily partitioning. Right: Storage format comparison — Parquet with Snappy compression offers the best balance of size and speed.

Epilogue: The Warehouse as a Living Organism

What started as frustration over a slow query became a fundamental lesson in data architecture. A data warehouse isn't a static construct — it's a living organism that grows and adapts with requirements.

The key takeaways from my server expedition:

  • Star schema for analytical workloads: Fewer joins, faster queries, self-documenting code. Snowflake only when you have genuine storage problems.
  • SCD Type 2 for everything that has history: The investment in surrogate keys and validity dates pays off with every historical analysis.
  • Layer separation is not optional: Raw → Clean → Business → Mart. Each layer has a responsibility. No shortcuts.
  • Partitioning from day 1: Don't wait until the table has 100 million rows. Choose the partition key early and use it consistently.
  • Columnar formats for analytics: Parquet/ORC for everything that gets aggregated. CSV is a transport format, not an analytics format.

My query now runs in 0.3 seconds instead of 47. The server purrs contentedly. And I've learned that the best architecture isn't the most complex one — it's the one you can understand, maintain, and evolve.

Citations & Sources

  1. Kimball, R. & Ross, M. (2013). The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling. 3rd Edition. Wiley.
  2. Inmon, W. H. (2005). Building the Data Warehouse. 4th Edition. Wiley.
  3. PostgreSQL Documentation: Table Partitioning. postgresql.org/docs/current/ddl-partitioning.html
  4. Apache Parquet Format Specification. parquet.apache.org/documentation/latest/
  5. DuckDB Documentation: Parquet Import/Export. duckdb.org/docs/data/parquet/overview
  6. Kimball Group: Slowly Changing Dimensions. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/
  7. Databricks: Medallion Architecture. docs.databricks.com/en/lakehouse/medallion.html
  8. Linstedt, D. & Olschimke, M. (2015). Building a Scalable Data Warehouse with Data Vault 2.0. Morgan Kaufmann.

Conclusion

Data warehouse design isn't rocket science — it's craftsmanship. With PostgreSQL on a Linux server, the right schema decisions, and a clear layered model, you can build analytical systems that compete with commercial solutions. The tools are freely available, the documentation is excellent, and the best part: you can test everything yourself, make mistakes, and learn from them. That's the true value of a self-directed project — not the result, but the journey.

Technical Documentation

AspectDetail
DatabasePostgreSQL 16 on Ubuntu Linux
Schema TypeStar Schema (primary), Snowflake (comparison)
SCD StrategyType 2 with Surrogate Keys & Validity Dates
PartitioningRange Partitioning by date (monthly/daily)
Export FormatParquet + Snappy Compression
Analytics EngineDuckDB for ad-hoc queries on Parquet
Test Data50-100 million synthetic sales records
Visualizationmatplotlib 3.8+, Python 3.11
No track selected

Click play to start