📦 50 Supply Chain KPIs
A structured reference for analytics projects, dashboards, and business reviews. Every KPI includes its definition, formula, business meaning, and ready-to-use implementations in Python, SQL, and Excel.
🐍 Python
🗄️ SQL
📊 Excel
50 KPIs
1. Service Level & Customer Fulfillment KPIs
#01
On-Time Delivery (OTD)
›
How to calculate
# df has columns: order_id, promised_date, actual_delivery_date
df['on_time'] = df['actual_delivery_date'] <= df['promised_date']
otd = df['on_time'].mean() * 100
print(f"OTD: {otd:.1f}%")
SELECT
ROUND(
100.0 * SUM(CASE WHEN actual_delivery_date <= promised_date THEN 1 ELSE 0 END)
/ COUNT(*), 2
) AS otd_pct
FROM orders;
| A (promised_date) | B (actual_delivery) | C (on_time?) | D (OTD %) |
C2: =IF(B2<=A2,"Yes","No")
D2: =COUNTIF(C2:C100,"Yes")/COUNTA(C2:C100)*100
#02
In-Full Delivery (IF)
›
How to calculate
# df: order_id, qty_ordered, qty_delivered
df['in_full'] = df['qty_delivered'] >= df['qty_ordered']
if_rate = df['in_full'].mean() * 100
SELECT
ROUND(100.0 * SUM(CASE WHEN qty_delivered >= qty_ordered THEN 1 ELSE 0 END) / COUNT(*), 2)
AS in_full_pct
FROM orders;
| A (qty_ordered) | B (qty_delivered) | C (in_full?) | D (IF %) |
C2: =IF(B2>=A2,"Yes","No")
D2: =COUNTIF(C2:C100,"Yes")/COUNTA(C2:C100)*100
#03
OTIF (On-Time In-Full)
›
How to calculate
df['otif'] = (
(df['actual_delivery_date'] <= df['promised_date']) &
(df['qty_delivered'] >= df['qty_ordered'])
)
otif = df['otif'].mean() * 100
SELECT
ROUND(100.0 * SUM(CASE WHEN
actual_delivery_date <= promised_date
AND qty_delivered >= qty_ordered
THEN 1 ELSE 0 END) / COUNT(*), 2) AS otif_pct
FROM orders;
| A (promised) | B (actual) | C (qty_ord) | D (qty_del) | E (OTIF?) | F (OTIF%) |
E2: =IF(AND(B2<=A2,D2>=C2),"Yes","No")
F2: =COUNTIF(E2:E100,"Yes")/COUNTA(E2:E100)*100
#04Order Fill Rate›
How to calculate
fill_rate = (df['units_shipped_immediately'].sum() / df['units_ordered'].sum()) * 100
SELECT ROUND(100.0 * SUM(units_shipped_immediately) / SUM(units_ordered), 2) AS fill_rate_pct
FROM order_lines;
| A (units_shipped_immed) | B (units_ordered) |
=SUM(A2:A100)/SUM(B2:B100)*100
#05Perfect Order Rate›
How to calculate
df['perfect'] = (
df['on_time'] & df['in_full'] &
df['no_damage'] & df['correct_docs']
)
por = df['perfect'].mean() * 100
SELECT ROUND(100.0 * SUM(CASE WHEN
on_time=1 AND in_full=1 AND no_damage=1 AND correct_docs=1
THEN 1 ELSE 0 END) / COUNT(*), 2) AS por
FROM orders;
| A (on_time) | B (in_full) | C (no_damage) | D (correct_docs) | E (perfect?) |
E2: =IF(AND(A2="Yes",B2="Yes",C2="Yes",D2="Yes"),"Yes","No")
=COUNTIF(E2:E100,"Yes")/COUNTA(E2:E100)*100
#06Backorder Rate›
How to calculate
backorder_rate = (df['backordered_units'].sum() / df['total_ordered_units'].sum()) * 100
SELECT ROUND(100.0*SUM(backordered_units)/SUM(total_ordered_units),2) AS backorder_rate
FROM order_lines;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=backordered, B=total ordered)
#07Stockout Rate›
How to calculate
stockout_rate = (df['stockout_events'].sum() / df['demand_events'].sum()) * 100
SELECT ROUND(100.0*SUM(stockout_flag)/COUNT(*),2) AS stockout_rate
FROM demand_events;
=COUNTIF(A2:A100,"Stockout")/COUNTA(A2:A100)*100
#08Case Fill Rate›
How to calculate
cfr = (df['cases_shipped'].sum() / df['cases_ordered'].sum()) * 100
SELECT ROUND(100.0*SUM(cases_shipped)/SUM(cases_ordered),2) AS case_fill_rate
FROM shipments;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=shipped, B=ordered)
#09Customer Order Cycle Time›
How to calculate
df['cycle_days'] = (df['delivery_date'] - df['order_date']).dt.days
avg_cycle = df['cycle_days'].mean()
SELECT AVG(DATEDIFF(delivery_date, order_date)) AS avg_cycle_days
FROM orders;
| A (order_date) | B (delivery_date) | C (days) |
C2: =B2-A2
=AVERAGE(C2:C100)
#10Returns Rate›
How to calculate
returns_rate = (df['returned_units'].sum() / df['delivered_units'].sum()) * 100
SELECT ROUND(100.0*SUM(returned_units)/SUM(delivered_units),2) AS returns_rate
FROM shipments;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=returned, B=delivered)
2. Inventory KPIs
#11Inventory Turnover›
How to calculate
avg_inv = (df['opening_inv'] + df['closing_inv']) / 2
turnover = df['cogs'] / avg_inv
SELECT ROUND(SUM(cogs) / AVG((opening_inv+closing_inv)/2), 2) AS inv_turnover
FROM inventory_summary;
| A (COGS) | B (avg_inventory) |
=A2/B2
#12Days Inventory Outstanding (DIO)›
How to calculate
dio = (avg_inv / df['cogs']) * 365
SELECT ROUND(AVG((opening_inv+closing_inv)/2) / SUM(cogs) * 365, 1) AS dio
FROM inventory_summary;
| A (avg_inventory) | B (COGS) |
=(A2/B2)*365
#13Inventory Accuracy›
How to calculate
df['match'] = df['system_qty'] == df['physical_qty']
accuracy = df['match'].mean() * 100
SELECT ROUND(100.0*SUM(CASE WHEN system_qty=physical_qty THEN 1 ELSE 0 END)/COUNT(*),2)
AS inv_accuracy FROM cycle_counts;
| A (system_qty) | B (physical_qty) | C (match?) |
C2: =IF(A2=B2,"Yes","No")
=COUNTIF(C2:C100,"Yes")/COUNTA(C2:C100)*100
#14Cycle Count Accuracy›
How to calculate
cca = (df['accurate_items'].sum() / df['total_counted'].sum()) * 100
SELECT ROUND(100.0*SUM(accurate_items)/SUM(total_counted),2) AS cycle_count_accuracy
FROM cycle_counts;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=accurate, B=total counted)
#15Safety Stock Coverage›
How to calculate
df['ss_coverage_days'] = df['safety_stock'] / df['avg_daily_demand']
SELECT sku, ROUND(safety_stock/avg_daily_demand, 1) AS ss_coverage_days
FROM inventory_params;
| A (safety_stock) | B (avg_daily_demand) |
=A2/B2
#16Inventory Days of Supply›
How to calculate
df['dos'] = df['current_inventory'] / df['avg_daily_demand']
SELECT sku, ROUND(current_inventory/avg_daily_demand,1) AS days_of_supply
FROM inventory;
=A2/B2 (A=current_inv, B=avg_daily_demand)
#17Slow-Moving Inventory Rate›
How to calculate
slow = df[df['velocity'] < threshold]['inv_value'].sum()
smr = (slow / df['inv_value'].sum()) * 100
SELECT ROUND(100.0*SUM(CASE WHEN velocity < 0.1 THEN inv_value ELSE 0 END)/SUM(inv_value),2)
AS slow_moving_rate FROM inventory;
| A (inv_value) | B (velocity) |
=SUMIF(B2:B100,"<0.1",A2:A100)/SUM(A2:A100)*100
#18Obsolete Inventory Rate›
How to calculate
obs_rate = (df[df['status']=='obsolete']['inv_value'].sum() / df['inv_value'].sum()) * 100
SELECT ROUND(100.0*SUM(CASE WHEN status='obsolete' THEN inv_value ELSE 0 END)/SUM(inv_value),2)
AS obsolete_rate FROM inventory;
=SUMIF(B2:B100,"obsolete",A2:A100)/SUM(A2:A100)*100
(A=inv_value, B=status)
#19Inventory Carrying Cost›
How to calculate
df['carrying_cost'] = (
df['storage'] + df['capital'] +
df['insurance'] + df['obsolescence'] + df['handling']
)
SELECT storage_cost+capital_cost+insurance_cost+obsolescence_cost+handling_cost
AS carrying_cost FROM cost_summary;
| A (storage) | B (capital) | C (insur) | D (obso) | E (handling) |
=SUM(A2:E2)
#20Forecast Bias›
How to calculate
df['bias'] = df['forecast'] - df['actual']
total_bias = df['bias'].sum() # positive = over-forecast
SELECT SUM(forecast - actual) AS forecast_bias
FROM demand_plan;
| A (forecast) | B (actual) | C (bias) |
C2: =A2-B2
=SUM(C2:C100) total bias; =AVERAGE(C2:C100) for avg bias
3. Forecasting & Planning KPIs
#21Forecast Accuracy›
How to calculate
mape = ((df['actual']-df['forecast']).abs()/df['actual']).mean() * 100
fa = 100 - mape
SELECT 100 - ROUND(100.0*AVG(ABS(actual-forecast)/NULLIF(actual,0)),2) AS forecast_accuracy
FROM demand_plan;
MAPE in column C: =ABS(A2-B2)/A2 (A=actual, B=forecast)
Forecast Accuracy:
=1-AVERAGE(C2:C100) format as %
#22MAPE›
How to calculate
mape = ((df['actual']-df['forecast']).abs()/df['actual']).mean() * 100
SELECT ROUND(100.0*AVG(ABS(actual-forecast)/NULLIF(actual,0)),2) AS mape
FROM demand_plan;
C2: =ABS(A2-B2)/A2 (A=actual, B=forecast)
=AVERAGE(C2:C100)*100 = MAPE %
#23WAPE›
How to calculate
wape = ((df['actual']-df['forecast']).abs().sum() / df['actual'].sum()) * 100
SELECT ROUND(100.0*SUM(ABS(actual-forecast))/SUM(actual),2) AS wape
FROM demand_plan;
C2: =ABS(A2-B2) (A=actual, B=forecast)
=SUM(C2:C100)/SUM(A2:A100)*100
#24MAE›
How to calculate
mae = (df['actual']-df['forecast']).abs().mean()
SELECT AVG(ABS(actual-forecast)) AS mae FROM demand_plan;
C2: =ABS(A2-B2)
=AVERAGE(C2:C100)
#25RMSE›
How to calculate
import numpy as np
rmse = np.sqrt((((df['actual']-df['forecast'])**2).mean()))
SELECT SQRT(AVG(POW(actual-forecast,2))) AS rmse FROM demand_plan;
C2: =(A2-B2)^2
=SQRT(AVERAGE(C2:C100))
#26Demand Plan Attainment›
How to calculate
dpa = (df['actual_demand'].sum() / df['planned_demand'].sum()) * 100
SELECT ROUND(100.0*SUM(actual_demand)/SUM(planned_demand),2) AS dpa
FROM demand_plan;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=actual, B=planned)
#27Supply Plan Attainment›
How to calculate
spa = (df['actual_supply'].sum() / df['planned_supply'].sum()) * 100
SELECT ROUND(100.0*SUM(actual_supply)/SUM(planned_supply),2) AS spa
FROM supply_plan;
=SUM(A2:A100)/SUM(B2:B100)*100
#28Production Plan Adherence›
How to calculate
ppa = (df['executed_as_scheduled'].sum() / df['total_planned'].sum()) * 100
SELECT ROUND(100.0*SUM(executed_as_scheduled)/SUM(total_planned),2) AS ppa
FROM production_schedule;
=SUM(A2:A100)/SUM(B2:B100)*100
#29Replenishment Lead Time›
How to calculate
df['replen_lt'] = (df['receipt_date']-df['order_date']).dt.days
avg_lt = df['replen_lt'].mean()
SELECT AVG(DATEDIFF(receipt_date,order_date)) AS avg_replen_lt
FROM replenishment_orders;
C2: =B2-A2 (A=order_date, B=receipt_date)
=AVERAGE(C2:C100)
#30Planning Cycle Time›
How to calculate
df['cycle_time'] = (df['completion_date']-df['start_date']).dt.days
avg_ct = df['cycle_time'].mean()
SELECT AVG(DATEDIFF(completion_date,start_date)) AS avg_cycle_time
FROM planning_cycles;
C2: =B2-A2
=AVERAGE(C2:C100)
4. Procurement & Supplier KPIs
#31Supplier On-Time Delivery›
How to calculate
sotd = (df[df['receipt_date']<=df['expected_date']].shape[0] / len(df)) * 100
SELECT ROUND(100.0*SUM(CASE WHEN receipt_date<=expected_date THEN 1 ELSE 0 END)/COUNT(*),2)
AS supplier_otd FROM supplier_deliveries;
C2: =IF(B2<=A2,"Yes","No") (A=expected, B=receipt)
=COUNTIF(C2:C100,"Yes")/COUNTA(C2:C100)*100
#32Supplier In-Full Delivery›
How to calculate
sif = (df[df['qty_received']>=df['qty_ordered']].shape[0] / len(df)) * 100
SELECT ROUND(100.0*SUM(CASE WHEN qty_received>=qty_ordered THEN 1 ELSE 0 END)/COUNT(*),2)
AS supplier_if FROM supplier_deliveries;
=COUNTIF(C2:C100,"Yes")/COUNTA(C2:C100)*100
C2: =IF(B2>=A2,"Yes","No") (A=ordered, B=received)
#33Supplier OTIF›
How to calculate
df['s_otif'] = (df['receipt_date']<=df['expected_date']) & (df['qty_received']>=df['qty_ordered'])
s_otif = df['s_otif'].mean() * 100
SELECT ROUND(100.0*SUM(CASE WHEN
receipt_date<=expected_date AND qty_received>=qty_ordered
THEN 1 ELSE 0 END)/COUNT(*),2) AS supplier_otif
FROM supplier_deliveries;
E2: =IF(AND(B2<=A2,D2>=C2),"Yes","No")
=COUNTIF(E2:E100,"Yes")/COUNTA(E2:E100)*100
#34Purchase Price Variance (PPV)›
How to calculate
df['ppv'] = (df['actual_price'] - df['standard_price']) * df['qty_purchased']
total_ppv = df['ppv'].sum()
SELECT SUM((actual_price-standard_price)*qty_purchased) AS total_ppv
FROM purchase_orders;
D2: =(B2-A2)*C2 (A=std_price, B=actual_price, C=qty)
=SUM(D2:D100)
#35Supplier Defect Rate›
How to calculate
defect_rate = (df['defective_units'].sum() / df['received_units'].sum()) * 100
SELECT ROUND(100.0*SUM(defective_units)/SUM(received_units),2) AS defect_rate
FROM supplier_receipts;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=defective, B=received)
#36Supplier Lead Time›
How to calculate
df['supplier_lt'] = (df['goods_receipt_date']-df['po_date']).dt.days
avg_slt = df['supplier_lt'].mean()
SELECT AVG(DATEDIFF(goods_receipt_date,po_date)) AS avg_supplier_lt
FROM purchase_orders;
C2: =B2-A2 (A=po_date, B=receipt_date)
=AVERAGE(C2:C100)
#37Purchase Order Cycle Time›
How to calculate
df['po_cycle'] = (df['completion_date']-df['initiation_date']).dt.days
avg_po_ct = df['po_cycle'].mean()
SELECT AVG(DATEDIFF(completion_date,initiation_date)) AS po_cycle_time
FROM purchase_orders;
C2: =B2-A2 (A=initiation, B=completion)
=AVERAGE(C2:C100)
#38Contract Compliance Rate›
How to calculate
ccr = (df[df['on_contract']==True]['spend'].sum() / df['spend'].sum()) * 100
SELECT ROUND(100.0*SUM(CASE WHEN on_contract=1 THEN spend ELSE 0 END)/SUM(spend),2)
AS contract_compliance FROM spend_data;
=SUMIF(B2:B100,"Yes",A2:A100)/SUM(A2:A100)*100
(A=spend, B=on_contract Y/N)
#39Spend Under Management›
How to calculate
sum_pct = (df[df['managed']==True]['spend'].sum() / df['spend'].sum()) * 100
SELECT ROUND(100.0*SUM(CASE WHEN managed=1 THEN spend ELSE 0 END)/SUM(spend),2)
AS spend_under_mgmt FROM spend_data;
=SUMIF(B2:B100,"Yes",A2:A100)/SUM(A2:A100)*100
#40Supplier Concentration Risk›
How to calculate
top_n = df.groupby('supplier')['spend'].sum().nlargest(5).sum()
conc_risk = (top_n / df['spend'].sum()) * 100
SELECT ROUND(100.0*SUM(spend)/(SELECT SUM(spend) FROM spend_data),2) AS concentration
FROM (SELECT supplier, SUM(spend) AS spend FROM spend_data
GROUP BY supplier ORDER BY spend DESC LIMIT 5) top5;
Sort spend descending, then:
=SUM(B2:B6)/SUM(B2:B100)*100 (top 5 rows / total spend)
5. Warehouse & Logistics KPIs
#41Warehouse Picking Accuracy›
How to calculate
wpa = (df['correct_lines'].sum() / df['total_lines'].sum()) * 100
SELECT ROUND(100.0*SUM(correct_lines)/SUM(total_lines),2) AS picking_accuracy
FROM warehouse_picks;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=correct, B=total)
#42Dock-to-Stock Time›
How to calculate
df['dts_hours'] = (df['stock_avail_ts']-df['receipt_ts']).dt.total_seconds() / 3600
avg_dts = df['dts_hours'].mean()
SELECT AVG(TIMESTAMPDIFF(HOUR, receipt_ts, stock_avail_ts)) AS avg_dts_hours
FROM inbound_receipts;
C2: =(B2-A2)*24 (A=receipt_ts, B=avail_ts) → hours
=AVERAGE(C2:C100)
#43Warehouse Capacity Utilization›
How to calculate
util = (df['used_positions'].sum() / df['total_positions'].sum()) * 100
SELECT ROUND(100.0*SUM(used_positions)/SUM(total_positions),2) AS capacity_utilization
FROM warehouse_snapshot;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=used, B=total)
#44Labor Productivity›
How to calculate
productivity = df['units_handled'].sum() / df['labor_hours'].sum()
SELECT ROUND(SUM(units_handled)/SUM(labor_hours),1) AS units_per_hour
FROM labor_log;
=SUM(A2:A100)/SUM(B2:B100) (A=units, B=hours)
#45Transportation Cost per Unit›
How to calculate
cost_per_unit = df['transport_cost'].sum() / df['shipped_units'].sum()
SELECT ROUND(SUM(transport_cost)/SUM(shipped_units),4) AS cost_per_unit
FROM shipments;
=SUM(A2:A100)/SUM(B2:B100) (A=cost, B=units)
#46Freight Cost as % of Sales›
How to calculate
fc_pct = (df['freight_cost'].sum() / df['net_sales'].sum()) * 100
SELECT ROUND(100.0*SUM(freight_cost)/SUM(net_sales),2) AS freight_pct_sales
FROM financials;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=freight, B=net_sales)
#47Truck Fill Rate›
How to calculate
tfr = (df['loaded_weight'].sum() / df['truck_capacity'].sum()) * 100
SELECT ROUND(100.0*SUM(loaded_weight)/SUM(truck_capacity),2) AS truck_fill_rate
FROM shipments;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=loaded, B=capacity)
#48Average Delivery Lead Time›
How to calculate
df['transit_days'] = (df['delivery_date']-df['shipment_date']).dt.days
avg_dlt = df['transit_days'].mean()
SELECT AVG(DATEDIFF(delivery_date,shipment_date)) AS avg_delivery_lt
FROM shipments;
C2: =B2-A2 (A=ship_date, B=delivery_date)
=AVERAGE(C2:C100)
#49Damage Rate in Transit›
How to calculate
damage_rate = (df['damaged_units'].sum() / df['shipped_units'].sum()) * 100
SELECT ROUND(100.0*SUM(damaged_units)/SUM(shipped_units),2) AS damage_rate
FROM shipments;
=SUM(A2:A100)/SUM(B2:B100)*100 (A=damaged, B=shipped)
#50Logistics Cost to Serve›
How to calculate
df['cost_to_serve'] = (
df['transport'] + df['warehousing'] +
df['handling'] + df['delivery']
)
cts_by_customer = df.groupby('customer')['cost_to_serve'].sum()
SELECT customer,
SUM(transport_cost+warehousing_cost+handling_cost+delivery_cost) AS cost_to_serve
FROM logistics_costs
GROUP BY customer
ORDER BY cost_to_serve DESC;
| A (transport) | B (warehouse) | C (handling) | D (delivery) |
=SUM(A2:D2) per row; then SUMIF by customer/channel