Batch and Serial Tracking in ERP: Full Traceability

Photo by Dave Mathews on flickr
Batch traceability tracks a group of units produced or received together under one lot number, so a single record covers hundreds or thousands of items. Serial number traceability assigns a unique identifier to every individual unit in addition to its lot. Most ERPs use batch tracking for raw materials and packaged goods, and reserve serial tracking for high-value or safety-critical items like medical devices.
FIFO picks stock based on receipt order, which works fine for non-perishable goods but can ship an older, near-expiry lot after a fresher one arrives. FEFO picks based on expiry date regardless of receipt order, which is what actually prevents expired stock from reaching a customer. Pharmaceutical, food, and chemical businesses generally require FEFO because shelf life, not arrival date, determines usability.
Separate the concepts into three tables: a lot table holding batch metadata like manufacturing and expiry dates, a unit table that optionally carries a unique serial number and references its parent lot, and an append-only movement table that logs every receipt, transfer, and shipment against a lot or unit id. This lets an item category use lot-only tracking simply by never populating the serial number column.
Yes, if every movement row references the lot or unit by foreign key rather than a copied text field. A recall report resolves the batch or serial number to its internal id, queries every shipment or transfer movement referencing that id, joins to the originating sales order or delivery, and aggregates by customer. Done correctly, this turns a manual multi-day recall trace into a query that runs in seconds.
The lot should move into a quarantine status on the lot record itself, which blocks it from being picked for any new order without deleting its movement history. From there a disposition workflow, such as return to supplier, destruction, or rework, gets logged as its own movement event so the full lifecycle of the quarantined lot remains auditable.

Photo by Dave Mathews on flickr
When a customer calls to say a product from a specific production run failed quality control, the question that decides whether your recall takes an afternoon or a week is simple: can your ERP tell you, in seconds, exactly which shipments, customers, and warehouse bins contain that lot? For food, pharmaceutical, chemical, and medical device businesses, batch and serial-number traceability is not a nice-to-have report, it is the backbone of every receiving, movement, and shipping transaction in the system.
This post walks through how to design that traceability layer in a custom ERP: the difference between lot-level and instance-level tracking, the data model that supports both, first-expired-first-out picking logic, and the recall report that ties a single batch number back to every customer who received it.
Not every item needs a unique serial number, and not every regulated item can get away with only a batch number. The GS1 standards body describes three levels of traceability granularity that map directly onto ERP design decisions: class-level identification by product code alone, lot-level identification by product code plus batch or lot number, and instance-level identification by product code plus a unique serial number per unit. Choosing the right level per item category is the first architectural decision, because it determines how much detail your movement ledger has to carry.
Do not force instance-level serialization onto every item category just because a few products need it. Serial-level tracking multiplies the number of rows your movement ledger writes on every transaction, and most warehouse teams cannot scan a hundred individual serials during a rush pick. Reserve it for items where regulation or warranty actually requires unit-level history.
A traceability-capable schema separates three concerns that are often flattened into one inventory table in simpler systems: the lot itself, with its manufacturing and expiry metadata; the physical unit, which may or may not carry its own serial number; and the movement event, which is the only place quantity actually changes. Keeping movements as an append-only ledger, rather than mutating a running balance, is what makes recall queries and audit trails possible after the fact, because you can always reconstruct the state of any lot at any point in history. This also means the current on-hand quantity for a lot is never stored directly, it is derived by summing signed quantities across every movement row referencing that lot, which is slower to read but far harder to silently corrupt than a mutable balance column.
| Concept | Lot-level example | Instance-level example |
|---|---|---|
| Identifier | Batch LOT-2026-0311 | Batch LOT-2026-0311 + serial SN-88213 |
| Quantity per record | 500 units in one lot row | 1 unit per stock_unit row |
| Recall query grain | All shipments containing the lot | The exact unit and its owner |
Every physical event, a purchase receipt, an internal transfer, a pick for an outbound order, or a quarantine hold, writes a row to the movement ledger referencing either a unit or a lot. On the outbound side, regulated inventory almost always requires FEFO, first-expired-first-out, rather than the simpler FIFO used for non-perishable goods. FEFO prioritizes the stock closest to its expiry date regardless of when it arrived, which minimizes write-offs and keeps expired stock from ever reaching a customer. The picking query below shows the core logic: filter to available stock for the requested item, sort by expiry date ascending, and take only what the order needs.
-- Simplified schema for lot/serial-level stock ledger
create table stock_lot (
id bigint primary key generated always as identity,
item_id bigint not null references item(id),
lot_code text not null,
mfg_date date,
expiry_date date,
unique (item_id, lot_code)
);
create table stock_unit (
id bigint primary key generated always as identity,
lot_id bigint not null references stock_lot(id),
serial_no text unique, -- null when item is lot-tracked only, not serialized
status text not null default 'in_stock' -- in_stock, reserved, shipped, quarantined
);
create table stock_movement (
id bigint primary key generated always as identity,
unit_id bigint references stock_unit(id),
lot_id bigint references stock_lot(id),
qty numeric not null,
movement_type text not null, -- receipt, transfer, pick, ship, adjust
warehouse_id bigint not null references warehouse(id),
ref_doc text, -- PO number, SO number, or transfer id
moved_at timestamptz not null default now()
);
-- FEFO pick candidate query: earliest expiry first, only usable stock
select su.id, sl.lot_code, sl.expiry_date, sl.item_id
from stock_unit su
join stock_lot sl on sl.id = su.lot_id
where su.status = 'in_stock'
and sl.item_id = $1
order by sl.expiry_date asc, sl.id asc
limit $2;FEFO logic has an edge case that trips up a lot of first implementations: partially expired or unusually short-dated lots that arrive from a supplier ahead of older stock. If your picking query only sorts by expiry date without also checking a minimum shelf-life threshold at time of shipment, you can ship a lot that expires before it even reaches the customer. Add a configurable minimum-remaining-shelf-life rule per item category, not just a raw expiry-date sort.
The entire point of the schema above is to answer one question fast: given a batch or serial number, list every downstream location and customer that received it. A recall report needs to walk the movement ledger forward from receipt to shipment, which is straightforward if movements always reference the lot or unit id rather than a copied text field. The typical recall trace follows this sequence:
Traceability data is only useful if the ERP can act on it immediately. When quality control flags a lot, the system needs a quarantine status that blocks the lot from being picked for any new order without deleting its movement history, followed by a disposition workflow, return to supplier, destroy, or rework, that itself gets logged as a movement event. Treating quarantine as a status flag on the lot record, checked at pick time, is far more reliable than trying to physically remove stock from every open order the moment a hold is placed.
A well-designed traceability module pays for itself the first time a recall report that used to take a warehouse team two days to compile by hand runs in under a minute from a single batch number lookup, with a full customer distribution list attached.
A few decisions consistently separate traceability modules that hold up under a real audit from ones that fall apart the first time an auditor asks a follow-up question. These are lessons learned the hard way, usually the first time a real recall request lands on a Friday afternoon and the report has to be correct on the first try: