Hook: Inventory analysis is the warehouse version of checking your bank balance before you spend it — interviewers love it because one small SQL mistake can turn into lost sales, angry customers, or stale cash.
Question: How do you analyze inventory in SQL to find low stock, overstock, stale items, and bad data?
Answer: Start from the inventory movement table, not just the current snapshot. Sum receipts, sales, and adjustments to get on-hand quantity, then compare that number with reorder point, safety stock, and recent demand to spot items that need attention. Add date logic for last movement and days of supply so you can separate fast movers from dead stock.
Interview-Ready Answer: I’d treat inventory analysis as a reconciliation problem first. I start from the transaction ledger, aggregate each product to get current on-hand, then calculate metrics like last movement date, 30-day sales, and days of supply. After that I classify items into healthy, low stock, dead stock, or data issues like negative inventory, because that catches both business risk and source-data problems.
Detailed Explanation: In SQL, inventory analysis usually means answering business questions from two kinds of data: the ledger (a transaction log of every stock change) and the snapshot (the current quantity on hand). The safest mental model is: rebuild reality from movements, then judge health from the result. That way you catch missing receipts, duplicate shipments, returns, adjustments, and negative stock before the dashboard lies to you.
GROUP BY over the movement table gives current on-hand quantity. If you have multiple warehouses, include warehouse_id in the grouping key or you will accidentally mix locations together.days of supply means how many days your current stock should last at the recent sales rate.Safety stock is the buffer you do not want to cross; reorder point is the level that says, “place an order now.” Those are business rules, not database rules, so they belong in the data model and in the query.HEALTHY, LOW STOCK, DEAD STOCK, OUT OF STOCK, or DATA ISSUE. That lets a planner scan one result set instead of reading raw numbers.COALESCE for missing rows, NULLIF to avoid divide-by-zero when sales are zero, and explicit checks for negative inventory. Negative stock usually means a timing issue, bad integration, or a missing movement row.| Approach | Best for | Trade-off |
|---|---|---|
| Movement ledger | Auditability | More rows |
| Daily snapshot | Fast reports | Less detail |
In practice, the ledger is the source of truth, and a snapshot is a performance layer built from it. If you only store snapshots, you lose the why behind the number; if you only store movements, large reports can become slow.
For a straight aggregate, the work is roughly O(n) over the number of movement rows the database must read, but real execution can look like O(n log n) if the engine has to sort, spill to disk, or compute window functions. On a small catalog of 50,000 SKUs, this is easy. On 100 million movement rows across many warehouses, you usually want an index on (product_id, txn_date) or (warehouse_id, product_id, txn_date), and sometimes partitioning by month so the engine can prune old data. A nightly snapshot table can also reduce report cost from scanning millions of transactions to scanning tens of thousands of product rows.
NULL or “infinite,” not a divide-by-zero error.Memory in one line: Inventory analysis is a scale in one hand and a calendar in the other. The scale tells you how much you have; the calendar tells you whether it is moving.
Real-World Example: Imagine an ecommerce checkout service during a holiday flash sale. The warehouse dashboard says there are 400 keyboards left, but orders start failing because the shelf is actually empty. The bug was a bad SQL query that summed every movement as a positive number, so sales never subtracted from stock. Customers saw “out of stock after payment,” support got flooded with cancellations, and the logs showed mismatched inventory totals after the nightly sync. The fix was to rebuild inventory from signed movements, add a negative-stock alert, and separate returns from receipts so the dashboard could not lie again.
-- Inventory analysis demo: build a tiny ledger, then classify each item safely.
-- The key idea is to reconstruct on-hand stock from movements instead of trusting a stale snapshot.
WITH
products(product_id, product_name, reorder_point, safety_stock, lead_time_days) AS (
VALUES
(1, 'Keyboard', 20, 10, 14),
(2, 'Mouse', 30, 15, 7),
(3, 'Monitor', 10, 5, 21),
(4, 'Dock', 5, 2, 30),
(5, 'Cable', 50, 20, 10)
),
inventory_movements(product_id, txn_date, qty_signed) AS (
VALUES
-- Receipts are positive, shipments are negative.
(1, DATE '2026-05-01', 100),
(1, DATE '2026-06-20', -85),
(1, DATE '2026-06-25', 2), -- customer return: stock comes back in
(2, DATE '2026-04-15', 50),
(2, DATE '2026-05-12', -20),
(3, DATE '2026-06-01', 20),
(3, DATE '2026-06-15', -18),
(3, DATE '2026-06-30', -2),
-- Product 4 intentionally has no movements to show the "no history" edge case.
(5, DATE '2026-06-01', 10),
(5, DATE '2026-06-05', -12) -- negative inventory: usually a data-quality problem
),
movement_rollup AS (
SELECT
product_id,
SUM(qty_signed) AS qty_on_hand,
MAX(txn_date) AS last_movement_date,
SUM(CASE
WHEN qty_signed < 0
AND txn_date >= DATE '2026-06-11' -- last 30 days from the analysis date
THEN -qty_signed
ELSE 0
END) AS sold_30d
FROM inventory_movements
GROUP BY product_id
)
SELECT
p.product_id,
p.product_name,
COALESCE(m.qty_on_hand, 0) AS qty_on_hand,
m.last_movement_date,
COALESCE(m.sold_30d, 0) AS sold_30d,
CASE
WHEN COALESCE(m.qty_on_hand, 0) < 0 THEN 'DATA ISSUE'
WHEN COALESCE(m.qty_on_hand, 0) = 0 THEN 'OUT OF STOCK'
WHEN m.last_movement_date IS NOT NULL
AND DATE '2026-07-11' - m.last_movement_date >= 60
AND COALESCE(m.sold_30d, 0) = 0 THEN 'DEAD STOCK'
WHEN COALESCE(m.qty_on_hand, 0) <= p.safety_stock THEN 'LOW STOCK'
WHEN COALESCE(m.qty_on_hand, 0) <= p.reorder_point THEN 'REORDER SOON'
ELSE 'HEALTHY'
END AS status,
CASE
-- NULLIF prevents divide-by-zero when there were no recent sales.
WHEN COALESCE(m.sold_30d, 0) = 0 THEN NULL
ELSE ROUND(COALESCE(m.qty_on_hand, 0) * 30.0 / NULLIF(m.sold_30d, 0), 1)
END AS days_of_supply
FROM products p
LEFT JOIN movement_rollup m
ON m.product_id = p.product_id
ORDER BY p.product_id;Follow-up & Tricky Questions:
warehouse_id to the grouping key and to every filter. Otherwise one full warehouse can hide an empty one.NULLIF so products with zero sales do not crash the query.SUM(quantity) sometimes wrong? Because if receipts and sales are both stored as positive numbers, the sum does not represent stock. You need a signed convention or separate in/out columns before aggregation makes sense.Common Mistakes:
NULLIF and return NULL or a clear label when there are no sales.product_id.Memory Hook: Scale + Calendar. The scale tells you how much stock exists; the calendar tells you whether it is moving. If one is missing, the answer is incomplete.
Cheat Sheet:
SUM(qty_signed) gives on-hand stock when the sign convention is correct.MAX(txn_date) gives last movement date for aging checks.NULLIF protects days-of-supply math from zero sales.(product_id, txn_date) or partition by date.Practice Tasks:
product_id and warehouse_id together.