Extended statistics in PostgreSQL

Unterschätztes Postgres: CREATE STATISTICS (Extended Statistics)

Extended Statistics helfen PostgreSQL dabei, schlechte Zeilenschätzungen zu korrigieren und bessere Pläne zu wählen, fast ohne zusätzlichen Storage.

Chris Engelbert 22 min

Kurzfassung: Wenn Queries langsam sind, denkt man zuerst an Indizes. PostgreSQL hat dafür viele Werkzeuge, von Multi-Column- über Partial- bis hin zu Expression-Indizes. Das Problem: Jeder zusätzliche Index kostet Storage, Schreibaufwand und WAL. Was viele übersehen: Extended Statistics. Sie verbessern Query-Pläne fast ohne zusätzlichen Speicherbedarf.

Das eigentliche Problem hinter vielen schlechten Plänen ist nicht Faulheit des Planners, sondern ein falsches Bild der Daten. Wenn mehrere Filter korreliert sind, reichen per-Column-Statistiken oft nicht aus. Genau hier setzt CREATE STATISTICS an.

Die Kurzversion

CREATE STATISTICS erlaubt Postgres, zusätzliche Informationen zu sammeln, die normale Spaltenstatistiken nicht ausdrücken können.

Praktisch hilft das vor allem bei vier Problemen:

  • korrelierten Filtern über mehrere Spalten,
  • Distinct-Schätzungen über mehrere Spalten,
  • schiefen, ungleich verteilten Wertekombinationen,
  • Ausdrücken, bei denen man sonst einen Expression-Index bauen würde.
-- Alternative to index on expressions
CREATE STATISTICS stats_expr
ON (date_trunc(day, created_at))
FROM events;
 
--- What people normally mean by
--- extended statistics
CREATE STATISTICS stats_multi
(dependencies, ndistinct, mcv)
ON plan_tier, region, billing_status
FROM tenants;

Die erste Form hilft zum Beispiel bei Ausdrucksstatistiken wie date_trunc oder JSONB-Extraktion. Die zweite Form ist das, was die meisten mit „Extended Statistics“ meinen.

Es gibt drei relevante Statistikarten: dependencies, ndistinct und mcv. Und wie bei normalen Statistiken gilt: CREATE STATISTICS definiert nur das Objekt, ANALYZE füllt es mit Daten.

Warum Extended Statistics?

Postgres legt standardmäßig vor allem Statistiken pro Spalte an. Das funktioniert gut, solange einzelne Filter unabhängig betrachtet werden können. Problematisch wird es, wenn Beziehungen zwischen Spalten entscheidend sind.

WHERE plan_tier = 'enterprise'
AND region = 'eu-central'
AND billing_status = 'active'

In realen Daten sind solche Prädikate oft korreliert. Ohne zusätzliche Statistik behandelt PostgreSQL sie aber so, als ob jede Bedingung die Zeilenmenge unabhängig reduziert. Das führt zu formal sauberen, operativ aber absurden Schätzungen.

Fixing the execution plan with extended statistics

Und sobald die Schätzung am Anfang falsch ist, wird danach fast alles wackelig: Join-Reihenfolge, Join-Typ, Parallelisierung, Hash-Größe, Sort-Strategie, Spill-Risiko.

Was Postgres standardmäßig weiß

Mit jedem ANALYZE speichert PostgreSQL pro Spalte Informationen wie Null-Anteile, Histogrammgrenzen, häufige Werte oder Distinct Counts. Das ist nützlich, aber eben nur spaltenlokal.

Bei einer Tabelle mit einer Million Zeilen könnte Postgres beispielsweise so rechnen:

  • plan_tier = 'enterprise' trifft 6 %
  • region = 'eu-central' trifft 25 %
  • billing_status = 'active' trifft 80 %
1,000,000 * 0.06 * 0.25 * 0.80 = 12,000 rows

Das Problem: In der Realität sind diese Spalten oft nicht unabhängig. Die echte Antwort kann 40.000, 60.000 oder auch nur 5.000 Zeilen sein. Normale Einzelspaltenstatistiken können das nicht sagen.

Benchmarking von Extended Statistics

Um das greifbar zu machen, habe ich einen synthetischen, aber bewusst realistischen SaaS-Analytics-Workload mit vier Tabellen gebaut: tenants, tenant_entitlements, usage_rollups und analytics_events.

Der Datensatz wurde gezielt so geformt, dass die Schwächen des Planners sichtbar werden: Korrelationen zwischen Tarif, Region und Billing-Status, konzentrierte Premium-Entitlements und nicht zufällige (tenant, feature, day)-Verteilungen.

Die Daten sind synthetisch, aber so synthetisch wie ein Windkanal: kontrolliert, um ein reales Phänomen isoliert zu zeigen.

Das Problem über Datenformen isolieren

Tabelle: tenants

Die Segmentierung nach Tarif, Region und Billing-Status ist bewusst korreliert, wie in realen SaaS-Daten.

Tabelle: tenant_entitlements

Premium-Features wie sso, audit_logs oder workflows sind häufiger bei Enterprise-Tenants konzentriert.

Tabelle: usage_rollups

Diese Tabelle modelliert realistische zeitgebundene Fanout-Effekte pro Tenant-Feature-Kombination.

Tabelle: analytics_events

Hier liegt die eigentliche Explosion. Für jede passende Tenant-Feature-Kombination gibt es potenziell viele Rohereignisse, besonders bei „heißen“ Kombinationen.

Die Reporting-Query

Die zentrale Frage lautet: „Welche Premium-Features erzeugen unter aktiven Enterprise-Tenants in einer wichtigen Region zuletzt die meiste Aktivität?“

SELECT
e.feature_name,
count(*) AS total_events,
sum(r.request_count) AS rolled_requests
FROM tenants t
JOIN tenant_entitlements te
ON te.tenant_id = t.id
JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
WHERE t.plan_tier = 'enterprise'
AND t.region = 'eu-central'
AND t.billing_status = 'active'
AND te.entitlement IN ('sso', 'audit_logs')
AND r.rollup_day >= CURRENT_DATE - 30
AND e.event_type IN ('view', 'click', 'run')
GROUP BY e.feature_name
ORDER BY rolled_requests DESC;

Für mehrere Regionen gleichzeitig gibt es eine leicht erweiterte Variante mit CTE und nachgelagerter Aggregation.

WITH regional_feature_totals AS (
SELECT
t.region,
e.feature_name,
count(*) AS total_events,
sum(r.request_count) AS rolled_requests
FROM tenants t
JOIN tenant_entitlements te
ON te.tenant_id = t.id
JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
WHERE t.plan_tier = 'enterprise'
AND t.billing_status = 'active'
AND t.region IN ('eu-central', 'ap-southeast', 'us-east')
AND te.entitlement IN ('sso', 'audit_logs', 'workflows')
AND r.rollup_day >= CURRENT_DATE - 30
AND e.event_type IN ('view', 'click', 'run')
GROUP BY t.region, e.feature_name
)
SELECT region, feature_name, total_events, rolled_requests
FROM (
SELECT
region,
feature_name,
total_events,
rolled_requests,
row_number() OVER (
PARTITION BY region
ORDER BY rolled_requests DESC,
total_events DESC,
feature_name
) AS regional_rank
FROM regional_feature_totals
) ranked
WHERE regional_rank <= 3
ORDER BY region, regional_rank;

Die Form der Query zerlegen

Stufe 1: Tenant-Slice definieren

WHERE t.plan_tier = 'enterprise'
AND t.billing_status = 'active'
AND t.region IN ('eu-central', 'ap-southeast', 'us-east')

Wenn Postgres diese erste Auswahl schon falsch einschätzt, basiert der gesamte restliche Plan auf der falschen Population.

Stufe 2: Auf berechtigte Features einschränken

JOIN tenant_entitlements te
ON te.tenant_id = t.id
...
AND te.entitlements IN (...)

Die Entitlement-Tabelle ist hier nicht nur Join-Ziel, sondern zugleich ein korrelierter Feature-Filter.

Stufe 3: Aktuelle Nutzung binden

JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
...
AND r.rollup_day >= CURRENT_DATE - 30

Hier beginnt die Vervielfachung: Ein Tenant kann mehrere passende Entitlements und pro Feature mehrere Rollup-Tage haben.

Stufe 4: Auf Rohereignisse erweitern

JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
...
AND e.event_type IN (...)

Spätestens an dieser Stelle werden schlechte Row Estimates teuer. Nested Loops können billig aussehen, obwohl sie es nicht sind.

Stufe 5: Die Explosion wieder klein aggregieren

GROUP BY e.feature_name
ORDER BY rolled_requests DESC
GROUP BY t.region, e.feature_name

Das Endergebnis ist klein, der Weg dahin aber überhaupt nicht. Genau das macht diese Query als Planner-Demo so wertvoll.

Stufe 6: Ranking in der Multi-Region-Query

row_number() OVER (
PARTITION BY region
ORDER BY rolled_requests DESC,
                 total_events DESC,
                 feature_name
) AS regional_rank
...
--- followed by
WHERE regional_rank <= 3

Hier kommt zusätzlicher Sort- und Temp-I/O-Druck hinzu. Die Query ist also nicht nur Join- und Aggregationsproblem, sondern auch ein Ranking-Problem.

Extended Statistics als Rettung

Der erste Reflex bei solchen Problemen ist oft: mehr Multi-Column-, Partial- oder Expression-Indizes. Das kann helfen, aber für diesen Fall nutzen wir drei Statistikobjekte, um die eigentlichen Fehlannahmen des Planners zu adressieren.

  • korrelierte Tenant-Spalten,
  • Tenant-Entitlement-Kombinationen,
  • Rollup-Kombinationen.

Wichtig: Nach dem Anlegen muss wieder ANALYZE laufen.

Typ: dependencies

Funktionale Abhängigkeitsstatistiken helfen PostgreSQL zu erkennen, dass manche Spalten andere so stark vorhersagen, dass Unabhängigkeitsannahmen falsch sind.

  • Land sagt oft Währung voraus.
  • Bundesstaat sagt oft Zeitzone voraus.
  • Tenant-Segment sagt oft Entitlement-Familien voraus.
CREATE STATISTICS stats_tl_segment_dependencies (dependencies)
ON id, plan_tier, region, billing_status
FROM tenants;
WHERE plan_tier = 'enterprise'
AND region = 'eu-central'
AND billing_status = 'active'

Als Merksatz: dependencies korrigiert „diese Filter sind nicht unabhängig“.

Typ: mcv

MCV steht für „most common values“, bei Extended Statistics aber besser gedacht als „most common combinations“.

CREATE STATISTICS stats_tl_segment_mcv (mcv)
ON plan_tier, region, billing_status
FROM tenants;

Damit lernt PostgreSQL zum Beispiel, dass bestimmte Kombinationen besonders häufig, besonders selten oder überhaupt unmöglich sind. Als Merksatz: mcv korrigiert „diese exakte Kombination verhält sich anders als der Durchschnitt“.

Typ: ndistinct

ndistinct sagt PostgreSQL, wie viele unterschiedliche Kombinationen mehrerer Spalten tatsächlich existieren, statt nur die Distinct-Werte je Spalte zu kennen.

CREATE STATISTICS stats_tel_entitlement_ndistinct (ndistinct)
ON tenant_id, entitlement
FROM tenant_entitlements;
 
CREATE STATISTICS stats_url_feature_ndistinct (ndistinct)
ON tenant_id, feature_name, rollup_day
FROM usage_rollups;

Als Merksatz: ndistinct korrigiert „diese Gruppen- oder Join-Kombinationen sind viel weniger zahlreich, als der Planner denkt“.

Was PostgreSQL speichert

Hier wird es besonders interessant. Man könnte all diese Probleme auch mit mehr Indizes angehen. Aber Indizes sind teuer zu pflegen und teuer im Speicherverbrauch.

Extended Statistics sind dagegen erstaunlich billig. Sie leben in pg_statistic_ext und pg_statistic_ext_data und bestehen im Kern aus Planner-Metadaten.

  • pg_statistic_ext beschreibt die Statistikobjekte.
  • pg_statistic_ext_data enthält die gesammelten Daten.

Im Benchmark lag der Gesamtbedarf aller Statistikobjekte bei etwa 2.059 Bytes:

  • Tenant-Segment mcv: ~1.049 Bytes
  • Tenant-Segment dependencies: ~518 Bytes
  • Entitlement ndistinct: ~220 Bytes
  • Rollup ndistinct: ~272 Bytes

Das ist der Punkt: keine riesigen Schattenindizes, sondern kleine Metadaten, die den Planner weniger falsch machen.

Execution Plans reparieren

Ursprünglich wollte ich vor allem bessere Laufzeiten zeigen. Die wurden auch besser, aber nicht so spektakulär wie gehofft. Was sich jedoch massiv verbessert hat, waren die Schätzungen. Und genau das ist in vielen Fällen wichtiger.

ScenarioAverage TimeWorst EstimateAdditional Storage
Extended statistics2,857.896 ms48.27x2.0 KiB
Baseline + Composite Join-Key Indexes5,882.659 ms106.48x37.64 MiB
Baseline + Events (feature_name, event_type, tenant_id)4,781.551 ms108.7x31.45 MiB
Baseline + Partial Hot-Path Indexes4,765.351 ms106.22x30.20 MiB
Extended Statistics + Partial Hot-Path Indexes4,876.187 ms46.65x30.21 MiB

Jede Query wurde fünfmal ausgeführt. Die vollständigen Pläne und Messwerte liegen im Repository.

Single-Region-Query

Das Basisschema nutzt einfache Single-Column-Indizes. Das vermeidet vollständig sequenzielle Ausführung, zwingt den Planner aber trotzdem dazu, Korrelationen über tenants, entitlements, rollups und events zu raten.

MetricValue
Average execution time14,168.174 ms
Average planning time4.134 ms
Execution samples31,093.955 ms,
29,065.788 ms,
3,564.811 ms,
3,570.114 ms,
3,546.200 ms
Planning samples5.058 ms,
3.952 ms,
8.969 ms,
1.899 ms,
0.793 ms
Join chainNested Loop:Inner -> Nested Loop:Inner -> Nested Loop:Inner
Scan nodesIndex Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan, Index Scan
Top-level rows7 estimated / 2 actual
Worst estimateNested Loop: 178,389 estimated / 18,774,720 actual (105.25x)
Worst estimate pathSort -> Aggregate -> Nested Loop

Mit Extended Statistics sieht dasselbe Bild deutlich besser aus:

MetricValue
Average execution time2,857.896 ms
Average planning time3.025 ms
Execution samples4,318.854 ms,
2,612.261 ms,
2,396.848 ms,
2,467.702 ms,
2,493.816 ms
Planning samples6.719 ms,
2.067 ms,
2.128 ms,
2.243 ms,
1.970 ms
Join chainHash Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesBitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows7 estimated / 2 actual
Worst estimateHash: 12,966 estimated / 625,824 actual (48.27x)
Worst estimate pathSort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Hash Join -> Hash

Der Planner verhielt sich danach nicht mehr so, als wären die Zwischenmengen winzig. Ein wichtiger Nebeneffekt: Die geschätzten Gesamtkosten gingen hoch. Das ist kein Rückschritt, sondern ein Zeichen dafür, dass PostgreSQL das Problem endlich realistischer einschätzt.

Multi-Region-Query

Bei der komplexeren Multi-Region-Variante waren die Laufzeitgewinne noch sichtbarer.

Baseline:

MetricValue
Average execution time24,933.705 ms
Average planning time3.648 ms
Execution samples84,815.859 ms,
12,696.190 ms,
11,819.487 ms,
8,482.936 ms,
6,854.054 ms
Planning samples6.125 ms,
5.681 ms,
2.067 ms,
2.181 ms,
2.187 ms
Join chainMerge Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesSubquery Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows28 estimated / 5 actual
Worst estimateSort: 53,580 estimated / 17,164,496 actual (320.35x)
Worst estimate pathIncremental Sort -> Subquery Scan -> WindowAgg -> Incremental Sort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Merge Join -> Sort

Mit Extended Statistics:

MetricValue
Average execution time7,289.319 ms
Average planning time2.875 ms
Execution samples7,503.924 ms,
8,261.715 ms,
7,433.035 ms,
8,181.565 ms,
5,066.357 ms
Planning samples9.244 ms,
0.932 ms,
1.039 ms,
1.801 ms,
1.359 ms
Join chainHash Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesSubquery Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows28 estimated / 5 actual
Worst estimateNested Loop: 68,387 estimated / 1,113,566 actual (16.28x)
Worst estimate pathIncremental Sort -> Subquery Scan -> WindowAgg -> Incremental Sort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Hash Join -> Hash -> Nested Loop

Das ist keine kleine Verfeinerung mehr. Das ist PostgreSQL, das wieder in dieselbe Postleitzahl wie die Realität gezogen wird.

Wurden die Queries wirklich schneller?

Manchmal ja, manchmal nein. Genau deshalb lohnt sich ein nüchterner Blick auf dieses Thema.

Single-Region-Laufzeit

  • Baseline im Schnitt: 14,168.174 ms
  • Mit Extended Statistics: 2,857.896 ms

Das sieht spektakulär aus, wird aber stark von zwei sehr langsamen ersten Läufen im Baseline-Fall beeinflusst. Die vorsichtige Aussage ist also eher: bessere Planner-Kenntnis korrelierte hier mit besserem Steady-State-Verhalten.

Multi-Region-Laufzeit

  • Nur Statistics: 7,289.319 ms
  • Beste reine Index-Strategie: 6,886.473 ms

Das schwächt den Fall für CREATE STATISTICS nicht. Es präzisiert ihn nur: Statistiken verbessern Schätzungen, Indizes schaffen physische Zugriffspfade. Das sind verwandte, aber nicht identische Probleme.

Warum der Index-Vergleich wichtig ist

Ein spannendes Ergebnis war, dass indexlastige Varianten bei Kosten oder Laufzeit teilweise attraktiv wirkten, aber in der Schätzqualität deutlich schlechter blieben.

  • Extended Statistics: etwa 2.0 KiB
  • Index-lastige Alternativen: etwa 30.20 MiB bis 37.64 MiB

Difference in storage usage between extended statistics and indexes

Genau das ist der unterschätzte Punkt: Extended Statistics machten PostgreSQL für ungefähr 2 KiB Metadaten deutlich weniger falsch, während indexbasierte Alternativen zig MiB extra brauchten und das eigentliche Planner-Problem oft gar nicht lösten.

Warum „beides zusammen“ nicht automatisch gewann

Natürlich habe ich auch die naheliegende Frage getestet: Werden Extended Statistics plus gute Indizes automatisch noch besser?

Überraschenderweise nicht immer.

Single-Region:

  • Nur Statistics: 2,857.896 ms
  • Statistics + Partial Hot-Path Indexes: 4,876.187 ms

Multi-Region:

  • Nur Statistics: 7,289.319 ms
  • Statistics + Partial Hot-Path Indexes: 8,950.207 ms

Mehr Indizes schaffen nicht nur Möglichkeiten. Sie verändern auch den Suchraum des Planners. „Nimm einfach beides“ ist also keine Naturregel, sondern ebenfalls ein Benchmark-Thema.

Praktische Einschränkungen

Ein paar Grenzen sollte man klar benennen:

  • Extended Statistics gelten nur innerhalb einer Relation.
  • PostgreSQL nutzt sie nicht direkt für Join-Selectivity über Tabellen hinweg.
  • Nicht jeder Workload profitiert. Bei echten Unabhängigkeiten bringen sie wenig.
  • Sie ersetzen keine guten Queries, sinnvollen Indizes, Partitionierung, vernünftige Speicherwerte oder ein schnelles Storage-Backend.

Sie sind ein Planner-Informationsfeature, kein universeller Performance-Zauber.

Wann man zu CREATE STATISTICS greifen sollte

Die praktische Regel ist simpel: Wenn ein Plan falsch aussieht und das Problem mit mehreren gemeinsam gefilterten oder gruppierten Spalten zusammenhängt, denke an Extended Statistics, bevor du reflexhaft den nächsten Index anlegst.

Gute Kandidaten sind:

  • stark korrelierte Filterspalten,
  • segmentgetriebene Dimensionseigenschaften,
  • Group-by-Workloads mit offensichtlich falscher Kardinalität,
  • Multi-Column-Fanout-Probleme,
  • schiefe Kombinationen häufiger Werte,
  • Pläne, bei denen eine schlechte Einzelrelation-Schätzung den Rest vergiftet.

Schlechtere Kandidaten sind:

  • Queries mit fehlendem physischen Zugriffspfad,
  • Workloads, die klar I/O-limitiert sind,
  • Fälle, in denen die Spalten tatsächlich nahe an Unabhängigkeit liegen.

Nutze EXPLAIN (ANALYZE, VERBOSE, BUFFERS). Wenn Estimated und Actual Rows massiv auseinanderlaufen, können Extended Statistics genau richtig sein.

Wahrheit ist besser als Tricks

Das Schönste an CREATE STATISTICS ist, dass es den Planner mit mehr Wahrheit versorgt, statt einfach immer neue Strukturen auf die Daten zu stapeln.

Die Benchmarks sagen nicht „Extended Statistics gewinnen immer“. Sie sagen etwas Nützlicheres:

  • Sie machten PostgreSQL deutlich weniger falsch.
  • Sie änderten die Planform sinnvoll.
  • Sie kosten fast keinen Speicher.
  • Sie adressieren das Planner-Problem oft direkter als Dutzende MiB zusätzlicher Indizes.

Benchmark, Queries und Ergebnisse liegen auf GitHub. In unserer Vela Postgres Sandbox kannst du die Queries auch selbst testen.

Und wenn du je auf einen Plan geschaut und gedacht hast: „Es gibt keine Welt, in der dieser Join dreimal in einem Trenchcoat verschachtelt sein sollte“, dann könnten Extended Statistics genau das Feature sein, das du gesucht hast.