Chapter 09 · Bridge Tables and Many-to-Many Analytical Relationships

Why Direct Many-to-Many Joins Can Duplicate Facts and Corrupt Metrics

Reproduce fact multiplication through a many-to-many customer-household relationship, explain why DISTINCT is not a general repair, and prove a safe bridge query from row counts and control totals.

Intermediate → Advanced110–130 minutesDouble-count diagnosis labPython stdlib sqlite3 · local/syntheticLast reviewed: September 2026

Learning outcomes

AtlasMart wants sales by household. A customer can legitimately belong to more than one household at the same business time, so joining the order-line fact directly through that relationship produces multiple rows for one measurement event. The database has not “made a mistake”; the query has crossed a many-to-many relationship without defining what repeated measures mean. This lesson makes that multiplication visible before introducing the repair.

01

Declare the grain of the base fact and bridge independently before writing a join.

02

Use row counts and control totals to prove where a many-to-many join multiplies measurement events.

03

Explain why SUM(DISTINCT measure) is not a semantic repair for repeated fact rows.

04

Distinguish participation questions from allocated-measure questions.

05

Repair the household analysis with an explicit temporal bridge and allocation contract.

Chapter 09 continuity contract

Chapter 09 starts from the accepted Chapter 08 state. Canonical paid sales remain seven order-line facts, four paid orders, nine units, 625 paid GMV, 380 cost-at-sale, and 245 gross profit. Customer history keeps Chapter 07's half-open business-effective intervals; Chapter 08's date-role, junk, degenerate, mini-dimension, and inferred-member patterns remain unchanged. Chapter 09 adds a synthetic customer-to-household many-to-many relationship solely to teach bridge semantics. The bridge does not alter the grain of fact_sales_history, does not rewrite customer surrogate keys, and does not redefine the accepted sales measures.

Execution and interpretation note

The mandatory lab uses Python's standard-library sqlite3 module and synthetic local data. Record the actual Python and SQLite versions before execution. Allocation weights, household membership, household types, and effective dates are explicit AtlasMart teaching contracts, not universal business rules. The local fixture proves relational and arithmetic semantics in one process; it does not prove distributed concurrency, cloud optimizer behavior, CDC ordering, production privacy policy, or performance at warehouse scale.

Important boundary

A bridge is not permission to sum every fact after every join. The analyst must know whether the question asks for presence/participation, distinct events, or allocated additive measures. The SQL differs because the business semantics differ.

1. Start from the unchanged atomic fact

The accepted sales fact grain is still one row per paid order line. Household membership is a separate relationship; it must not be pushed into the fact by cloning the sales row once per household because that would change the physical row population and invite accidental double counting.

Control Accepted value Reason
fact rows 7 one row per paid order line
paid orders 4 O1001, O1002, O1003, O1005
units 9 additive at order-line grain
paid GMV 625 sum of extended_amount
cost-at-sale 380 sum of extended_cost
gross profit 245 GMV minus cost-at-sale

The household bridge grain is one customer-to-household membership for one effective interval. Those two grains are compatible for an as-of relationship, but they are not the same grain.

Customer Effective interval Household(s) Weights
D-CUST-001 2026-01-01 ≤ t < 2026-09-19 H-FAMILY-A; H-BUSINESS-A 0.60; 0.40
D-CUST-001 2026-09-19 ≤ t H-FAMILY-A 1.00
D-CUST-002 2026-01-01 ≤ t H-FAMILY-B 1.00
D-CUST-004 2026-01-01 ≤ t H-FAMILY-C; H-SHARED-C 0.50; 0.50

2. Minimal bridge DDL and why the keys matter

chapter09_schema.sql
PRAGMA foreign_keys = ON;DROP TABLE IF EXISTS bridge_category_ancestor;DROP TABLE IF EXISTS dim_category;DROP TABLE IF EXISTS bridge_customer_household;DROP TABLE IF EXISTS dim_household;DROP TABLE IF EXISTS fact_sales_history;CREATE TABLE fact_sales_history (  order_id TEXT NOT NULL,  line_no INTEGER NOT NULL,  event_date TEXT NOT NULL,  durable_customer_id TEXT NOT NULL,  product_id TEXT NOT NULL,  quantity INTEGER NOT NULL,  extended_amount NUMERIC NOT NULL,  extended_cost NUMERIC NOT NULL,  PRIMARY KEY(order_id,line_no));CREATE TABLE dim_household (  household_sk INTEGER PRIMARY KEY,  household_code TEXT NOT NULL UNIQUE,  household_name TEXT NOT NULL,  household_type TEXT NOT NULL);CREATE TABLE bridge_customer_household (  durable_customer_id TEXT NOT NULL,  household_sk INTEGER NOT NULL REFERENCES dim_household(household_sk),  effective_from TEXT NOT NULL,  effective_to TEXT NOT NULL,  allocation_weight NUMERIC NOT NULL CHECK(allocation_weight > 0 AND allocation_weight <= 1),  membership_role TEXT NOT NULL,  CHECK(effective_from < effective_to),  PRIMARY KEY(durable_customer_id, household_sk, effective_from));CREATE INDEX ix_bridge_customer_time  ON bridge_customer_household(durable_customer_id,effective_from,effective_to);CREATE TABLE dim_category (  category_sk INTEGER PRIMARY KEY,  category_code TEXT NOT NULL UNIQUE,  category_name TEXT NOT NULL);CREATE TABLE bridge_category_ancestor (  descendant_category_sk INTEGER NOT NULL REFERENCES dim_category(category_sk),  ancestor_category_sk INTEGER NOT NULL REFERENCES dim_category(category_sk),  distance INTEGER NOT NULL CHECK(distance >= 0),  PRIMARY KEY(descendant_category_sk,ancestor_category_sk));

The fact keeps the durable customer identifier only because this chapter is isolating bridge mechanics from the customer SCD join already established in Chapter 07. In a production dimensional model, the chosen durable/history keys must follow the conformance contract. The bridge carries the multivalued membership and its temporal/allocation semantics; it does not own the sales measure.

3. Reproduce the bug: seven fact rows become eleven joined rows

wrong_many_to_many.sql
SELECT f.order_id,f.line_no,f.event_date,f.durable_customer_id,       h.household_code,b.allocation_weight,f.extended_amountFROM fact_sales_history AS fJOIN bridge_customer_household AS b  ON b.durable_customer_id = f.durable_customer_id AND b.effective_from <= f.event_date AND f.event_date < b.effective_toJOIN dim_household AS h ON h.household_sk=b.household_sk-- Deliberately wrong for additive dollars:SELECT COUNT(*) AS joined_rows,       SUM(f.extended_amount) AS wrong_gmvFROM fact_sales_history fJOIN bridge_customer_household b  ON b.durable_customer_id=f.durable_customer_id AND b.effective_from <= f.event_date AND f.event_date < b.effective_to;

Expected evidence from the deterministic fixture is joined_rows = 11 and wrong_gmv = 900, even though the base control is seven rows and 625. O1001's two lines each match two household memberships, and O1005's two lines also match two memberships. Multiplication is therefore observable in row cardinality before it is visible in the money total.

4. Why DISTINCT is not a universal rescue

SUM(DISTINCT extended_amount) deduplicates equal numeric values, not duplicate business events. AtlasMart legitimately has multiple different order lines with the same extended amount (for example, 100 and 50 appear on more than one line). On this fixture, summing the distinct amount values returns 375 rather than 625. A SQL keyword cannot infer which repeated values are duplicates versus separate sales.

distinct_is_not_grain.sql
SELECT SUM(DISTINCT extended_amount) AS wrong_distinct_gmvFROM fact_sales_history;-- Expected in this fixture: 375, not 625.

5. Repair: allocate only when the business owns the rule

For the teaching question “allocate paid GMV across simultaneous households,” AtlasMart declares weights that sum to 1.0 for each customer membership interval. The query multiplies each atomic amount by the relationship weight, then groups. This creates an allocated analytical measure; it does not claim the customer literally paid fractional dollars from each household.

weighted_household_rollup.sql
SELECT h.household_code,       ROUND(SUM(f.extended_amount*b.allocation_weight),2) AS allocated_gmvFROM fact_sales_history AS fJOIN bridge_customer_household AS b  ON b.durable_customer_id=f.durable_customer_id AND b.effective_from <= f.event_date AND f.event_date < b.effective_toJOIN dim_household AS h ON h.household_sk=b.household_skGROUP BY h.household_codeORDER BY h.household_code
Household Historical allocated GMV
H-BUSINESS-A 50.00
H-FAMILY-A 225.00
H-FAMILY-B 200.00
H-FAMILY-C 75.00
H-SHARED-C 75.00
Control total 625.00

The allocated household totals sum to 625. Reconciliation proves conservation of the base measure under the declared allocation policy. It does not prove that 60/40 is the right business policy—that requires an owner and definition.

6. Presence questions need different SQL

If the question is “which households participated in orders?” allocation may be irrelevant. Use distinct fact identifiers or pre-aggregated event counts according to the declared grain. Conversely, if the question is “how much GMV should each household receive credit for?”, a weighting policy is required. Mixing those intents is how bridge tables turn into hidden semantic bugs.

Knowledge check

Check your understanding

  1. Why did the joined row count increase from 7 to 11?
  2. Why is SUM(DISTINCT extended_amount) unsafe?
  3. What proves the weighted query is arithmetically conservative?
  4. Does reconciliation prove the 60/40 business rule is correct?
  5. When might weights be unnecessary?
Review the answers

1. Some sales rows matched multiple simultaneously valid household memberships, so the relationship expanded one measurement event into multiple joined rows.

2. It deduplicates equal numeric values, not repeated fact identities, so legitimate equal-valued sales can disappear.

3. The sum of allocated household GMV reconciles exactly to the 625 atomic sales control.

4. No. It proves arithmetic consistency with the declared rule; business ownership must justify the rule itself.

5. For presence, membership, or distinct-event questions where the measure is not being allocated across the multivalued dimension.

Summary and next step

A bridge preserves legitimate multivalued relationships without changing the fact grain, but every query crossing it must state whether it is counting participation or allocating measures. The next lesson generalizes this into direct bridges, group bridges, memberships, and hierarchy bridges.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.