Build a Warehouse Pick List from Orders
Every morning a warehouse turns the orders that came in overnight into pick lists: which item to take from which bin, how many, and in what order to walk the aisles. Warehouse management systems do this, but many smaller operations run on a stock spreadsheet and an order export, and the pick lists are put together by hand — slowly, and with stock promised twice when two orders want the last units in a bin. The logic is not complicated, and pandas handles it well. This page builds pick lists from an order file and a bin-level stock file: each order line is allocated to specific bins, oldest stock first, and shortages are recorded rather than allocated; identical items across orders are combined into one pick; the picks are sorted into a snake path through the aisles; and each picker gets a printable Excel sheet with a tick column and a table for sorting the picked items back into orders. It belongs to Building Finance and Operations Reports.
Prerequisites
pip install pandas openpyxl
Two inputs: the open order lines (order, SKU, quantity) and stock by bin, where each bin code is zone–aisle–position — A-02-05 is zone A, aisle 2, position 5 — with the quantity on hand and the date the stock was received. Twelve order lines from six orders are picked today.
Step 1 — Allocate order lines to bins, oldest stock first
import pandas as pd
orders = pd.read_excel("open_orders.xlsx")
stock = pd.read_excel("bin_stock.xlsx", parse_dates=["Received"]).sort_values(["SKU", "Received"])
available = stock.set_index("Bin")["On hand"].to_dict()
allocations, shortages = [], []
for line in orders.itertuples(index=False):
need = line.Qty
for b in stock[stock["SKU"] == line.SKU].itertuples(index=False):
take = min(need, available[b.Bin])
if take > 0:
allocations.append({"Order": line.Order, "SKU": line.SKU, "Bin": b.Bin, "Qty": take})
available[b.Bin] -= take
need -= take
if need == 0:
break
if need:
shortages.append({"Order": line.Order, "SKU": line.SKU, "Ordered": line.Qty, "Short": need})
alloc = pd.DataFrame(allocations)
short = pd.DataFrame(shortages)
assert alloc.groupby("Bin")["Qty"].sum().le(stock.set_index("Bin")["On hand"]).all()
print(alloc.to_string(index=False))
print(short.to_string(index=False))
Each order line takes stock from the bins that hold its SKU, starting with the bin received earliest so older stock leaves first — first in, first out, which matters for anything with a shelf life or a risk of becoming obsolete. A running dictionary of what is still available in each bin is the important part: when order SO-502 asks for ten WID-A, the oldest bin has only six left after SO-501, so it takes those six and the remaining four come from the second bin. That is exactly the double promise a hand-built list gets wrong. When the bins run out before the line is filled, the remainder is recorded as a shortage rather than allocated, and the assertion at the end proves that no bin has been asked for more than it holds. Orders are allocated in the order they appear in the file; sorting the order lines first by promised date or customer priority changes who gets the stock when there is not enough for everyone.
Step 2 — Batch the picks and sort them into a walking path
loc = alloc["Bin"].str.extract(r"(?P<Zone>[A-Z])-(?P<Aisle>\d+)-(?P<Position>\d+)").astype({"Aisle": int, "Position": int})
alloc = pd.concat([alloc, loc], axis=1)
picks = alloc.groupby(["Bin", "Zone", "Aisle", "Position", "SKU"], as_index=False)["Qty"].sum()
aisle_rank = picks[["Zone", "Aisle"]].drop_duplicates().sort_values(["Zone", "Aisle"]).reset_index(drop=True)
aisle_rank["Leg"] = aisle_rank.index
picks = picks.merge(aisle_rank, on=["Zone", "Aisle"])
picks["Walk"] = picks["Position"].where(picks["Leg"] % 2 == 0, -picks["Position"]) # snake: every other aisle backwards
picks = picks.sort_values(["Leg", "Walk"]).reset_index(drop=True)
picks.insert(0, "Stop", picks.index + 1)
print(picks[["Stop", "Bin", "SKU", "Qty"]].to_string(index=False))
Picking order by order means walking past the same bins several times. Batch picking takes every allocation from the same bin together: the picker collects the 300 BOLT-M8 for two orders from bin A-02-05 in one visit, not two, and splits them into orders at the packing bench. Grouping the allocations by bin and SKU does that. The walking order then comes from the bin code. Splitting it into zone, aisle and position allows a snake path: go up the first aisle in increasing position, down the next in decreasing position, up the third, and so on, so the picker never walks back to the start of an aisle. The trick is a sort key that is the position in even legs and the negative position in odd ones.
Batch picking pays off when there are many small orders that share items, which is typical of trade and online orders; for a handful of large orders, picking each one separately avoids the sorting step and is often just as quick.
Step 3 — The put-to-order table and the shortage list
put = alloc.pivot_table(index=["SKU", "Bin"], columns="Order", values="Qty", aggfunc="sum", fill_value=0)
print(put.to_string())
print("units picked:", int(alloc["Qty"].sum()), "| units short:", int(short["Short"].sum()) if len(short) else 0)
After a batch pick, the items have to be sorted back into their orders. A small pivot — SKU and bin down the side, orders across the top — tells the packer how many of each picked item go into each order. It is also a check: each row adds up to the quantity picked from that bin. The shortage list goes to whoever manages customer orders, so they can tell customers, split shipments or chase the supplier before the van leaves. Here two order lines cannot be filled completely: SO-505 gets one of its two pumps because only two were in stock and SO-503 took the first, and SO-504 is three gaskets short after SO-502's four left six of the ten. Of 498 units ordered, 494 are picked.
Step 4 — One printable sheet per picker
from openpyxl import load_workbook
from openpyxl.styles import Alignment, Font
pickers = ["Picker 1", "Picker 2"]
picks["Picker"] = [pickers[0] if leg < 3 else pickers[1] for leg in picks["Leg"]] # zone A to picker 1, the rest to picker 2
path = "pick_lists_2026-10-06.xlsx"
with pd.ExcelWriter(path) as xw:
for name, g in picks.groupby("Picker"):
g.assign(Done="☐")[["Stop", "Bin", "SKU", "Qty", "Done"]].to_excel(xw, sheet_name=name, index=False, startrow=2)
put.reset_index().to_excel(xw, sheet_name="Put to order", index=False)
(short if len(short) else pd.DataFrame(columns=["Order", "SKU", "Ordered", "Short"])).to_excel(xw, sheet_name="Shortages", index=False)
wb = load_workbook(path)
for name in pickers:
ws = wb[name]
ws["A1"] = f"{name} — pick list for Tue 6 Oct 2026"
ws["A1"].font = Font(size=14, bold=True)
for row in ws.iter_rows(min_row=4):
row[4].alignment = Alignment(horizontal="center")
for cell in row:
cell.font = Font(size=13)
ws.column_dimensions["B"].width = ws.column_dimensions["C"].width = 14
ws.page_setup.fitToWidth = 1
ws.sheet_properties.pageSetUpPr.fitToPage = True
ws.print_title_rows = "3:3"
wb.save(path)
print(wb.sheetnames)
Each picker gets a sheet with their stops in walking order, the bin, the SKU, the quantity and an empty box to tick, in a large font because the list is read on a trolley, not at a desk. Splitting by zone is the simplest way to divide the work between two pickers without them crossing paths; splitting by number of stops or by weight works the same way with a different rule. The heading row repeats on every printed page, and fit-to-width keeps each list on the paper. The put-to-order and shortage sheets go to the packing bench and the customer service team. For printing sheets from a scheduled job, see Print an Excel Workbook from Python with win32com.
Common pitfalls
- Allocating against the starting stock. Track what is left in each bin as lines are allocated.
- Over-allocating silently. Record shortages, and assert no bin is overdrawn.
- Picking order by order. Combine identical bin and SKU picks into one stop.
- Sorting bins alphabetically. Use a snake path by aisle so pickers never double back.
- Losing the link to orders. Keep a put-to-order table for sorting the batch at packing.
Conclusion
A pick list is an allocation, a grouping and a sort. Allocate each order line to its SKU's bins oldest first while tracking what is left in each bin, and record what cannot be filled as a shortage. Combine allocations from the same bin into one stop, order the stops in a snake path through zones and aisles, give each picker a printable sheet with tick boxes, and hand the packers a put-to-order table to split the batch back into orders.
Frequently asked questions
How do I allocate stock to orders first in, first out in pandas? Sort the bins for each SKU by received date and take from each in turn, reducing a running record of what is left in every bin.
How do I sort a pick list to minimise walking? Split bin codes into zone, aisle and position, and sort by aisle with the position ascending in one aisle and descending in the next.
What is batch picking? Collecting the combined quantity of an item for several orders in one visit to its bin, then sorting it into orders afterwards.
How do I record stock shortages when allocating orders? When the bins for a SKU are exhausted before the line is filled, store the remaining quantity in a shortage list instead of allocating it.
Related
- Up one level: Building Finance and Operations Reports — finance and operations reports.
- Build an Inventory Reorder Report with Python and Excel — what to replenish.
- Calculate FIFO Inventory Valuation with Python — first in, first out for valuation.
- Build a Purchase Order Tracking Report — chasing the shortages.
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