Founder & Lead Engineer, RAITHub
FEFO (first expired, first out) ships the batch with the nearest expiry date first; FIFO ships the oldest receipt first. For cosmetics, food and pharmacy retail, FEFO is the safer default, because a batch received later can expire sooner. Allocate batches when the order is placed, skip any batch with less than your minimum remaining shelf life, for example 180 days, and lock batch rows while allocating.
If you would rather have it built for you, see how RAITHub would build this below.
What is the difference between FEFO and FIFO?
FIFO (first in, first out) picks by receipt date. FEFO picks by expiry date. They give the same answer whenever batches arrive in expiry order, and different answers the moment they do not: a supplier clears old stock to you late, a return comes back, or two suppliers ship the same product with different shelf lives.
| Rule | Picks first | Fits | Fails when |
|---|---|---|---|
| FIFO | The batch received earliest | Goods with no expiry, or where age matters more than expiry (packaging, seasonal stock) | A later delivery expires sooner, so short-dated stock sits on the shelf until it is unsellable |
| FEFO | The batch that expires earliest | Cosmetics, food, supplements, pharmacy retail: anything with a printed expiry or best-before date | Batches are recorded without expiry dates, or pickers grab whatever is nearest |
| LIFO | The batch received last | Rarely in ecommerce; bulk goods where the newest stock is on top | Anything that ages |
Packaged warehouse systems implement FEFO the same way. Odoo's FEFO removal strategy needs lot tracking and expiration dates switched on, then reserves "lots with the soonest removal dates first", where the removal date is the expiry date minus a set number of days. Its own example fills 6 units by taking all 5 from the soonest lot and 1 from the next (Odoo: FEFO removal strategy). That "removal date" idea is the minimum remaining shelf life, below.
Should batches be allocated when the order is placed or when it is picked?
For most stores, when the order is placed. It is the only point where you can still tell the customer, honestly, that you cannot ship enough life on the product.
| Allocate at | Strength | Weakness | Use it when |
|---|---|---|---|
| Order time | The order is promised specific batches; shelf-life rules are checked before you take money | A batch damaged in the warehouse later needs a reallocation | Retail and B2B orders with expiry rules, and anywhere a customer can see or demand the expiry |
| Pick time | Uses the freshest view of the shelf; less reallocation | You took an order you may not be able to fill with acceptable life | High-volume, same-day picking of goods with long shelf lives |
| Both: reserve the quantity at order time, choose the batch at pick time | No overselling, flexible picking | The shelf-life check happens late, so it has to be repeated at pick | Large warehouses with many locations per SKU |
Whichever you choose, the total quantity must be reserved inside the order transaction, or you oversell. That part is covered in stopping overselling with stock reservation inside the order transaction; this post is about which batch the units come from.
What is minimum remaining shelf life, and how do you enforce it?
Minimum remaining shelf life is the least amount of life a unit must have left when it ships. A B2B buyer reselling the goods may refuse anything with less than six months left; a retail customer may accept less on a discounted short-dated product. Store it as a number of days, at the right level:
- Per product: the default, e.g. 180 days for a serum, 30 days for a snack.
- Per customer or price tier: wholesale buyers often contract for a longer minimum than retail.
- Per channel: a clearance channel can sell what the main store cannot.
Enforce it in the allocation query, not in the UI: a batch whose expiry is earlier than today plus the minimum is simply not a candidate. Batches that fall below every channel's minimum should be moved to a short_dated or quarantine status by a nightly job, so they stop appearing as available stock at all.
What does the data model look like?
Stock lives per batch, and an order line's units are recorded as one or more allocation rows, so one line can draw on several batches.
CREATE TABLE batches (
id bigserial PRIMARY KEY,
variant_id bigint NOT NULL REFERENCES product_variants(id),
batch_code text NOT NULL,
expiry_date date NOT NULL,
location_code text NOT NULL,
qty_on_hand integer NOT NULL CHECK (qty_on_hand >= 0),
qty_allocated integer NOT NULL DEFAULT 0 CHECK (qty_allocated >= 0),
status text NOT NULL DEFAULT 'sellable'
CHECK (status IN ('sellable', 'short_dated', 'quarantine')),
CHECK (qty_allocated <= qty_on_hand)
);
CREATE INDEX batches_fefo ON batches (variant_id, expiry_date, id)
WHERE status = 'sellable';
CREATE TABLE allocations (
order_line_id bigint NOT NULL REFERENCES order_lines(id),
batch_id bigint NOT NULL REFERENCES batches(id),
qty integer NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_line_id, batch_id)
);
The CHECK (qty_allocated <= qty_on_hand) constraint is the floor: whatever the application does, a batch can never be promised more units than it holds.
How do you allocate FEFO safely when orders arrive at the same time?
Lock the candidate batches in expiry order, plan the split in code, then write the allocations in the same transaction. The locking choice is where FEFO differs from a single stock counter.
PostgreSQL's SKIP LOCKED skips "any selected rows that cannot be immediately locked". The documentation warns that this "provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table" (PostgreSQL: SELECT). A list of batches to draw from behaves like that queue: if another order is allocating from the earliest batch, this order can take the next one instead of waiting. The inconsistent view matters in one case only: when the skipped rows were the stock you needed. So never declare "out of stock" from a skip-locked read. Retry with a plain, waiting FOR UPDATE first.
SELECT id, expiry_date::text AS expiry, qty_on_hand - qty_allocated AS free
FROM batches
WHERE variant_id = $1
AND status = 'sellable'
AND expiry_date >= current_date + $2::int -- minimum remaining shelf life, in days
AND qty_on_hand - qty_allocated > 0
ORDER BY expiry_date, id
FOR UPDATE SKIP LOCKED;
The allocator itself, in TypeScript with node-pg. The planning step is a pure function, so it can be unit tested without a database.
import type { PoolClient } from 'pg'
type Batch = { id: number; expiry: string; free: number }
type Pick = { batchId: number; qty: number }
// Pure FEFO plan: earliest expiry first, splitting across batches when needed.
export function planFefo(batches: Batch[], qty: number): Pick[] | null {
const sorted = [...batches].sort((a, b) => a.expiry.localeCompare(b.expiry) || a.id - b.id)
const picks: Pick[] = []
let left = qty
for (const b of sorted) {
if (left === 0) break
const take = Math.min(b.free, left)
if (take > 0) {
picks.push({ batchId: b.id, qty: take })
left -= take
}
}
return left === 0 ? picks : null
}
async function lockCandidates(client: PoolClient, variantId: number, minDays: number, skipLocked: boolean) {
const sql =
'SELECT id, expiry_date::text AS expiry, qty_on_hand - qty_allocated AS free FROM batches ' +
'WHERE variant_id = $1 AND status = $3 AND expiry_date >= current_date + $2::int ' +
'AND qty_on_hand - qty_allocated > 0 ORDER BY expiry_date, id FOR UPDATE' +
(skipLocked ? ' SKIP LOCKED' : '')
const r = await client.query(sql, [variantId, minDays, 'sellable'])
return r.rows.map((x) => ({ id: Number(x.id), expiry: String(x.expiry), free: Number(x.free) }))
}
// Call inside the order transaction, with lines sorted by variant ID.
export async function allocateLine(
client: PoolClient, orderLineId: number, variantId: number, qty: number, minDays: number,
) {
// Fast path: take batches no other order is allocating right now.
let plan = planFefo(await lockCandidates(client, variantId, minDays, true), qty)
// A skip-locked read can miss stock, so wait for the locks before saying no.
if (!plan) plan = planFefo(await lockCandidates(client, variantId, minDays, false), qty)
if (!plan) throw new Error('Not enough stock with the required shelf life: variant ' + variantId)
for (const p of plan) {
await client.query('UPDATE batches SET qty_allocated = qty_allocated + $2 WHERE id = $1', [p.batchId, p.qty])
await client.query(
'INSERT INTO allocations (order_line_id, batch_id, qty) VALUES ($1, $2, $3)',
[orderLineId, p.batchId, p.qty],
)
}
return plan
}
Three things to know before you ship it:
- Skip-locked FEFO is near-FEFO under load. Two simultaneous orders may get the first and second batch rather than both drawing on the first. If strict expiry order matters more than throughput, drop the fast path and always wait.
- Order can drift. PostgreSQL notes that a locking
SELECTwithORDER BYat Read Committed can return rows out of order if they changed while it waited (PostgreSQL: SELECT). That is whyplanFefosorts again in code. - Deadlocks are still possible when the fallback waits on a batch another order holds. PostgreSQL aborts one of them; retry the whole order transaction on a deadlock error.
What should a warehouse pick list show?
The batch, not just the SKU. If the pick list says "SKU 1042, qty 6" the picker takes the nearest units and your allocation is fiction. Print or display one row per allocation, sorted by location so the picker walks the aisle once:
SELECT b.location_code, v.sku, b.batch_code, b.expiry_date, SUM(a.qty) AS qty
FROM allocations a
JOIN batches b ON b.id = a.batch_id
JOIN order_lines ol ON ol.id = a.order_line_id
JOIN product_variants v ON v.id = b.variant_id
WHERE ol.order_id = ANY($1)
GROUP BY b.location_code, v.sku, b.batch_code, b.expiry_date
ORDER BY b.location_code, v.sku, b.expiry_date;
Make the picker scan or confirm the batch code. If the allocated batch is damaged or missing, the picker should trigger a reallocation, not substitute a batch by hand, so the expiry promise and the stock counts stay true.
How do partial batches, cancellations and returns work?
- Partial batches: one order line can hold several allocation rows, as in Odoo's 5 plus 1 example above. Show both batch codes on the pick list and the packing slip.
- Cancellation before picking: delete the allocation rows and subtract their quantity from
qty_allocatedin one transaction. - Dispatch: subtract the quantity from both
qty_on_handandqty_allocated, idempotently, so a double-clicked "packed" button cannot ship stock twice. - Returns: receive the item into inspection, not into stock. If it passes and still has enough remaining life, add it back to its original batch so the expiry stays true; otherwise move it to quarantine or write it off. Never create a fresh batch for returned goods: that resets the expiry clock.
Buy, build or hire?
| Option | Typical cost | Choose this when | The catch |
|---|---|---|---|
| Off-the-shelf inventory or ERP: Zoho Inventory, Odoo | Zoho Inventory Professional, the first plan with batch tracking, $79 a month billed annually (Zoho pricing); Odoo offers one app free for unlimited users, with paid plans priced per user (Odoo pricing) | You run a standard warehouse and your rules fit their settings | Shelf-life rules per customer tier, or allocation tied to your own checkout and credit rules, often need custom work anyway |
| Template: a hosted store with a batch-tracking app or a spreadsheet | Shopify from $19 a month billed yearly (Shopify pricing), plus any app | Low volume and a few long-life SKUs; one person picks and knows the shelf | FEFO depends on the picker's memory; check that your plan and app really track lots and expiry before relying on them |
| Custom build | A fixed quote for your scope | FEFO has to work with your own pricing, credit, channels and returns rules, inside your order transaction | You own the code and its tests |
For a smaller retailer weighing a packaged system first, see retail inventory management software for small businesses.
How long does it take to build yourself, and what is the risk?
Our estimate for an experienced backend developer adding FEFO to an existing store: 2 to 4 weeks for batch tables, the allocator, pick lists, cancellations, dispatch and returns, with concurrency tests. Migration is the main risk. Existing stock usually has no batch or expiry recorded, and a guessed expiry is worse than none. Plan a physical count that records batch and expiry before switching allocation on.
Regulated goods: pharmacy and pharmaceutical distribution can carry storage, traceability and record-keeping duties set by regulators. RAITHub builds web stores, portals and internal tools; we do not build GxP-validated systems, so regulated pharmaceutical warehousing needs a validated vendor. This is general information; confirm what applies to you with your regulatory adviser.
Why RAITHub for this
- FEFO in production. Sundor Skin, a B2B wholesale platform RAITHub built, holds stock per batch with expiry and allocates first-expiring, first-out, after server-side re-pricing and a credit check. Returns go through authorisation and inspection, and restocked goods re-enter their original batch. It runs on 146 PostgreSQL tables with row-level security and 530+ tests. See the Sundor Skin case study.
- Concurrency is tested. Allocation, dispatch and returns get tests against a real database, gated in CI.
- Role-aware warehouses. Sundor Skin has 12 staff roles, from owner to picker, with 88 permission codes, so a picker sees a pick list, not the price book.
When you don't need us
- Your products do not expire. FIFO, or no batch tracking at all, is fine.
- A packaged inventory app fits your rules. If its FEFO and shelf-life settings match how you sell, configure it rather than build.
- You already track batches and only need the allocator. The code above is a fair start for your own developer.
How RAITHub would build this
- Batch-level stock: receiving with batch and expiry, nightly short-dated and quarantine jobs, and a migration plan for existing stock.
- FEFO allocation inside checkout: minimum remaining shelf life per product, customer tier and channel, with partial-batch splits.
- Warehouse flow: location-sorted pick lists with batch confirmation, reallocation, idempotent packing and dispatch.
- Returns: authorisation, inspection, restock into the original batch or write-off, with an audit trail.
Timeline: adding FEFO to an existing platform is backend work, typically 6 to 12 weeks; as part of a new store MVP it fits our 4 to 6 week fixed-scope range. See API and backend development, the ecommerce industry page, building a marketplace or B2B store and B2B ecommerce platform development.
You receive: automated tests and CI, including concurrency tests for allocation, handover docs and warehouse runbooks, and full IP in your name under NDA.
Next step: book the free 15-minute technical audit with your current stock model and a sample order, and we will follow up with a written fixed quote.
Frequently asked questions
What does FEFO mean in inventory?
First expired, first out: the batch with the earliest expiry date is picked and shipped first, whatever order the batches arrived in.
Is FEFO better than FIFO?
For goods with expiry or best-before dates, yes, because a later delivery can expire sooner than older stock. For goods that do not expire, FIFO is simpler and enough.
When should an order be allocated to a batch?
Usually when the order is placed, so shelf-life rules are checked before you take payment. Large warehouses sometimes reserve quantity at order time and choose the batch at pick time, then repeat the shelf-life check.
What is a minimum remaining shelf life?
The least time a unit must have left before expiry when it ships, stored in days per product, customer tier or channel. Batches below it are not candidates for allocation.
Is SKIP LOCKED safe for stock allocation?
For choosing which batch to draw from, yes, with care. Skipped rows give an incomplete view, so never reject an order on a skip-locked read alone: retry with a waiting lock, and keep a database constraint as the floor.
Where do returned items go in a FEFO system?
Into inspection first. If they pass and still meet the minimum remaining shelf life, back into their original batch; otherwise into quarantine or write-off.
Related posts
Technical SEO Checklist for 2026: The Foundation That Lets You Rank
7 min readLocal and Geo SEO for Service Businesses: Rank Where Your Customers Are
7 min readSaaS Entitlements: Enforcing Plans, Limits and Add-ons in Code
14 min readReady to discuss your project?
Book a free 15-minute technical audit with our engineering team.