Fan-Out Join Explosion
Many-to-many join cardinality mistakes multiply rows before GROUP BY or SUM, inflating metrics and hiding true totals.
Search across all documentation pages
Many-to-many join cardinality mistakes multiply rows before GROUP BY or SUM, inflating metrics and hiding true totals.
Quick-reference recipe card - copy-paste ready.
-- WRONG: tags fan out line items
SELECT o.order_id, SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN order_tags ot ON ot.order_id = o.order_id
GROUP BY o.order_id;
-- RIGHT: aggregate facts before optional dimensions
SELECT o.order_id, agg.revenue
FROM orders o
JOIN (
SELECT order_id, SUM(quantity * unit_price) AS revenue
FROM order_items
GROUP BY order_id
) agg ON agg.order_id = o.order_id
LEFT JOIN order_tags ot ON ot.order_id = o.order_id;When to reach for this:
EXPLAIN row counts explode after adding a new join.has_many associations in one query.BEGIN;
CREATE TABLE orders (order_id bigint PRIMARY KEY);
CREATE TABLE order_items (
order_id bigint REFERENCES orders (order_id),
product_id bigint,
quantity integer,
unit_price numeric(12,2),
PRIMARY KEY (order_id, product_id)
);
CREATE TABLE order_tags (
order_id bigint REFERENCES orders (order_id),
tag text,
PRIMARY KEY (order_id, tag)
);
INSERT INTO orders VALUES (1);
INSERT INTO order_items VALUES (1, 10, 2, 50.00), (1, 20, 1, 30.00);
INSERT INTO order_tags VALUES (1, 'gift'), (1, 'rush'), (1, 'vip');
-- True revenue: 2*50 + 1*30 = 130
SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = 1;
-- Fan-out wrong answer: 130 * 3 tags = 390
SELECT SUM(oi.quantity * oi.unit_price)
FROM order_items oi
JOIN order_tags ot ON ot.order_id = oi.order_id
WHERE oi.order_id = 1;
COMMIT;What this demonstrates:
SUM without GROUP BY on inflated join over-counts by tag cardinality.GROUP BY after fan-out can still be wrong if non-aggregated columns come from fan-out side incorrectly.COUNT(*) after fan-out counts joined rows, not distinct business entities.LEFT JOIN chains are a common source of silent inflation.SELECT
(SELECT COUNT(*) FROM order_items WHERE order_id = 1) AS items,
(SELECT COUNT(*) FROM order_tags WHERE order_id = 1) AS tags,
(SELECT COUNT(*) FROM order_items oi JOIN order_tags ot ON ot.order_id = oi.order_id WHERE oi.order_id = 1) AS joined_rows;If joined_rows = items * tags, you have fan-out.
| Goal | Pattern |
|---|---|
| Order revenue | Aggregate order_items grouped by order_id first |
| Distinct tag list | string_agg(DISTINCT tag, ',') after correct grain |
| Filter orders by tag | EXISTS (SELECT 1 FROM order_tags ...) |
| Count orders with tag | COUNT(DISTINCT o.order_id) with careful joins |
SELECT DISTINCT hides duplicates but breaks sums. Fix: fix grain, not DISTINCT on wrong query.AVG(rating) weighted wrong when reviews duplicate per line item. Fix: subquery per entity.| Alternative | Use When | Don't Use When |
|---|---|---|
| Subquery aggregation | OLTP reports, few metrics | Many dimensions - use warehouse |
EXISTS filter | Tag/status filters only | Need tag columns in SELECT |
| Materialized view at order grain | Repeated dashboard | Need real-time line items |
| Window functions | Row-level allocation | Simple totals - subquery is clearer |
Compare actual rows from nested loop or hash join to expected fact count. Multi-hundred multiplier on small order_id sample is a red flag.
Only if all selected non-aggregated columns are functionally dependent on order_id and aggregates use correct grain. Fan-out columns in SELECT can still duplicate groups incorrectly.
Works for counting entities sometimes but SUM/AVG still wrong on fan-out. Fix grain first.
Including multiple includes on has_many associations generates one giant join. Use separate queries or careful preload strategy.
Yes - DELETE ... USING join may delete fact rows multiple times or miss rows. Use subquery keys.
Can be, if LATERAL subquery returns one row per parent at correct grain. Still requires cardinality analysis.
Fixture with known line items and tags; assert revenue query equals 130 not 390.
Star schemas define fact grain explicitly - still possible to mis-join bridge tables if grain ignored.
The smallest unit a measure summarizes - order line, not order line times tag.
Rarely in aggregates. Exploding rows for allocation algorithms needs explicit normalization step dividing measures.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 19, 2026