Design inventory management across warehouses
The interviewer asks
“Design the inventory system for an e-commerce company with ~50 warehouses and many sellers. It must tell the website what can be sold and delivered to each pincode, reserve stock during checkout, and stay correct as goods are received, picked, shipped, returned and lost.”
What a strong answer looks like
A strong answer separates the physical truth (what is on the shelf in each warehouse, owned by the warehouse management system) from the sellable view (what the website can promise). Inventory is tracked per (SKU, warehouse) as quantities in states: on_hand, reserved, available = on_hand - reserved - safety buffer. Every change is an append-only movement in a ledger plus a conditional update of the balance row in the same transaction, which gives auditability and idempotency. Checkout reserves stock with a TTL using an atomic conditional decrement; order confirmation commits it; picking and shipping reduce on_hand. A sellable-availability view per SKU and pincode is derived asynchronously (via events into a cache) for browse pages, while checkout always hits the source of truth. Strong candidates handle allocation (which warehouse serves an order), hot SKUs, reconciliation with physical cycle counts, and the difference between accurate and fast numbers.
Concepts to mention
1 Requirements
Functional
- Track stock per SKU per warehouse (and per seller for marketplace stock)
- Show 'in stock' and delivery promise for a SKU and pincode on product pages
- Reserve stock at checkout, commit on payment, release on failure or timeout
- Allocate each order line to the best warehouse (nearest with stock, cost, capacity)
- Record inbound receipts, transfers between warehouses, picks, shipments, returns, damage and cycle-count adjustments
- Full audit history of every quantity change
- Low-stock alerts and replenishment signals
Non-functional
- Never oversell beyond a configured tolerance (target zero for normal SKUs)
- Reservation API p99 under 50 ms; ~5-10k reservations/s at sale peak
- Availability read for browse: ~100k reads/s, can be a few seconds stale
- Inventory ledger durable with RPO 0; balances strongly consistent per (SKU, warehouse)
- Availability 99.99% for reservations; browse reads degrade gracefully
Out of scope (say it out loud)
- Warehouse floor operations (bin locations, picker routing)
- Demand forecasting models
- Procurement and supplier purchase orders
- Last-mile delivery
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
GET/v1/availability?sku_ids=a,b,c&pincode=560001 | Browse: sellable status and earliest delivery date per SKU for a pincode (served from cache) |
POST/v1/reservations | Reserve order lines for an order_id; picks warehouses; idempotent on order_id; returns reservation IDs and expiry |
POST/v1/reservations/{order_id}/commit | Payment succeeded: convert reservation to a committed allocation |
POST/v1/reservations/{order_id}/release | Payment failed or order cancelled: return quantity to available |
POST/v1/movements | Warehouse events: receipt, pick, ship, return, damage, cycle-count adjustment; each with a unique movement_id |
GET/v1/inventory/{sku_id}/ledger?warehouse_id= | Audit trail of quantity changes |
4 Data model
stock_balancePostgres / MySQL (sharded by sku_id)sku_idwarehouse_id (composite PK)on_handreservedallocatedsafety_bufferversionupdated_atOne row per SKU per warehouse. Updated only with conditional statements (WHERE available >= qty). Sharding by sku_id keeps all warehouses of a SKU on one shard, so allocation across warehouses is single-shard.
stock_movements (ledger)Same database, append-only table (partitioned by month)movement_id (PK, idempotency)sku_idwarehouse_idtype (RECEIVE, RESERVE, RELEASE, COMMIT, PICK, SHIP, RETURN, ADJUST)qty_delta per bucketref_id (order/PO)created_atWritten in the same transaction as the balance update; the balance is a cached sum of movements. Archived to the data lake for audit.
reservationsSame databasereservation_idorder_idline_idsku_idwarehouse_idqtystatus (HELD, COMMITTED, RELEASED, EXPIRED)expires_atIndex on (status, expires_at) for the expiry sweeper; unique (order_id, line_id) for idempotency.
serviceabilityPostgres + in-memory copywarehouse_idpincodedelivery_dayscod_allowedcutoff_timeUpdated by logistics daily; loaded into memory by the availability service.
availability_viewRedisavail:{sku_id} -> {warehouse_id: available_qty_bucket}updated_atDerived from balance change events; browse computes pincode availability by combining with serviceability. Stores coarse buckets (0, 1-5, 5+) for most uses.
outboxSame databaseevent_idtype (StockChanged, LowStock)payloadFeeds Kafka for the availability view, search index (in-stock flag) and replenishment.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Reserve stock at checkout
- 1Order service calls POST /v1/reservations with order_id, lines and the delivery pincode.
- 2Inventory service checks for existing reservations with this order_id; if found, it returns them (idempotent retry).
- 3Allocation engine ranks warehouses for each line: serviceable to the pincode, fastest delivery, lowest shipping cost, prefer shipping all lines from one warehouse to avoid split shipments.
- 4For the first choice, in one transaction: UPDATE stock_balance SET reserved = reserved + q, version = version + 1 WHERE sku_id = ? AND warehouse_id = ? AND on_hand - reserved - allocated - safety_buffer >= q; insert the reservation (HELD, expires in 15 min); insert a RESERVE movement; insert an outbox event.
- 5If the update affects 0 rows, try the next warehouse; if none works, return OUT_OF_STOCK for that line.
- 6Outbox relay publishes StockChanged; the projector updates the Redis availability view within ~1-2 s, so browse pages stop promising the last unit.
- 7On payment success, commit moves qty from reserved to allocated; when the warehouse picks and ships, on_hand and allocated decrease together.
Goods received at a warehouse
- 1Warehouse staff scan a pallet against a purchase order; the WMS sends POST /v1/movements with a unique movement_id.
- 2Inventory inserts the RECEIVE movement (unique movement_id rejects scanner retries) and increments on_hand in the same transaction.
- 3A StockChanged event updates availability and the search in-stock flag, and clears any 'notify me' waitlist for the SKU.
7 Deep dives
Quantities in states, and ledger plus balance
A single 'quantity' number is not enough. Track buckets per (SKU, warehouse): on_hand (physically there), reserved (held for unpaid checkouts), allocated (paid orders waiting to be picked), and a safety_buffer. Sellable available = on_hand - reserved - allocated - safety_buffer. Each business event moves quantity between buckets: reserve (available -> reserved), commit (reserved -> allocated), ship (allocated and on_hand both down), return-to-stock (on_hand up after QC), damage (on_hand down). Store every change as an append-only movement row with a unique movement_id, and update the balance row in the same database transaction. This is a ledger pattern like double-entry accounting: the balance is a fast cached sum, the movements are the truth, and you can rebuild or audit any balance at any point in time. The unique movement_id makes every warehouse scan and every API retry idempotent: a duplicate insert fails, and the balance is not changed twice. Interviewers like this because it answers 'how do you debug why stock is wrong?'
Concurrency: preventing oversell
Two checkouts for the last unit arrive at the same time. A read-then-write ('read available = 1, then write 0') races and both succeed. Fixes in order of preference: a single conditional UPDATE that checks and decrements atomically (the database row lock is held only for microseconds); optimistic concurrency with a version column and retry; or SELECT ... FOR UPDATE inside a short transaction. The conditional update is simplest and scales to a few thousand updates/s per row. For multi-line orders, lock rows in a consistent order (sorted by sku_id, warehouse_id) to avoid deadlocks, or reserve lines independently and release the successful ones if any line fails, which is a mini-saga. Sharding by sku_id keeps a SKU's warehouses together. For extremely hot SKUs during sales (a single row at 50k attempts/s), move the SKU's sellable count to Redis with a Lua check-and-decrement fronting the DB, or split the stock into sub-rows (stripes) that are tried in turn, as in the flash-sale design.
Fast availability for browse vs accurate availability for checkout
Browse traffic (~100k reads/s) is 10-100x checkout traffic and does not need perfect accuracy; checkout does. So run two paths. The availability view in Redis is a projection updated from StockChanged events, usually within a second or two. The availability service combines it with serviceability (which warehouses deliver to this pincode and in how many days, considering today's cut-off time) to answer 'in stock, delivery by Thursday'. Store coarse buckets rather than exact counts where possible; 'only 3 left' needs a number, but most cards only need yes/no, and buckets mean fewer cache writes. Checkout never trusts the view; it reserves against the database. The small window where the view says yes but the reservation fails is handled with a clear 'just sold out' message and alternative sellers. The safety buffer per SKU-warehouse covers the opposite error, when physical stock is lower than the system thinks because of damage or mis-scans.
Reconciliation with the physical world
Software counts drift from reality: items are damaged, misplaced, stolen, or scanned twice. Warehouses run cycle counts (counting a subset of bins every day) and full counts periodically. A count produces an ADJUST movement with a reason, never a direct overwrite of the balance, so the history explains the change. Large or frequent adjustments for a SKU are flagged for investigation. Separately, a reconciliation job checks internal consistency: every balance must equal the sum of its movements; every HELD reservation past its expiry must be released; every allocated quantity must map to a live order. Expired reservations are released by a sweeper using the (status, expires_at) index, in small batches with a conditional status update so a commit racing with expiry has exactly one winner. Mention 'phantom inventory' (system says 1, shelf is empty) as the main cause of cancellations after order, and safety buffers per SKU velocity as the fix.
8 Trade-offs
Ledger + balance vs balance only
Balance-only is simpler and faster to write but cannot explain discrepancies or support idempotent replays. The ledger costs extra writes and storage (~10 GB/day) but gives audit, debugging and rebuilds. For inventory, use both.
Central inventory DB vs per-warehouse databases
Per-warehouse databases keep warehouses working during network issues but make cross-warehouse allocation a distributed problem. A central service sharded by sku_id, with the WMS buffering scans locally during outages, is simpler for allocation.
Reservation TTL length
Short TTLs (5 min) free stock quickly but may expire during slow UPI payments; long TTLs (30 min) lock stock that never sells. ~15 minutes with an extension when payment is in progress is a common middle.
Exact counts vs buckets in the browse view
Exact counts cause a cache write for every unit sold; buckets (0, 1-5, 5+) cut writes drastically. Use exact only where the UI shows 'only N left'.
9 Bottlenecks
Hot SKU rows during sales
Row lock contention at thousands of updates/s on one (SKU, warehouse). Use Redis-fronted counters or stock striping, and pre-assign sale stock to a separate pool.
Ledger table growth
~30M rows/day. Partition by month, keep 90 days hot, archive older partitions to the data lake, and periodically snapshot balances so rebuilds do not replay years of history.
Availability fan-out on listing pages
A page with 40 products needs 40 lookups. Batch with MGET and keep in-stock flags in the search index so listings rarely call availability at all.
10 Failure handling
Inventory DB shard down
Reservations for SKUs on that shard fail fast; the order service shows 'try again shortly' instead of overselling. Browse still reads the Redis view. Failover to the synchronous replica takes ~30 s.
Event pipeline lag
The Redis view becomes stale, so the site may show stock that is gone. Checkout still checks the DB, so no oversell; alert on projector lag and widen safety buffers if lag persists.
Warehouse loses network
The WMS keeps scanning offline and queues movements with their movement_ids; on reconnect it replays them and idempotency prevents double counting. Meanwhile, allocation stops routing new orders to that warehouse.
11 What impresses
- Modelling stock as states with explicit transitions
- Ledger plus balance with idempotent movement IDs
- Separate fast browse path and accurate checkout path
- Lock ordering or a mini-saga for multi-line orders
- Adjustments as movements, never overwrites
- Safety buffers tuned by SKU velocity
12 Mistakes
- Read-then-write updates that race and oversell
- One global quantity number with no reserved state
- Overwriting stock counts from warehouse counts with no audit trail
- Calling the inventory DB for every product card on browse pages
- Never expiring reservations
- Using distributed 2PC across warehouses for each order
13 Follow-ups
Lessons this design uses
Practice questions
- Two catalogue managers open the same product page. One changes the price, the other changes the description, and both save. The price change disappears. Why, and how do you fix it?
- Two customers buy the last unit of a product at the same moment. How does your schema and query make sure you do not oversell?
- For an e-commerce site, the product manager asks: 'Should the cart and the inventory count be strongly consistent or eventually consistent?' How do you answer?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.