Repair Fulfillment
Routed to an out-of-stock location
The order looks fine everywhere except the one place it matters: the location it got assigned to has nothing to pick. Staff open it in the warehouse and there is no stock, so it just sits there, OPEN or IN_PROGRESS, going nowhere. Here is why Shopify routes orders this way and a small script that finds the ones stuck on a dead location and moves them to one that can actually ship.
Shopify assigns a fulfillment order to a location at order time using routing rules and whatever inventory data it has then. If the stock at that location was never really there, or a count changed right after, the order is stuck on a location with nothing to pick. Run a small Python or Node.js script that lists the fulfillment orders assigned to the problem location, asks Shopify with locationsForMove which other locations could cover every remaining line item, and calls fulfillmentOrderMove to reassign only the ones with a full match. Full code, tests, and a dry run guard are below.
The problem in plain words
When an order comes in, Shopify decides which location should fulfill each line item. It looks at your location priority, shipping zones, and the inventory it believes each location has, then assigns a fulfillment order to whichever location wins that decision.
That decision is only as good as the inventory data behind it. A location can be marked as stocking an item when the shelf is actually empty, a manual count can lag what really happened in the warehouse, or a routing rule can send every order from a region to one location regardless of what is on hand there. The fulfillment order gets created anyway, sitting at OPEN or IN_PROGRESS, and no one notices until a picker opens it and finds nothing to grab.
Why it happens
Shopify's fulfillment order routing trusts the inventory numbers it has at the moment the order is placed. A few common ways that trust breaks down:
- A location-specific inventory count drifts from the real shelf, so Shopify believes stock exists there when it does not.
- A custom routing rule or app sends orders to a preferred location by shipping zone or priority, without checking real-time availability first.
- Inventory was moved between locations in the backend, but the transfer or the count was never reflected before the next order came in.
- A location was deactivated or unstocked for a product line, but old fulfillment orders assigned before the change are still sitting there.
This is a common source of confusion. Store owners see the order sitting as OPEN and assume it is a shipping delay, not a routing problem, so it can sit for days before anyone checks the location itself. Shopify does let staff move a fulfillment order by hand in the admin, but doing that one order at a time does not scale once several orders land on the same dead location, and it is easy to move an order to a place that only has half the items in stock. See the citations at the end for the exact docs.
Moving a fulfillment order is not just "send it somewhere else." A partial move can split one order into two fulfillment orders shipping from two places, which usually is not what a merchant wants for a small order. So the safe pattern is to only move an order to a location that Shopify itself confirms, through locationsForMove, can cover every remaining line item in one go. If nothing qualifies, we leave the order alone for a human to check.
The fix, as a flow
We do not touch inventory counts or routing rules. We add a job that lists the fulfillment orders assigned to a known problem location, asks Shopify which other locations could take the work, keeps only the locations that can cover every line item still owed, and calls the same move Shopify's admin uses. Anything without a full match is skipped for a human to look at.
Build it step by step
Get an Admin API access token
Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_assigned_fulfillment_orders, read_merchant_managed_fulfillment_orders, and write_merchant_managed_fulfillment_orders scopes, then install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.
pip install requests
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export OUT_OF_STOCK_LOCATION_ID="gid://shopify/Location/123456789"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export OUT_OF_STOCK_LOCATION_ID="gid://shopify/Location/123456789"
export DRY_RUN="true" // start safe, change to false to write
Talk to the Admin GraphQL API
Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to read fulfillment orders and to run the move.
import os, requests
SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
def gql(query, variables=None):
r = requests.post(
ENDPOINT,
json={"query": query, "variables": variables or {}},
headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
async function gql(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Shopify ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
List the fulfillment orders stuck at the problem location
Ask the location for its fulfillment orders, and for each one read its status, its remaining line items, and the candidate locations from locationsForMove, including which line items each candidate can actually cover. We page through with a cursor so the job handles a large backlog.
FULFILLMENT_ORDERS_QUERY = """
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
fulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lineItems(first: 50) { nodes { id remainingQuantity } }
locationsForMove(first: 10) {
nodes {
location { id name }
message
availableLineItems(first: 50) { nodes { id remainingQuantity } }
}
}
}
}
}
}"""
def stuck_fulfillment_orders():
cursor = None
while True:
data = gql(FULFILLMENT_ORDERS_QUERY, {"cursor": cursor, "locationId": PROBLEM_LOCATION_ID})
page = data["location"]["fulfillmentOrders"]
for node in page["nodes"]:
yield node
if not page["pageInfo"]["hasNextPage"]:
return
cursor = page["pageInfo"]["endCursor"]
const FULFILLMENT_ORDERS_QUERY = `
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
fulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lineItems(first: 50) { nodes { id remainingQuantity } }
locationsForMove(first: 10) {
nodes {
location { id name }
message
availableLineItems(first: 50) { nodes { id remainingQuantity } }
}
}
}
}
}
}`;
async function* stuckFulfillmentOrders() {
let cursor = null;
while (true) {
const data = await gql(FULFILLMENT_ORDERS_QUERY, { cursor, locationId: PROBLEM_LOCATION_ID });
const page = data.location.fulfillmentOrders;
for (const node of page.nodes) yield node;
if (!page.pageInfo.hasNextPage) return;
cursor = page.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a fulfillment order and returns a location id to move to, or nothing. A pure function like this is easy to read and easy to test, which we do later. The rule is strict on purpose. The order must still be movable, it must have quantity left to fulfill, and a candidate location must be able to cover every remaining unit, not just some of them. Among locations that qualify, we pick the one with the most coverage. If nothing qualifies, we leave the order alone.
MOVABLE_STATUSES = {"OPEN", "IN_PROGRESS"}
def total_remaining(line_items):
return sum(item.get("remainingQuantity", 0) for item in line_items or [])
def pick_reroute_location(fulfillment_order):
if fulfillment_order.get("status") not in MOVABLE_STATUSES:
return None
needed = total_remaining(fulfillment_order.get("lineItems", {}).get("nodes"))
if needed <= 0:
return None
best_id = None
best_covered = -1
for candidate in fulfillment_order.get("locationsForMove", {}).get("nodes", []):
covered = total_remaining(candidate.get("availableLineItems", {}).get("nodes"))
if covered < needed:
continue
if covered > best_covered:
best_covered = covered
best_id = candidate["location"]["id"]
return best_id
const MOVABLE_STATUSES = new Set(["OPEN", "IN_PROGRESS"]);
export function totalRemaining(lineItems) {
return (lineItems || []).reduce((sum, item) => sum + (item.remainingQuantity || 0), 0);
}
export function pickRerouteLocation(fulfillmentOrder) {
if (!MOVABLE_STATUSES.has(fulfillmentOrder.status)) return null;
const needed = totalRemaining(fulfillmentOrder.lineItems?.nodes);
if (needed <= 0) return null;
let bestId = null;
let bestCovered = -1;
for (const candidate of fulfillmentOrder.locationsForMove?.nodes || []) {
const covered = totalRemaining(candidate.availableLineItems?.nodes);
if (covered < needed) continue;
if (covered > bestCovered) {
bestCovered = covered;
bestId = candidate.location.id;
}
}
return bestId;
}
Move the fulfillment order the way the Admin UI would
When a candidate qualifies, call the fulfillmentOrderMove mutation with the fulfillment order id and the new location id. Shopify reassigns the eligible line items to the new location. Always read back userErrors. If Shopify refuses the move, for example because progress was already reported by hand, the error tells you why, and the script should stop on it rather than pretend it worked.
MOVE_MUTATION = """
mutation($id: ID!, $newLocationId: ID!) {
fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
movedFulfillmentOrder { id status }
userErrors { field message }
}
}"""
def move_fulfillment_order(fulfillment_order_id, new_location_id):
result = gql(MOVE_MUTATION, {"id": fulfillment_order_id, "newLocationId": new_location_id})["fulfillmentOrderMove"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["movedFulfillmentOrder"]["status"]
const MOVE_MUTATION = `
mutation($id: ID!, $newLocationId: ID!) {
fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
movedFulfillmentOrder { id status }
userErrors { field message }
}
}`;
async function moveFulfillmentOrder(fulfillmentOrderId, newLocationId) {
const result = (await gql(MOVE_MUTATION, { id: fulfillmentOrderId, newLocationId })).fulfillmentOrderMove;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.movedFulfillmentOrder.status;
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which fulfillment orders it would move and where. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how fast new orders land on the problem location, for example every fifteen minutes.
Always start with DRY_RUN=true, and only move an order when a candidate location can cover every remaining line item. A partial move can split one order into two shipments from two places, so the strict full-coverage rule is what keeps this safe to run unattended.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only moves fulfillment orders when Shopify itself confirms a location can cover the entire remaining order.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Move Shopify fulfillment orders off a location that cannot stock them.
An order can land on a location that shows OPEN or IN_PROGRESS but has no
usable inventory for one or more line items there, often because a location
rule or the customer's address routed it there before a stock count caught
up. Shopify will not fulfill from a location with nothing to pick, so the
order stalls. This lists fulfillment orders assigned to a "problem" location,
asks Shopify which other locations could take the line items with
locationsForMove, picks the best candidate in pure code, and calls
fulfillmentOrderMove to reassign it. Read only apart from the move.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reroute_out_of_stock_fulfillment")
SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
PROBLEM_LOCATION_ID = os.environ["OUT_OF_STOCK_LOCATION_ID"]
MOVABLE_STATUSES = {"OPEN", "IN_PROGRESS"}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
FULFILLMENT_ORDERS_QUERY = """
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
fulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lineItems(first: 50) { nodes { id remainingQuantity } }
locationsForMove(first: 10) {
nodes {
location { id name }
message
availableLineItems(first: 50) { nodes { id remainingQuantity } }
}
}
}
}
}
}"""
MOVE_MUTATION = """
mutation($id: ID!, $newLocationId: ID!) {
fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
movedFulfillmentOrder { id status }
userErrors { field message }
}
}"""
def gql(query, variables=None):
r = requests.post(
ENDPOINT,
json={"query": query, "variables": variables or {}},
headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def total_remaining(line_items):
return sum(item.get("remainingQuantity", 0) for item in line_items or [])
def pick_reroute_location(fulfillment_order):
"""Pure decision: which location (if any) should this fulfillment order move to.
Returns a candidate location id, or None if the order should be left alone.
The order is only a candidate when it is still movable (OPEN or IN_PROGRESS)
and it has unfulfilled line items. Among the locations Shopify reports with
locationsForMove, we keep only the ones that can cover every remaining line
item (no partial moves that would split the order in two), and we pick the
one that covers the most remaining quantity so the busiest order clears
first when several locations qualify.
"""
if fulfillment_order.get("status") not in MOVABLE_STATUSES:
return None
needed = total_remaining(fulfillment_order.get("lineItems", {}).get("nodes"))
if needed <= 0:
return None
best_id = None
best_covered = -1
for candidate in fulfillment_order.get("locationsForMove", {}).get("nodes", []):
covered = total_remaining(candidate.get("availableLineItems", {}).get("nodes"))
if covered < needed:
continue
if covered > best_covered:
best_covered = covered
best_id = candidate["location"]["id"]
return best_id
def stuck_fulfillment_orders():
cursor = None
while True:
data = gql(FULFILLMENT_ORDERS_QUERY, {"cursor": cursor, "locationId": PROBLEM_LOCATION_ID})
location = data.get("location")
if location is None:
return
page = location["fulfillmentOrders"]
for node in page["nodes"]:
yield node
if not page["pageInfo"]["hasNextPage"]:
return
cursor = page["pageInfo"]["endCursor"]
def move_fulfillment_order(fulfillment_order_id, new_location_id):
result = gql(MOVE_MUTATION, {"id": fulfillment_order_id, "newLocationId": new_location_id})["fulfillmentOrderMove"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["movedFulfillmentOrder"]["status"]
def run():
moved = 0
for fulfillment_order in stuck_fulfillment_orders():
target = pick_reroute_location(fulfillment_order)
if target is None:
continue
log.info(
"Fulfillment order %s can move to %s. %s",
fulfillment_order["id"], target, "would move" if DRY_RUN else "moving",
)
if not DRY_RUN:
move_fulfillment_order(fulfillment_order["id"], target)
moved += 1
log.info("Done. %d fulfillment order(s) %s.", moved, "to move" if DRY_RUN else "moved")
if __name__ == "__main__":
run()
/**
* Move Shopify fulfillment orders off a location that cannot stock them.
*
* An order can land on a location that shows OPEN or IN_PROGRESS but has no
* usable inventory for one or more line items there, often because a location
* rule or the customer's address routed it there before a stock count caught
* up. Shopify will not fulfill from a location with nothing to pick, so the
* order stalls. This lists fulfillment orders assigned to a "problem"
* location, asks Shopify which other locations could take the line items
* with locationsForMove, picks the best candidate in pure code, and calls
* fulfillmentOrderMove to reassign it. Run on a schedule.
*/
import { pathToFileURL } from "node:url";
const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const PROBLEM_LOCATION_ID = process.env.OUT_OF_STOCK_LOCATION_ID || "gid://shopify/Location/1";
const MOVABLE_STATUSES = new Set(["OPEN", "IN_PROGRESS"]);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function totalRemaining(lineItems) {
return (lineItems || []).reduce((sum, item) => sum + (item.remainingQuantity || 0), 0);
}
export function pickRerouteLocation(fulfillmentOrder) {
if (!MOVABLE_STATUSES.has(fulfillmentOrder.status)) return null;
const needed = totalRemaining(fulfillmentOrder.lineItems?.nodes);
if (needed <= 0) return null;
let bestId = null;
let bestCovered = -1;
for (const candidate of fulfillmentOrder.locationsForMove?.nodes || []) {
const covered = totalRemaining(candidate.availableLineItems?.nodes);
if (covered < needed) continue;
if (covered > bestCovered) {
bestCovered = covered;
bestId = candidate.location.id;
}
}
return bestId;
}
async function gql(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Shopify ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const FULFILLMENT_ORDERS_QUERY = `
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
fulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lineItems(first: 50) { nodes { id remainingQuantity } }
locationsForMove(first: 10) {
nodes {
location { id name }
message
availableLineItems(first: 50) { nodes { id remainingQuantity } }
}
}
}
}
}
}`;
const MOVE_MUTATION = `
mutation($id: ID!, $newLocationId: ID!) {
fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
movedFulfillmentOrder { id status }
userErrors { field message }
}
}`;
async function* stuckFulfillmentOrders() {
let cursor = null;
while (true) {
const data = await gql(FULFILLMENT_ORDERS_QUERY, { cursor, locationId: PROBLEM_LOCATION_ID });
const location = data.location;
if (!location) return;
const page = location.fulfillmentOrders;
for (const node of page.nodes) yield node;
if (!page.pageInfo.hasNextPage) return;
cursor = page.pageInfo.endCursor;
}
}
async function moveFulfillmentOrder(fulfillmentOrderId, newLocationId) {
const result = (await gql(MOVE_MUTATION, { id: fulfillmentOrderId, newLocationId })).fulfillmentOrderMove;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.movedFulfillmentOrder.status;
}
export async function run() {
let moved = 0;
for await (const fulfillmentOrder of stuckFulfillmentOrders()) {
const target = pickRerouteLocation(fulfillmentOrder);
if (!target) continue;
console.log(`Fulfillment order ${fulfillmentOrder.id} can move to ${target}. ${DRY_RUN ? "would move" : "moving"}`);
if (!DRY_RUN) await moveFulfillmentOrder(fulfillmentOrder.id, target);
moved++;
}
console.log(`Done. ${moved} fulfillment order(s) ${DRY_RUN ? "to move" : "moved"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a real order gets reassigned to a real location. Because we kept pick_reroute_location pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from reroute_out_of_stock_fulfillment import pick_reroute_location, total_remaining
def line_item(remaining=1, item_id="gid://shopify/FulfillmentOrderLineItem/1"):
return {"id": item_id, "remainingQuantity": remaining}
def candidate(location_id, covered_items, name="Warehouse B"):
return {
"location": {"id": location_id, "name": name},
"message": None,
"availableLineItems": {"nodes": covered_items},
}
def fulfillment_order(status="OPEN", line_items=None, candidates=None):
return {
"id": "gid://shopify/FulfillmentOrder/1",
"status": status,
"lineItems": {"nodes": line_items if line_items is not None else [line_item(2)]},
"locationsForMove": {"nodes": candidates or []},
}
def test_skips_orders_that_are_not_movable():
order = fulfillment_order(status="CLOSED", candidates=[candidate("gid://shopify/Location/2", [line_item(2)])])
assert pick_reroute_location(order) is None
def test_skips_orders_with_nothing_left_to_fulfill():
order = fulfillment_order(line_items=[line_item(0)], candidates=[candidate("gid://shopify/Location/2", [line_item(0)])])
assert pick_reroute_location(order) is None
def test_skips_when_no_candidate_covers_all_remaining_quantity():
order = fulfillment_order(line_items=[line_item(5)], candidates=[candidate("gid://shopify/Location/2", [line_item(3)])])
assert pick_reroute_location(order) is None
def test_picks_the_candidate_with_the_most_coverage_when_several_qualify():
order = fulfillment_order(
line_items=[line_item(2)],
candidates=[
candidate("gid://shopify/Location/2", [line_item(2)]),
candidate("gid://shopify/Location/3", [line_item(9)]),
],
)
assert pick_reroute_location(order) == "gid://shopify/Location/3"
import { test } from "node:test";
import assert from "node:assert/strict";
import { totalRemaining, pickRerouteLocation } from "./reroute-out-of-stock-fulfillment.js";
const lineItem = (remaining = 1, id = "gid://shopify/FulfillmentOrderLineItem/1") => ({ id, remainingQuantity: remaining });
const candidate = (locationId, coveredItems, name = "Warehouse B") => ({
location: { id: locationId, name },
message: null,
availableLineItems: { nodes: coveredItems },
});
const fulfillmentOrder = ({ status = "OPEN", lineItems = [lineItem(2)], candidates = [] } = {}) => ({
id: "gid://shopify/FulfillmentOrder/1",
status,
lineItems: { nodes: lineItems },
locationsForMove: { nodes: candidates },
});
test("skips orders that are not movable", () => {
const order = fulfillmentOrder({ status: "CLOSED", candidates: [candidate("gid://shopify/Location/2", [lineItem(2)])] });
assert.equal(pickRerouteLocation(order), null);
});
test("skips when no candidate covers all remaining quantity", () => {
const order = fulfillmentOrder({ lineItems: [lineItem(5)], candidates: [candidate("gid://shopify/Location/2", [lineItem(3)])] });
assert.equal(pickRerouteLocation(order), null);
});
test("picks the candidate with the most coverage when several qualify", () => {
const order = fulfillmentOrder({
lineItems: [lineItem(2)],
candidates: [
candidate("gid://shopify/Location/2", [lineItem(2)]),
candidate("gid://shopify/Location/3", [lineItem(9)]),
],
});
assert.equal(pickRerouteLocation(order), "gid://shopify/Location/3");
});
Case studies
The rule sent every order to a warehouse with no stock
A furniture brand set a routing rule that sent all orders from one state to its closest warehouse, to save on shipping. That warehouse ran out of a popular chair for two weeks, but the rule kept assigning orders there anyway, because it never checked live availability.
The job caught the backlog on its first run in dry run, showed exactly which orders could move to the main distribution center that still had stock, and after a quick review the team let it run for real. Every stuck order shipped the same day, and they added an alert for the next time a location runs dry.
A manual count never matched the shelf
A small retailer with two stores fulfilled online orders from whichever store had stock on paper. One store's count had drifted high for months, so it kept getting assigned orders that its shelves could not fill, and staff resorted to fulfilling them by hand from the other store every time.
Once the script started running hourly, it moved the misassigned orders to the store that could actually cover them, using the same full-coverage rule described above. No orders split into partial shipments, and the manual fixing stopped.
After this runs on a schedule, an order routed to a dead location is a quick reassignment away from actually shipping, without anyone manually clicking through the admin. The move only happens when a location can cover the whole order, so nothing splits into surprise partial shipments. Keep an eye on the ones the script skips, since those usually point at a location that needs a real stock count.
FAQ
Why did my Shopify order get assigned to a location with no stock?
Shopify assigns a fulfillment order to a location using routing rules and inventory data at the moment the order is placed. If a stock count changes right after, or a routing rule ignores real availability, the order can end up assigned to a location that has nothing to pick, and it will not fulfill from there.
Is it safe to move a fulfillment order to another location automatically?
Yes, when the script only moves an order to a location that Shopify itself reports through locationsForMove as able to cover every remaining line item. That keeps the move from splitting the order into two partial shipments, and it runs in dry run first so you see the plan before anything changes.
What does locationsForMove tell you about a fulfillment order?
locationsForMove lists the locations a fulfillment order could move to and, for each one, which of its line items that location can actually cover. Comparing that coverage against what the order still needs is how the script decides whether a move is safe.
Related field notes
Citations
On the problem:
- Shopify Help Center: how fulfillment order routing assigns a location to each order. help.shopify.com/en/manual/fulfillment/managing-orders/assigning-fulfillment-locations
- Shopify Help Center: managing inventory across multiple locations. help.shopify.com/en/manual/products/inventory/managing-inventory/multiple-locations
- Shopify Community: fulfillment orders stuck at a location with no available inventory. community.shopify.com shopify apis and sdks
On the solution:
- Shopify Admin GraphQL: the
fulfillmentOrderMovemutation. shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentOrderMove - Shopify Admin GraphQL: the
FulfillmentOrderobject, includinglocationsForMoveand line items. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrder - Shopify Admin GraphQL: the
FulfillmentOrderLocationForMoveobject. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrderLocationForMove
Stuck on a tricky one?
If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this get your orders moving again?
If this saved you a pile of manual reassignments or a warehouse full of confused pickers, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi