Calculate FIFO Inventory Valuation with Python
FIFO — first in, first out — assumes the oldest stock is sold first, so each sale is costed at the price of the earliest purchases still on hand, and the stock left at the end is valued at the most recent prices. It is simple to state and awkward in a spreadsheet: one sale can use up the tail of one purchase and part of the next, and every later cost depends on what earlier sales consumed. In Python it is a queue per product. This page is part of Building Finance and Operations Reports.
Prerequisites
pip install pandas openpyxl xlsxwriter
stock_moves_july.xlsx lists the stock movements for July 2026 for two products: the opening stock with its cost, purchases with their unit cost, and sales with quantities only — the cost of a sale is what FIFO has to work out. In practice the purchases come from the purchase ledger and the sales from the sales or shipping system; combine them into one table of dated movements first.
Step 1 — Order the movements
FIFO depends on sequence, so sort by date, and within a day put receipts before sales, so that goods received in the morning are available to an afternoon sale. That is a policy choice; stating it in code makes it visible:
import pandas as pd
moves = pd.read_excel("stock_moves_july.xlsx", parse_dates=["Date"])
order = {"Opening": 0, "Purchase": 1, "Sale": 2}
moves = moves.sort_values(["Date", "Type"], key=lambda s: s.map(order) if s.name == "Type" else s,
kind="stable").reset_index(drop=True)
print(moves.head(5).to_string(index=False))
A stable sort keeps the original order of movements with the same date and type, which matters when two sales happen on one day.
Step 2 — Consume layers with a queue
Each SKU gets a deque of cost layers, [quantity, unit_cost]. A receipt appends a layer at the back; a sale takes units from the front until its quantity is covered, splitting a layer if it only needs part of it. Each sale's cost is the sum over the layers it touched:
from collections import deque, defaultdict
class NegativeStock(Exception):
pass
def fifo(moves):
layers = defaultdict(deque)
costed = []
for m in moves.itertuples():
stack = layers[m.SKU]
if m.Type in ("Opening", "Purchase"):
stack.append([m.Qty, m.UnitCost])
continue
need, cost, used = m.Qty, 0.0, []
while need > 0:
if not stack:
raise NegativeStock(f"{m.Date:%Y-%m-%d} {m.SKU}: sale of {m.Qty} exceeds stock by {need}")
qty, unit = stack[0]
take = min(qty, need)
cost += take * unit
used.append(f"{take}@{unit:.2f}")
need -= take
if take == qty:
stack.popleft()
else:
stack[0][0] -= take
costed.append({"Date": m.Date, "SKU": m.SKU, "Qty": m.Qty, "COGS": round(cost, 2),
"UnitCOGS": round(cost / m.Qty, 4), "Layers used": " + ".join(used)})
return pd.DataFrame(costed), layers
sales, layers = fifo(moves)
print(sales.to_string(index=False))
Look at the P-100 sale on 10 July: 90 units, of which 50 came from the last of the opening stock at 4.10 and 40 from the 6 July purchase at 4.35, so its unit cost is between the two. That split is the whole point of FIFO, and the "Layers used" column makes it auditable.
Step 3 — Value the closing stock
Whatever layers are left at the end of the period are the closing stock, valued at their own costs, which under FIFO are the most recent ones:
closing = pd.DataFrame(
[(sku, qty, unit, round(qty * unit, 2)) for sku, stack in layers.items() for qty, unit in stack],
columns=["SKU", "Qty", "UnitCost", "Value"],
)
print(closing.to_string(index=False))
print(closing.groupby("SKU")[["Qty", "Value"]].sum().to_string())
Step 4 — Reconcile
Cost is conserved: everything that came in, opening stock plus purchases, either went out as cost of goods sold or is still on the shelf. Check it per SKU, and check quantities the same way:
receipts = moves[moves["Type"] != "Sale"].assign(Value=lambda d: d["Qty"] * d["UnitCost"])
check = pd.DataFrame({
"In value": receipts.groupby("SKU")["Value"].sum(),
"COGS": sales.groupby("SKU")["COGS"].sum(),
"Closing value": closing.groupby("SKU")["Value"].sum(),
})
check["Difference"] = (check["In value"] - check["COGS"] - check["Closing value"]).round(2)
qty_in = receipts.groupby("SKU")["Qty"].sum()
qty_out = sales.groupby("SKU")["Qty"].sum()
check["Qty check"] = qty_in - qty_out == closing.groupby("SKU")["Qty"].sum()
print(check.round(2).to_string())
A difference other than rounding means a movement was skipped or double counted. Rounding each sale's cost to cents can leave a cent or two; if the ledger must match exactly, keep full precision and round only for display.
Step 5 — When sales exceed stock
In real data a sale sometimes appears before the receipt that covered it: a goods-received note keyed a day late, or a return processed as a negative purchase. FIFO cannot cost units that are not there, so the function stops and names the movement. Try it by moving a purchase after the sale that needs it:
broken = moves.copy()
broken.loc[(broken["SKU"] == "P-200") & (broken["Date"] == "2026-07-14"), "Date"] = pd.Timestamp("2026-07-25")
broken = broken.sort_values(["Date", "Type"], key=lambda s: s.map(order) if s.name == "Type" else s, kind="stable")
try:
fifo(broken)
except NegativeStock as e:
print("stopped:", e)
Stopping is deliberate. Guessing a cost for missing units, or letting the layer go negative, produces a valuation that looks fine and is wrong. Fix the source data, or record the policy explicitly, for example costing the shortfall at the last known cost and flagging it for review.
Step 6 — Compare with weighted average cost
Many businesses report on weighted average cost instead, and the difference between the two methods is often asked for. In a periodic weighted average, every unit available in the period costs the same: total value in divided by total units in:
avg_cost = (receipts.groupby("SKU")["Value"].sum() / receipts.groupby("SKU")["Qty"].sum())
compare = pd.DataFrame({
"FIFO COGS": sales.groupby("SKU")["COGS"].sum(),
"Avg cost COGS": qty_out * avg_cost,
"FIFO closing": closing.groupby("SKU")["Value"].sum(),
"Avg cost closing": (qty_in - qty_out) * avg_cost,
}).round(2)
print(compare.to_string())
With prices rising through the month, FIFO charges the older, cheaper costs to sales and leaves the newer, dearer ones in stock, so its cost of goods sold is lower and its closing stock higher than weighted average. When prices fall, the difference reverses.
Step 7 — Write the report
Three sheets: sales with their FIFO cost and the layers used, the closing layers, and the reconciliation:
with pd.ExcelWriter("fifo_valuation_2026-07.xlsx", engine="xlsxwriter") as xw:
sales.to_excel(xw, sheet_name="Sales at FIFO cost", index=False)
closing.to_excel(xw, sheet_name="Closing layers", index=False)
check.join(compare).to_excel(xw, sheet_name="Reconciliation")
money = xw.book.add_format({"num_format": "#,##0.00"})
xw.sheets["Sales at FIFO cost"].set_column("A:A", 18, xw.book.add_format({"num_format": "yyyy-mm-dd"}))
xw.sheets["Sales at FIFO cost"].set_column("D:E", 11, money)
xw.sheets["Sales at FIFO cost"].set_column("F:F", 30)
xw.sheets["Closing layers"].set_column("C:D", 11, money)
xw.sheets["Reconciliation"].set_column("A:I", 15, money)
print("written")
Scaling up
The loop processes one movement at a time, which is fine for tens of thousands of rows per month. For millions of movements, process each SKU independently — they never interact — and carry the closing layers forward as next month's opening layers, stored as a small table of SKU, Qty, UnitCost rows, so each month only processes its own movements. That table is also what the auditors will ask for.
Common pitfalls
- Unsorted movements. FIFO is sequence dependent; sort by date and a documented same-day rule.
- Averaging across layers. A sale that spans two layers is costed at each layer's price for the units taken from it, not at an average.
- Negative stock. Stop and fix the data rather than costing units that do not exist.
- Returns. A customer return re-enters stock; decide whether at its original FIFO cost or as a new layer, and apply it consistently.
- Rounding each step. Round for display only, or the reconciliation drifts by cents.
Conclusion
Sort movements by date with receipts before sales on the same day, keep a deque of [qty, unit_cost] layers per SKU, append receipts, and consume sales from the front, splitting layers as needed and recording which layers each sale used. Value the remaining layers as closing stock, reconcile value in against COGS plus closing stock, stop on sales that exceed stock, and compare with weighted average cost when asked.
Frequently asked questions
How do I calculate FIFO cost of goods sold in Python? Keep a queue of purchase layers per product. For each sale, take units from the oldest layer first, moving to the next when it is used up, and add up quantity times unit cost for the units taken. The sum is the sale's cost of goods sold.
How is closing inventory valued under FIFO? At the cost of the layers still in the queue after all sales, which are the most recent purchases. Multiply each remaining layer's quantity by its unit cost and add them up.
What is the difference between FIFO and weighted average cost? FIFO costs sales at the oldest prices and leaves recent prices in stock; weighted average uses one blended cost per unit for both. When prices rise, FIFO gives lower cost of goods sold and higher closing stock.
What should happen when a sale exceeds the stock on hand? Treat it as a data problem: usually a receipt was recorded late. Stop the calculation and report the movement, rather than inventing a cost.
Related
- Up one level: Building Finance and Operations Reports — the rest of the finance reports.
- Build an Inventory Reorder Report — the quantity side of the same stock.
- Build a Profit and Loss Statement from a General Ledger — where cost of sales lands.
- Convert Multi-Currency Transactions with an FX Rate Table — for purchases in other currencies.
- Running Totals and Year-Over-Year Growth in pandas — the simpler cumulative case.
More on report layouts & templates
- GuideBuilding Multi-Sheet Excel Dashboards
- How-toAdd a Data Dictionary Sheet to an Excel Report
- How-toAdd a KPI Summary Block to an Excel Dashboard
- How-toAdd a Table of Contents Sheet with Hyperlinks in Excel
- How-toAdd a Dropdown Filter That Drives an Excel Dashboard
- How-toAdd a Summary Sheet to an Excel Report with Python