Economic Order Quantity: how much to order, and when a volume discount is worth the extra stock
The data in this project is synthetic — a distributor’s SKU catalogue (demand, ordering cost, unit price, holding rate) generated locally by
data/make_data.py. That is deliberate: grading needs each SKU’s known cost-optimal order quantity, which real purchasing data doesn’t come labelled with — and it keeps the project download-free. No signup, no GPU.
Before you start
New to inventory ordering? Start here.
Every business that holds stock faces the same recurring question for each item: how much to order at a time? Two costs pull in opposite directions.
- Ordering cost (
S) — a fixed cost every time you place an order: paperwork, receiving, inspection. Order in big batches and you pay this rarely. - Holding cost (
H) — the cost of keeping a unit in stock for a year: warehouse space, tied-up capital, spoilage. Usually a fraction of the unit price. Order in big batches and you pay a lot of this, because the stock sits around.
Order too big and holding cost dominates; too small and ordering cost dominates. The quantity that minimises their sum is the Economic Order Quantity:
EOQ = sqrt( 2 * D * S / H ) D = annual demand, H = holding_rate * unit_priceThat square root is the whole idea — the best order size grows with the square root of demand, not in proportion to it. Part A builds EOQ and shows the cost curve around it is forgiving. Part B adds the twist every buyer knows: suppliers offer quantity discounts — a lower unit price if you order enough. Now ordering more than the EOQ can pay off, because the price saving can beat the extra holding cost. Sometimes. What you’ll learn is in Section 3.
New to Python too? See Start Here — it starts from zero, and shows how to run this in your browser with nothing to install.
1. The Brief
You run replenishment for a distributor with a few hundred SKUs. Part A: set each SKU’s order quantity with the EOQ instead of a “just order a month’s worth” calendar rule, and see how much the rule of thumb overspends. Part B: your suppliers introduce all-units quantity discounts — order past a break quantity and the whole order ships at a lower unit price. For each SKU you now have to decide whether to stay at the EOQ or bump the order up to a break to grab the discount. It’s a real per-SKU trade-off: the discount saves money on every unit, but a bigger order means more cash sitting in the warehouse.
EOQ and its discount extension are the textbook core of inventory replenishment — the “how much to order” decision that sits alongside “when to reorder” (safety stock) and “how much for one selling season” (the newsvendor problem). Getting the discount logic right is worth real money and is exactly where the naive answer quietly loses it.
2. Difficulty & time
Difficulty 4 / 10. The EOQ formula itself is a one-liner, but Part B is a genuine per-SKU
decision with a classic trap (evaluate every price break, not just the base price, and bump up only
when it pays). Calibrated against the ledger (G10): a peer of newsvendor-stocking (4),
maverick-spend (4), and nearest-facility (4) — a clear method plus one real judgment. Above
abc-xyz-inventory (3), which is a single classification pass; below reorder-point-trap (5), which
layers demand and lead-time uncertainty on top of the sizing decision.
Time is separate from difficulty: Part A is about an hour; Part B is 2–3 hours. The scripts run in under a second.
3. What you’ll be able to do after
- Compute the EOQ for each SKU and explain why the best order size grows with the square root of demand, not linearly.
- Show the cost curve is flat-bottomed — quantify how little it costs to order a bit off the EOQ, which is why chasing a discount can be worth it.
- Evaluate an all-units quantity discount correctly — for each SKU, compare the plain EOQ against bumping up to each price break, and pick the true minimum-cost quantity.
- Beat a rule-of-thumb policy — measure how much “order monthly” overspends versus per-SKU EOQ.
The finished result
By the end of Part A you’ll have sized all 200 SKUs with the EOQ and measured what a monthly rule of thumb wastes:
SKU0000: annual_demand 4,397 -> EOQ 542 units (~8 orders/year), cost at EOQ 2,452.11EOQ ranges 77 to 905 units across the 200 SKUscost curve is flat-bottomed: ordering 0.7x the EOQ costs +6.4%, 1.5x costs +8.4% over the minimum"order every month" (D/12) overspends the per-SKU EOQ by 11.8% overall, and +176% on the worst SKUPer-SKU EOQ sizing beats the monthly rule of thumb by ~12% overall — and because the cost curve is flat near the EOQ, the price breaks in Part B are often worth chasing.
4. Prereqs & time box
You can write a function, use numpy arrays and a pandas DataFrame, and read a CSV. No inventory background needed — the orientation above gives you the whole cost model, and Part A builds the formula step by step. A square root is the only math.
Where to write and run code
Two ways to run this project — pick either:
- In your browser (no install). If the project page shows a ▶ Run button, click it to run Part A right here — nothing to set up. Part A uses only pandas and numpy, which run in the browser.
- On your computer. The one-time setup — installing Python, downloading the code from the
course site (no GitHub, no
git), opening a terminal, creating a virtual environment, installing the requirements, and running Part A step by step — is covered once in Start Here. Come back here oncepython -c "import pandas"runs without error. Part B (writing your own solution) needs this local setup.
Time box
- Part A — Guided Build: 60–90 minutes. Hard cap.
- Part B — Your Turn: 2–4 hours. Hard cap.
5. Setup & data
The data is synthetic and generated locally — see the banner above and SOURCES.md for full
provenance. One command creates both parts’ data:
python data/make_data.pyThat writes three CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical because
the seeds are fixed):
- Part A —
skus_a.csv(200 rows:sku_id, annual_demand, ordering_cost, unit_price, holding_rate). No discounts. - Part B —
skus_b.csv(240 rows, same columns) anddiscounts_b.csv— the all-units discount schedule shared by every SKU: full price below 500 units, 3% off at 500, 6% off at 2,000 (min_qty, price_multiplier).
The values describe no real company — the cost trade-off is the point, not a claim about any firm.
6. Part A — Guided Build
You’ll compute the EOQ for 200 SKUs, probe the cost curve around it, and compare it to a monthly
rule of thumb. Run the whole thing, or (better) one # %% cell at a time — see
Start Here:
python part_a/eoq.pyPrefer the browser? If this page has a ▶ Run button, click it instead of the command above — Part A runs in your browser with nothing to install. The STEP output and every checkpoint below are identical either way.
The full, commented script is in part_a/eoq.py. The spine:
Step 1 — EOQ per SKU.
Checkpoint:
EOQ = sqrt(2·D·S/H)withH = holding_rate × unit_price. Here the EOQs range 77 to 905 units across the 200 SKUs; the first SKU (demand 4,397, S≈151, price≈18.3, rate 0.247) has EOQ 542, about 8 orders a year.
Step 2 — The cost curve is flat-bottomed.
Checkpoint: for that SKU, ordering 0.7× the EOQ costs only +6.4% and 1.5× costs +8.4% over the EOQ minimum (0.5× or 2.0× jump to ~+25%). Getting the quantity roughly right is fine — which is exactly why a discount can be worth chasing.
Step 3 — EOQ vs “order every month”.
Checkpoint: ordering
D/12each month overspends the per-SKU EOQ by 11.8% overall on ordering+holding cost, and by up to +176% on the worst single SKU. Writespart_a/eoq_a.csv.
Step 4 — EOQ scales with √demand.
Checkpoint: doubling demand multiplies the EOQ by ~1.41 (√2), not 2 — so a SKU with 4× the demand orders only 2× the quantity, and its inventory cost per unit sold falls.
Why this and not that
- Why EOQ, not “a month’s worth”. A calendar rule scales the order linearly with demand and ignores the ordering/holding balance, so it drifts further from optimal as demand grows — the measured 11.8% overspend is free money left on the table.
- Why the flat bottom matters. Because cost barely moves for quantities near the EOQ, you can round to a convenient lot — and, in Part B, a modest bump toward a price break costs little in holding while saving on every unit.
- Why the square root. EOQ trades a term that falls with Q (ordering) against one that rises with Q (holding); their sum is minimised where they’re equal, and solving that gives the √. It’s a balance point, not a linear rule — the single most important intuition here.
Functions you’ll meet (and where to read more)
| Function | What it does here | Docs |
|---|---|---|
np.sqrt | The EOQ formula | sqrt |
vectorised arithmetic on a Series | EOQ for all SKUs at once | Series |
Series.round().astype(int) | Whole-unit order quantities | round |
df.iloc[0] | Pull one SKU to probe its cost curve | iloc |
If something looks off
- The “cost at EOQ” looks too low — where’s the price of the goods? Left out on purpose. Part A’s cost is ordering + holding only; the purchase cost (annual demand × unit price) is the same no matter what quantity you order, so it cancels out of the comparison and is dropped. It comes back in Part B, where a volume discount makes the unit price depend on the order quantity.
- A checkpoint number doesn’t match. The data is seeded, so the numbers are exact. Re-run
python data/make_data.py(you may have skipped or edited it), then re-run Part A. - Setup problems (Python not found,
pip,FileNotFoundError, re-activating the venv): see the troubleshooting list in Start Here.
7. Part B — Your Turn
Part B is the optional “your turn” half — a guided fill-in-the-blank, not a blank page. You get a working starter (
part_b/starter.py) that already handles the easy version; your job is to finish the# TODOit marks out. More open than Part A’s step-by-step, but you are never staring at an empty file. If you finished Part A and want to prove the skill, this is where it happens. New to Python? It’s completely fine to stop after Part A and come back later.
Same core skill — lot sizing — but now with all-units quantity discounts (discounts_b.csv). For
every SKU in skus_b.csv, choose the order quantity that minimises annual total cost = purchase +
ordering + holding, where a bigger order can drop the unit price (and holding cost is charged on the
discounted price). The starter computes the plain EOQ at each SKU’s base price and stops — it never
considers bumping up to a break, so it scores 0.17 and fails.
The transfer: without discounts (Part A) the purchase price is constant and drops out, so you just balance ordering vs holding. With all-units discounts the purchase term depends on the quantity, so the smooth EOQ is no longer automatically best — for many SKUs, ordering up to a break to capture the lower price wins, but for others the extra holding outweighs it. You have to check.
Reference rule (reproduce it to match the yardstick)
For each SKU, build a small set of candidate quantities and take the cheapest:
- For each price tier, the EOQ computed at that tier’s discounted price — but only if that quantity actually falls within the tier’s quantity range.
- Plus each break quantity itself (500 and 2,000) — the “bump up to unlock the discount” move.
Score every candidate with total_cost = D·price(q) + (D/q)·S + (q/2)·holding_rate·price(q), where
price(q) is the base price times the multiplier for the tier q lands in, and keep the minimum.
Acceptance criteria (checkable)
Your solution writes into part_b/out/:
orders.csv— columnssku_id, order_qty, total_cost(every SKU, one row,order_qtya positive integer).report.json— with keysskus,achieved_metric(you can leaveachieved_metricnull; the grader scores you).
Then:
python part_b/starter.pypytest part_b/test_solution.py -qIf
pytestreports everything asskipped, it just means the output files don’t exist yet — runpython part_b/starter.pyfirst (once your solution fills in the TODOs), then re-run.
You’re done when the tests pass. The grader recomputes your annual cost from each order_qty and
scores the fraction of SKUs within 0.5% of that SKU’s optimal cost; you need ≥ 0.90 (the
reference reaches 1.00). The naive “EOQ at base price” baseline scores ~0.17.
Constraints — what must be true of your solution, not how to get there
- Every SKU gets a positive-integer
order_qty. - Holding cost is charged on the discounted unit price, not the base price.
- Consider every price break, not just the first — a SKU can be cheapest at the plain EOQ, at 500, or at 2,000, and which one differs by SKU.
- Don’t just bump every SKU to the biggest break — for low-demand SKUs the extra holding cost beats the saving. (Bumping everyone to 2,000 scores ~0.29.)
Hints
Hint 1 — why the plain EOQ isn't enough
The EOQ minimises ordering + holding, but the discount cuts the purchase cost too — a term the plain EOQ ignores because without discounts it’s constant. Ordering a bit more than the EOQ costs little extra in holding (Part A’s flat bottom) but can shave a few percent off every unit’s price.
Hint 2 — the candidate set
You don’t need calculus or a solver. For each SKU the optimum is one of a handful of quantities: the EOQ evaluated at each tier’s price (if it’s in range), or a break quantity itself. Compute the total cost of each candidate and take the min. That’s the whole all-units-discount procedure.
Hint 3 — the holding-cost gotcha
When you bump to a break, recompute holding on the discounted price (holding_rate × discounted price), not the base price. Using the base price for holding will make bumping look worse than it is
and you’ll leave savings behind.
8. Self-check
You don’t need the answer key. You’re done when all of these hold:
pytestpasses — within 0.5% of optimal on at least 90% of SKUs.- Some SKUs stay at the plain EOQ, some sit at 500, some at 2,000. If all your quantities are break values, you’re over-bumping; if none are, you’re ignoring the discounts.
- Re-running from
python data/make_data.pyreproduces the same numbers — the data is seeded.
And the tell-tale: a SKU with low demand and a high holding rate should usually stay near its EOQ (the discount doesn’t pay); a high-demand SKU should usually bump to a break. If your answer doesn’t show that pattern, re-check the holding-on-discounted-price step.
9. Stretch
- Where’s the indifference point? For one SKU, plot total cost against order quantity across the breaks and mark the EOQ, the break quantities, and the true optimum. Find the demand level at which bumping to 500 flips from not-worth-it to worth-it.
- Incremental discounts. Real suppliers sometimes discount only the units above a break (incremental), not the whole order (all-units). Rework the cost function for incremental discounts and see how the optimal quantities change.
- (Genuinely hard) Joint replenishment. If several SKUs ship from one supplier and share the ordering cost when ordered together, the per-SKU EOQ is no longer optimal. Model a shared ordering cost and find a common order cycle — the start of joint-replenishment / can-order policies.
10. Ship it
Put this in a portfolio repo and it counts. In that repo’s README:
- State the finding in one sentence a buyer gets: “per-SKU EOQ beat a monthly-ordering rule by ~12%, and evaluating all-units discounts correctly cut cost further on the SKUs where a price break outweighed the extra holding.”
- Show the flat-bottomed cost curve and the discount decision for a few SKUs (EOQ vs bump-to-break).
- Include the EOQ derivation in one paragraph and the all-units candidate-set logic. That paragraph is what signals you understand the trade-off, not just the formula.
Keep make_data.py and the tests in the repo so anyone can reproduce your numbers from zero.
11. Sources
Rendered from SOURCES.md.
| Field | Value |
|---|---|
| Dataset | Synthetic distributor SKU catalogue (Part A 200 SKUs, Part B 240 SKUs) + a shared all-units discount schedule |
| Type | Synthetic |
| Generated by | data/make_data.py (this repo), seeds 20260750 (A) and 20260749 (B) |
| Source URL | Not applicable — generated, not fetched |
| License | Not applicable — synthetic, no third-party rights |
| Access / generation date | 2026-07-18 |
| Redistribution | Permitted — synthetic; output regenerated from the seeded script, never committed |
| robots.txt checked | Not applicable — no scraping |
| ToS URL | Not applicable — no external source |
Synthetic data teaches the mechanic; it does not prove a finding about the real world. The SKU parameters were generated on purpose — the EOQ and quantity-discount techniques are the point, not a claim about any real catalogue.
Next up
Finished this one? Continue the Supply Chain track:
The newsvendor problem: how much to stock when running out and over-ordering cost different amounts → · Browse all courses