TL;DR & Quick Summary
Safety stock is extra inventory held to cover variation in demand and supplier lead time. The reorder point is the stock level at which you place a new order. Both take a few lines of Python. The common mistakes are ignoring lead-time variability and using the normal formula for slow movers at high service targets.
Combined formula: SS = z × √(LT × σd² + d² × σLT²)
Reorder point: ROP = d × LT + SS
Example: 120 units a week, 2-week lead time → safety stock 121, reorder point 361
Ignoring lead-time variability cut safety stock to 70, and a 200,000-run simulation showed real service falling from 95% to 83%
Slow movers: use Poisson. At a 99% target the normal formula under-stocked in our tests.
Key Takeaway: Don't trust a safety stock formula you haven't checked. A short simulation shows whether the buffer delivers the service level you think it does.
Get Started: For the theory and formula variations, read safety stock calculation formulas. To have your reorder points checked against real data, schedule a strategy call with Cogniq AI.
The Formulas
Safety stock, accounting for both demand and lead-time variability:
SS = z × √(LT × σd² + d² × σLT²)
- d: average demand per period
- σd: standard deviation of demand per period
- LT: average lead time, in the same periods
- σLT: standard deviation of lead time
- z: the number of standard deviations for your target cycle service level, meaning the probability of not running out during a replenishment cycle
Reorder point: ROP = d × LT + SS
The first term covers expected demand during the lead time. Safety stock covers the variation around it.
In Python
import numpy as np
from scipy.stats import norm
def safety_stock(d, sd, lt, sd_lt, service=0.95):
z = norm.ppf(service)
return z * np.sqrt(lt * sd**2 + d**2 * sd_lt**2)
def reorder_point(d, lt, ss):
return d * lt + ss
ss = safety_stock(d=120, sd=30, lt=2, sd_lt=0.5) # 120.9
rop = reorder_point(120, 2, ss) # 360.9
With weekly demand averaging 120 units (standard deviation 30), a lead time of 2 weeks (standard deviation half a week) and a 95% target, z is 1.645, safety stock is about 121 units, and the reorder point is about 361.
Note the units: demand and lead time must use the same period. If demand is weekly, lead time must be in weeks.
What Lead-Time Variability Is Worth
A common shortcut drops the lead-time term:
ss_short = norm.ppf(0.95) * np.sqrt(2) * 30 # 69.8
That gives about 70 units instead of 121. The difference looks like a modelling detail. The simulation below shows what it costs.
Checking the Buffer With a Simulation
Formulas rest on assumptions. A simulation tests the result directly: generate many replenishment cycles with random lead times and random demand, and count how often demand during the lead time stays at or below the reorder point.
rng = np.random.default_rng(1)
n = 200_000
lt = np.clip(rng.normal(2, 0.5, n), 0.1, None) # random lead times
demand = rng.normal(120 * lt, 30 * np.sqrt(lt)) # demand during each lead time
full = np.mean(demand <= 240 + 120.9) # ≈ 0.946
short = np.mean(demand <= 240 + 69.8) # ≈ 0.830
| Safety stock method | Safety stock | Simulated cycle service level |
|---|---|---|
| Combined formula (demand + lead-time variability) | 121 | 94.6% |
| Demand variability only | 70 | 83.0% |
The combined formula delivered close to its 95% target. The shortcut, aiming for 95%, actually achieved 83%. That means stockouts in roughly one cycle in six instead of one in twenty. When supplier lead times vary, the lead-time term is often the larger part of the buffer.
Swap in your own distributions, for example lead times sampled from your actual purchase order history, and the same code checks whether your current reorder points deliver what you expect.
Slow Movers: Use Poisson
The normal formula assumes demand during the lead time is smooth and symmetric. For slow-moving items, which sell a few units at a time with many zero periods, demand is lumpy and skewed. A Poisson distribution is usually a better model when demand comes in single units or small quantities.
from scipy.stats import poisson
def rop_poisson(mean_lt_demand, service=0.95):
return int(poisson.ppf(service, mean_lt_demand))
def rop_normal(mean_lt_demand, service=0.95):
return round(mean_lt_demand + norm.ppf(service) * np.sqrt(mean_lt_demand))
We compared the two at different average lead-time demands (λ) and targets:
| Average lead-time demand | Target | Normal approx. ROP (coverage) | Poisson ROP (coverage) |
|---|---|---|---|
| 0.5 | 95% | 2 (98.6%) | 2 (98.6%) |
| 0.5 | 99% | 2 (98.6%) ✗ | 3 (99.8%) |
| 1.2 | 95% | 3 (96.6%) | 3 (96.6%) |
| 1.2 | 99% | 4 (99.2%) | 4 (99.2%) |
| 3.0 | 95% | 6 (96.6%) | 6 (96.6%) |
| 3.0 | 99% | 7 (98.8%) ✗ | 8 (99.6%) |
Coverage is the Poisson probability that lead-time demand doesn't exceed the reorder point.
At 95% the two methods agreed in every case we tested. At 99%, the normal approximation missed the target twice. For critical spares, where high targets are common, that's exactly where it matters. Poisson is just as easy to compute, so use it for slow movers.
For items with very irregular sizes, Poisson can understate the spread. A negative binomial distribution, or bootstrapping from actual lead-time demand history, handles that. Forecasting such items is covered in Croston, SBA and TSB in Python.
Where the Inputs Come From
- d and σd: from your forecast and its error, not raw sales history. If you have a forecast, use the standard deviation of forecast errors, because that's the uncertainty the buffer has to cover. See forecast accuracy metrics in Python.
- LT and σLT: from purchase order history, the time from placing an order to stock being available, per supplier and ideally per item
- Service level: by segment, set from the cost of running out versus the cost of holding stock
Clean the history first. Periods where you were out of stock understate demand. Expedited orders understate normal lead time. Both make safety stock too low.
Cycle Service Level vs Fill Rate
The formulas above target a cycle service level: the probability of not running out at all during a replenishment cycle. Many businesses actually care about fill rate: the share of demand served straight from stock.
They aren't the same. With a 95% cycle service level, you run out in about one cycle in twenty. But when you do run out, it's usually only for part of the demand, so the fill rate is typically higher than 95%. Setting a 95% cycle service level when you meant 95% fill rate means holding more stock than you need.
The simulation makes this easy to check. Count the units short in each simulated cycle instead of just whether a stockout happened:
short = np.clip(demand - (240 + 120.9), 0, None)
fill_rate = 1 - short.sum() / demand.sum()
In our simulation, the reorder point that delivered a 94.6% cycle service level delivered a 99.2% fill rate. Stockouts happened in about one cycle in eighteen, but they were small, so less than 1% of demand went unserved.
That gap is why the choice of measure matters so much. A business whose customers judge it on fill rate, which is common in B2B supply where orders can be partly shipped, could reach a 95% fill rate with noticeably less safety stock than a 95% cycle service level requires. A business that loses the whole order when anything is missing, such as a technician who can't finish a job without one part, should think in cycle service level or even job-level availability.
Decide which measure your customers and contracts care about, then set the target in those terms. Then use the simulation to find the smallest buffer that meets it.
Setting Service Targets by Segment
Each step towards 100% costs disproportionately more stock, because the z value grows quickly near the top. Set targets by segment rather than one figure for everything:
| Segment | Typical approach |
|---|---|
| Critical items: stopping a line, contract penalties, safety | High targets (98–99%+), Poisson or empirical for slow ones |
| High-margin, fast-moving items | High targets; lost sales are expensive |
| Low-value, easily substituted items | Lower targets; a stockout costs little |
| Slow, expensive items | Moderate targets, or stock centrally rather than at every site |
Revisit the targets with sales and finance once a year, using actual stockout costs where you have them.
Common Mistakes
- Mixing periods: daily demand with lead time in weeks gives a meaningless result
- Using sales instead of forecast error when you have a forecast: the buffer should cover forecast uncertainty
- One service level for everything: each step towards 100% needs disproportionately more stock, so reserve high targets for items that justify them
- Ignoring lead-time variability: as the simulation showed, this can cost over ten points of service level
- Using the normal formula for slow movers at high targets: use Poisson or an empirical distribution
- Never re-checking: demand and lead times change, so recalculate on a schedule
From Script to Process
- Pull demand (or forecast error) and purchase order lead times per item
- Classify items into fast, slow and intermittent
- Calculate safety stock with the combined formula or Poisson, by segment
- Simulate each segment against its target and adjust
- Load reorder points into the inventory system
- Recalculate monthly, and alert when actual stockouts or excess stock drift from the targets
Step 5 is usually the engineering work. Our guide to connecting inventory systems to predictive reordering covers how calculated reorder points get into ERP and purchasing.
Conclusion
Safety stock and reorder points take a few lines of Python. Getting them right takes three more habits: include lead-time variability, use Poisson for slow movers at high targets, and check the result with a simulation. In our example, skipping the lead-time term turned a 95% service target into 83% in practice, a gap the formula alone wouldn't have revealed.
Schedule a strategy call with Cogniq AI to have your reorder points checked against your own demand and supplier data, or explore our predictive analytics services.



