Back to BlogArchitecture & Engineering

FEFO Fulfilment: Shipping Short-Dated Stock First

Rupak Amin

Founder & Lead Engineer, RAITHub

14 min read

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.

RulePicks firstFitsFails when
FIFOThe batch received earliestGoods 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
FEFOThe batch that expires earliestCosmetics, food, supplements, pharmacy retail: anything with a printed expiry or best-before dateBatches are recorded without expiry dates, or pickers grab whatever is nearest
LIFOThe batch received lastRarely in ecommerce; bulk goods where the newest stock is on topAnything 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 atStrengthWeaknessUse it when
Order timeThe order is promised specific batches; shelf-life rules are checked before you take moneyA batch damaged in the warehouse later needs a reallocationRetail and B2B orders with expiry rules, and anywhere a customer can see or demand the expiry
Pick timeUses the freshest view of the shelf; less reallocationYou took an order you may not be able to fill with acceptable lifeHigh-volume, same-day picking of goods with long shelf lives
Both: reserve the quantity at order time, choose the batch at pick timeNo overselling, flexible pickingThe shelf-life check happens late, so it has to be repeated at pickLarge 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 SELECT with ORDER BY at Read Committed can return rows out of order if they changed while it waited (PostgreSQL: SELECT). That is why planFefo sorts 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_allocated in one transaction.
  • Dispatch: subtract the quantity from both qty_on_hand and qty_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?

OptionTypical costChoose this whenThe catch
Off-the-shelf inventory or ERP: Zoho Inventory, OdooZoho 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 settingsShelf-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 spreadsheetShopify from $19 a month billed yearly (Shopify pricing), plus any appLow volume and a few long-life SKUs; one person picks and knows the shelfFEFO depends on the picker's memory; check that your plan and app really track lots and expiry before relying on them
Custom buildA fixed quote for your scopeFEFO has to work with your own pricing, credit, channels and returns rules, inside your order transactionYou 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.

fefo inventoryfefo vs fifofirst expired first outbatch allocationexpiry date stockwarehouse pick list

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.