Skip to content

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_price

That 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.11
EOQ ranges 77 to 905 units across the 200 SKUs
cost 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 SKU

Per-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 once python -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:

Terminal window
python data/make_data.py

That writes three CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical because the seeds are fixed):

  • Part Askus_a.csv (200 rows: sku_id, annual_demand, ordering_cost, unit_price, holding_rate). No discounts.
  • Part Bskus_b.csv (240 rows, same columns) and discounts_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:

Terminal window
python part_a/eoq.py

Prefer 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) with H = 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/12 each month overspends the per-SKU EOQ by 11.8% overall on ordering+holding cost, and by up to +176% on the worst single SKU. Writes part_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)

FunctionWhat it does hereDocs
np.sqrtThe EOQ formulasqrt
vectorised arithmetic on a SeriesEOQ for all SKUs at onceSeries
Series.round().astype(int)Whole-unit order quantitiesround
df.iloc[0]Pull one SKU to probe its cost curveiloc

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 # TODO it 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 — columns sku_id, order_qty, total_cost (every SKU, one row, order_qty a positive integer).
  • report.json — with keys skus, achieved_metric (you can leave achieved_metric null; the grader scores you).

Then:

Terminal window
python part_b/starter.py
pytest part_b/test_solution.py -q

If pytest reports everything as skipped, it just means the output files don’t exist yet — run python part_b/starter.py first (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:

  • pytest passes — 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.py reproduces 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.

FieldValue
DatasetSynthetic distributor SKU catalogue (Part A 200 SKUs, Part B 240 SKUs) + a shared all-units discount schedule
TypeSynthetic
Generated bydata/make_data.py (this repo), seeds 20260750 (A) and 20260749 (B)
Source URLNot applicable — generated, not fetched
LicenseNot applicable — synthetic, no third-party rights
Access / generation date2026-07-18
RedistributionPermitted — synthetic; output regenerated from the seeded script, never committed
robots.txt checkedNot applicable — no scraping
ToS URLNot 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