Hook: A Bill of Materials is just a product family tree, and interviewers love it because one small part can cascade into many needed parts.
Question: How do you write a SQL query to explode a Bill of Materials and calculate the total quantity needed for each component?
Answer: A Bill of Materials, or BOM, is a table that says which parts make up a parent part and how many of each child part are needed. The usual SQL solution is a recursive CTE, which means a Common Table Expression that can call itself until no more child rows are found. You start with the top product, then repeatedly join to its children and multiply quantities along the path.
Interview-Ready Answer: I would solve a BOM in SQL with a recursive CTE. First I anchor on the finished product, then I join each part to its children and multiply quantities as I go down the tree. That gives me the full exploded structure, and then I group by component to get the total required amount. I would also guard against cycles, because a bad BOM row can otherwise cause infinite recursion.
A Bill of Materials is a hierarchy. Think of a bike: the bike contains a frame, wheels, and a seat; the wheels contain rims, spokes, and hubs; the frame may contain bolts and paint. The key idea is that the quantity of a leaf part is the product of all quantities along the path.
A normal subquery can fetch one level of children, but it cannot naturally keep descending through unknown depth. A recursive CTE is built for trees and graphs. That is why BOMs, org charts, folder structures, and category trees almost always use it.
| Approach | Best for | Trade-off |
|---|---|---|
| Recursive CTE | Unknown depth | Clean and set-based |
| Nested subquery | One level only | Stops too early |
| Procedural loop | Legacy systems | More code, less SQL |
UNION ALL, not UNION. UNION ALL keeps every row and is faster because it skips duplicate elimination. In a BOM, duplicate paths often matter because they represent real quantity.parent_part is the usual choice. Without it, each recursion level may scan the whole BOM table again.MAXRECURSION; MySQL 8 defaults to 1000 via cte_max_recursion_depth. PostgreSQL does not use a small fixed default in the same way, but it still stops when no more rows are produced.For a valid tree, the work is roughly proportional to the number of exploded edges, so think O(nodes + edges) for the result you actually materialize. In practice, the expensive part is not the math; it is repeated joins and, if you add cycle checks with a path string, repeated membership tests. A BOM with 10,000 edges is usually fine with indexing, but a highly shared structure can create many output rows because one component may appear under several branches.
Memory line: Seed the top, walk downward, multiply on the way, sum at the end.
Imagine a manufacturing planning system for an e-bike company. A checkout service sells one bike, but the warehouse must reserve frame tubes, wheels, spokes, bolts, batteries, and packaging. The BOM query feeds the inventory reservation service so it knows the exact parts to deduct from stock.
What goes wrong if someone writes only a one-level query? The system reserves the bike shell and the wheels, but forgets the spokes, bolts, and battery cells hidden deeper in the tree. At peak traffic, orders look successful, but the line later stalls because the warehouse runs out of parts it never reserved. The symptom is subtle: customer orders are accepted, yet manufacturing exceptions appear hours later with logs like insufficient_component_stock or reservation_shortfall.
That is why BOM bugs are expensive. They do not fail loudly; they fail by undercounting. A correct recursive CTE turns a hidden dependency tree into a flat, trustworthy list the rest of the system can use.
-- Bill of Materials demo using a recursive CTE.
-- This script is standard SQL style and runs in databases that support recursive CTEs.
CREATE TABLE bom (
parent_part VARCHAR(20) NOT NULL,
component_part VARCHAR(20) NOT NULL,
qty_per INTEGER NOT NULL CHECK (qty_per > 0)
);
INSERT INTO bom (parent_part, component_part, qty_per) VALUES
('BIKE', 'FRAME', 1),
('BIKE', 'WHEEL_SET', 1),
('BIKE', 'SEAT', 1),
('WHEEL_SET', 'WHEEL', 2),
('WHEEL', 'RIM', 1),
('WHEEL', 'SPOKE', 28),
('WHEEL', 'HUB', 1),
('FRAME', 'TUBE', 3),
('FRAME', 'BOLT', 8),
('BOLT', 'METAL', 1),
('FRAME', 'PAINT', 1),
('SEAT', 'FOAM', 1),
('SEAT', 'COVER', 1),
('COVER', 'LEATHER', 1),
-- Edge case: a bad row that points to itself. The cycle guard below prevents infinite recursion.
('LOOP_PART', 'LOOP_PART', 1);
-- Explode the BIKE BOM and total the quantity of each component.
WITH RECURSIVE exploded_bom AS (
-- Anchor row: start from the finished product.
SELECT
b.parent_part,
b.component_part,
b.qty_per AS total_qty,
CAST(b.parent_part || '>' || b.component_part AS VARCHAR(500)) AS path,
1 AS level
FROM bom b
WHERE b.parent_part = 'BIKE'
UNION ALL
-- Recursive step: treat each component as a new parent and keep walking downward.
SELECT
b.parent_part,
b.component_part,
e.total_qty * b.qty_per AS total_qty,
CAST(e.path || '>' || b.component_part AS VARCHAR(500)) AS path,
e.level + 1 AS level
FROM exploded_bom e
JOIN bom b
ON b.parent_part = e.component_part
-- Prevent cycles: if the next component already exists in the path, stop.
WHERE POSITION('>' || b.component_part || '>' IN '>' || e.path || '>') = 0
)
SELECT
component_part,
SUM(total_qty) AS required_qty
FROM exploded_bom
GROUP BY component_part
ORDER BY component_part;
-- Edge case demo: starting from LOOP_PART would loop forever without the path guard.
-- Because the guard blocks the repeated self-reference, this query returns only one row.
WITH RECURSIVE loop_check AS (
SELECT
b.parent_part,
b.component_part,
b.qty_per AS total_qty,
CAST(b.parent_part || '>' || b.component_part AS VARCHAR(500)) AS path
FROM bom b
WHERE b.parent_part = 'LOOP_PART'
UNION ALL
SELECT
b.parent_part,
b.component_part,
l.total_qty * b.qty_per AS total_qty,
CAST(l.path || '>' || b.component_part AS VARCHAR(500)) AS path
FROM loop_check l
JOIN bom b
ON b.parent_part = l.component_part
WHERE POSITION('>' || b.component_part || '>' IN '>' || l.path || '>') = 0
)
SELECT
component_part,
total_qty
FROM loop_check;Follow-up & Tricky Questions:
UNION instead of UNION ALL? UNION removes duplicates and can accidentally hide valid repeated paths, plus it adds sort or hash work. In BOM queries, repeated components often represent real demand, so UNION ALL is the correct default.Common Mistakes:
UNION by habit. Correction: prefer UNION ALL unless you explicitly need duplicate removal.Memory Hook: Picture a tree of nested shipping boxes: open the top box, find the smaller boxes inside, multiply the labels as you go, then count all the items at the bottom.
Cheat Sheet:
UNION ALL.Practice Tasks:
unit_cost column and compute total material cost for the final product.