Skip to content

Diagnostic Shipping & Warehouses

Warehouse cannot join a shipping zone without a shared channel

The warehouse is right there in the shipping zone's warehouse list. It has stock. It should be able to fulfill orders for that zone. But orders in that zone quietly stop pulling from it, and nobody gets an error, because the warehouse and the zone no longer share a single channel. Here is why Saleor ties warehouses to zones through channels, how that link can silently break after the fact, and a script that finds every zone where it already has.

Python and Node.js Saleor GraphQL API Report only (dry run)
A forklift loading a truck
Photo by metin erkut bayrak on Unsplash
The short answer

In Saleor, a shipping zone's usable warehouses must be a subset of the warehouses assigned to the zone's channels. A warehouse can only fulfill a zone if it shares at least one channel with that zone. Because channels, warehouses, and zones are managed through three separate mutations, an admin can unassign a channel from a warehouse, or remove a channel from a zone, after the warehouse-zone link was already made. Saleor does not revalidate that link afterward, so the warehouse stays listed on the zone with zero shared channels, and its stock quietly drops out of that zone's fulfillment. Run a Python or Node.js script that queries every shipping zone with its channels and warehouses, and every channel with its warehouses, builds a warehouse-to-channels map, and flags any warehouse-zone pair with an empty channel intersection. Full code, tests, and a dry run guard are below.

The problem in plain words

A ShippingZone in Saleor is only allowed to use warehouses that make sense for it, and "makes sense" is defined narrowly: the warehouse has to be assigned to at least one channel that the zone is also assigned to. That is the rule shippingZoneUpdate enforces the moment you call addWarehouses. If the warehouse and the zone do not share a channel yet, Saleor rejects the field outright with an INVALID error, rather than silently accepting a link it cannot use.

The trouble is that channels, warehouses, and zones each get edited through their own mutation, on their own screen, often by different people at different times. channelUpdate can add or remove warehouses from a channel. shippingZoneUpdate can add or remove channels and warehouses from a zone. Nothing forces those three edits to happen together, and nothing checks back later. So a warehouse-zone pairing that was valid the day it was created can become invalid the day someone removes a channel from the warehouse, or removes a channel from the zone, for a completely unrelated reason. The link itself is never touched. It just stops meaning anything.

Warehouse + zone share channel A addWarehouses link accepted Channel A removed from warehouse or zone no revalidation Link still exists, 0 shared channels Stock left out, silent
The warehouse-zone link is never edited or removed, so nothing looks wrong on the zone. But a later channel change on either side leaves them sharing zero channels, and the stock quietly stops counting for that zone.

Why it happens

Saleor models warehouses, channels, and shipping zones as three separate objects, each with its own mutation, and the shared-channel rule is only checked at write time, not continuously. A few common ways stores end up with an orphaned pair:

The result is the same either way: a warehouse still appears in shippingZone.warehouses, but it shares zero channels with that zone, so it cannot actually serve it. Nothing about the zone's configuration looks broken at a glance, because the link is still there. See the citations at the end for the exact issue and docs.

The key insight

A warehouse assigned to a zone and a warehouse able to fulfill that zone are not the same thing in Saleor. The zone's warehouses field only remembers that a link was made, not whether it still makes sense. The shared-channel rule is enforced once, at the moment you add the warehouse, and never again. So the only reliable way to find a broken pairing is to recompute the intersection yourself: for each warehouse in a zone, does it still share a channel with that zone right now.

The fix, as a flow

We do not touch the zone or the warehouse. We query every shipping zone with its channels and warehouses, and every channel with its warehouses, in one round trip. We build a warehouse-to-channels map from the channel side, since Warehouse has no direct channels field, then intersect each zone's channel slugs against each of its warehouses' channel slugs. An empty intersection is an orphaned link, and we only ever report it.

Query zones and channels Build warehouse to channel-set map findOrphaned WarehouseZoneLinks Shares a channel? yes, fine no, orphaned Report the orphaned pair
Every zone-warehouse pair is recomputed from the channel data, not trusted from the stored link. An empty intersection is reported, never auto-fixed.

Build it step by step

1

Get an app token with shipping and channel scopes

Create an app in the Saleor dashboard, or use tokenCreate with staff credentials, and grant it permission to manage shipping and read channels and warehouses. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   # start safe, change to false only to allow --repair to detach orphaned pairs
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false only to allow --repair to detach orphaned pairs
2

Talk to the Saleor GraphQL endpoint

Every call goes to the single GraphQL endpoint with your token in the Authorization: Bearer header. A small helper sends a query and returns the data, and raises if Saleor reports an error.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {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"]
step2.js
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

Fetch shipping zones and channels together

Query every shipping zone with its channels and warehouses, and every channel with its warehouses, in one call. Warehouse has no direct channels field in the Saleor schema, so the reverse relation on Channel.warehouses is how we learn which channels each warehouse actually belongs to.

step3.py
ZONES_AND_CHANNELS_QUERY = """
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id slug }
        warehouses { id name }
      }
    }
  }
  channels(first: 100) {
    edges {
      node {
        id slug
        warehouses(first: 100) { edges { node { id } } }
      }
    }
  }
}"""

def fetch_zones_and_channels():
    data = gql(ZONES_AND_CHANNELS_QUERY)
    zones = [e["node"] for e in data["shippingZones"]["edges"]]
    channels = [e["node"] for e in data["channels"]["edges"]]
    return zones, channels
step3.js
const ZONES_AND_CHANNELS_QUERY = `
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id slug }
        warehouses { id name }
      }
    }
  }
  channels(first: 100) {
    edges {
      node {
        id slug
        warehouses(first: 100) { edges { node { id } } }
      }
    }
  }
}`;

async function fetchZonesAndChannels() {
  const data = await gql(ZONES_AND_CHANNELS_QUERY);
  const zones = data.shippingZones.edges.map((e) => e.node);
  const channels = data.channels.edges.map((e) => e.node);
  return { zones, channels };
}
4

Build the warehouse-to-channels map

Walk the channels query once and invert it: for each channel, for each warehouse under it, add that channel's slug to a set keyed by the warehouse id. This map is the only place we learn which channels a warehouse belongs to, since the query for shipping zones only gives us warehouse id and name.

buildmap.py
def build_warehouse_channel_map(channels):
    warehouse_channel_map = {}
    for channel in channels:
        slug = channel["slug"]
        for edge in channel.get("warehouses", {}).get("edges", []):
            wid = edge["node"]["id"]
            warehouse_channel_map.setdefault(wid, set()).add(slug)
    return warehouse_channel_map
buildmap.js
function buildWarehouseChannelMap(channels) {
  const warehouseChannelMap = new Map();
  for (const channel of channels) {
    const slug = channel.slug;
    for (const edge of channel.warehouses?.edges || []) {
      const wid = edge.node.id;
      if (!warehouseChannelMap.has(wid)) warehouseChannelMap.set(wid, new Set());
      warehouseChannelMap.get(wid).add(slug);
    }
  }
  return warehouseChannelMap;
}
5

Decide, with one pure function

Keep the decision in its own function that takes the shipping zones already fetched and the warehouse-to-channels map already built, and returns the orphaned pairs. A pure function like this is easy to read and easy to test, which we do later. For each zone, compute its channel slugs. For each warehouse in that zone, look up its channel slugs from the map, defaulting to an empty set if the warehouse is not in the map at all. If the intersection of the two sets is empty, the pair is orphaned.

decide.py
def find_orphaned_warehouse_zone_links(shipping_zones, warehouse_channel_map):
    orphaned = []
    for zone in shipping_zones:
        zone_channel_slugs = {c["slug"] for c in zone.get("channels", [])}
        for warehouse in zone.get("warehouses", []):
            warehouse_channel_slugs = warehouse_channel_map.get(warehouse["id"], set())
            if not (zone_channel_slugs & warehouse_channel_slugs):
                orphaned.append({
                    "zoneId": zone["id"],
                    "zoneName": zone["name"],
                    "warehouseId": warehouse["id"],
                    "warehouseName": warehouse["name"],
                    "zoneChannelSlugs": sorted(zone_channel_slugs),
                })
    return orphaned
decide.js
export function findOrphanedWarehouseZoneLinks(shippingZones, warehouseChannelMap) {
  const orphaned = [];
  for (const zone of shippingZones) {
    const zoneChannelSlugs = new Set((zone.channels || []).map((c) => c.slug));
    for (const warehouse of zone.warehouses || []) {
      const warehouseChannelSlugs = warehouseChannelMap.get(warehouse.id) || new Set();
      const sharesChannel = [...zoneChannelSlugs].some((slug) => warehouseChannelSlugs.has(slug));
      if (!sharesChannel) {
        orphaned.push({
          zoneId: zone.id,
          zoneName: zone.name,
          warehouseId: warehouse.id,
          warehouseName: warehouse.name,
          zoneChannelSlugs: [...zoneChannelSlugs].sort(),
        });
      }
    }
  }
  return orphaned;
}
6

Report first, repair only on request

The default action is to print every orphaned pair with the zone, the warehouse, and the zone's current channel slugs, so a human can decide whether to add a shared channel back or detach the warehouse from the zone. That choice needs merchant intent, which the script cannot know, so it never writes by default. An optional --repair flag calls shippingZoneUpdate with removeWarehouses to unblock the zone's fulfillment logic by detaching the pair. It never calls addChannels or addWarehouses on its own, since attaching a channel is a decision only a human should make.

Run it safe

Always start with DRY_RUN=true and read the report before deciding anything. The two possible fixes point in opposite directions: adding a shared channel with channelUpdate(addWarehouses: ...) or shippingZoneUpdate(addChannels: ...) keeps the warehouse serving the zone, while shippingZoneUpdate(removeWarehouses: ...) accepts that it should not. Only a human who knows why the channel was removed can pick correctly.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only writes when --repair is passed explicitly, and even then only to detach an orphaned pair, never to attach a new channel.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
find_orphaned_warehouse_zone_links.py
"""Find Saleor shipping zones whose warehouses share no channel with the zone.

A ShippingZone can only use a warehouse for fulfillment when that warehouse shares
at least one channel with the zone. shippingZoneUpdate(addWarehouses: ...) enforces
this at write time and rejects the field with INVALID if the shared channel is
missing (see saleor/saleor issue #17029). But nothing revalidates the link later:
if a channel is removed from the warehouse or from the zone afterward, the warehouse
stays listed on the zone with zero shared channels, and its stock silently drops out
of that zone's fulfillment. This queries every shipping zone with its channels and
warehouses, and every channel with its warehouses, builds a warehouse-to-channels
map, and reports every zone-warehouse pair whose channel intersection is empty.
It never writes by default. An optional --repair flag detaches the orphaned pair
with shippingZoneUpdate(removeWarehouses: ...); attaching a new shared channel is
left to a human, since that depends on merchant intent.
"""
import os
import sys
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_orphaned_warehouse_zone_links")

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ZONES_AND_CHANNELS_QUERY = """
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id slug }
        warehouses { id name }
      }
    }
  }
  channels(first: 100) {
    edges {
      node {
        id slug
        warehouses(first: 100) { edges { node { id } } }
      }
    }
  }
}"""

REMOVE_WAREHOUSES_MUTATION = """
mutation($id: ID!, $warehouseIds: [ID!]!) {
  shippingZoneUpdate(id: $id, input: { removeWarehouses: $warehouseIds }) {
    shippingZone { id }
    shippingErrors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {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 build_warehouse_channel_map(channels):
    warehouse_channel_map = {}
    for channel in channels:
        slug = channel["slug"]
        for edge in channel.get("warehouses", {}).get("edges", []):
            wid = edge["node"]["id"]
            warehouse_channel_map.setdefault(wid, set()).add(slug)
    return warehouse_channel_map


def find_orphaned_warehouse_zone_links(shipping_zones, warehouse_channel_map):
    orphaned = []
    for zone in shipping_zones:
        zone_channel_slugs = {c["slug"] for c in zone.get("channels", [])}
        for warehouse in zone.get("warehouses", []):
            warehouse_channel_slugs = warehouse_channel_map.get(warehouse["id"], set())
            if not (zone_channel_slugs & warehouse_channel_slugs):
                orphaned.append({
                    "zoneId": zone["id"],
                    "zoneName": zone["name"],
                    "warehouseId": warehouse["id"],
                    "warehouseName": warehouse["name"],
                    "zoneChannelSlugs": sorted(zone_channel_slugs),
                })
    return orphaned


def fetch_zones_and_channels():
    data = gql(ZONES_AND_CHANNELS_QUERY)
    zones = [e["node"] for e in data["shippingZones"]["edges"]]
    channels = [e["node"] for e in data["channels"]["edges"]]
    return zones, channels


def detach_warehouse(zone_id, warehouse_id):
    result = gql(REMOVE_WAREHOUSES_MUTATION, {"id": zone_id, "warehouseIds": [warehouse_id]})["shippingZoneUpdate"]
    if result["shippingErrors"]:
        raise RuntimeError(result["shippingErrors"])


def run():
    repair = "--repair" in sys.argv

    zones, channels = fetch_zones_and_channels()
    warehouse_channel_map = build_warehouse_channel_map(channels)
    orphaned = find_orphaned_warehouse_zone_links(zones, warehouse_channel_map)

    if not orphaned:
        log.info("Every zone's warehouses share at least one channel with the zone.")
        return

    for pair in orphaned:
        log.warning(
            "Zone %s has warehouse %s with no shared channel (zone channels: %s)",
            pair["zoneName"], pair["warehouseName"], pair["zoneChannelSlugs"],
        )
        if repair:
            log.info("%s remove warehouse %s from zone %s",
                      "Would" if DRY_RUN else "Will", pair["warehouseName"], pair["zoneName"])
            if not DRY_RUN:
                detach_warehouse(pair["zoneId"], pair["warehouseId"])
        else:
            log.info("Add a shared channel or rerun with --repair to detach. Not modified.")

    log.info("Done. %d orphaned warehouse-zone pair(s) found.", len(orphaned))


if __name__ == "__main__":
    run()
find-orphaned-warehouse-zone-links.js
/**
 * Find Saleor shipping zones whose warehouses share no channel with the zone.
 *
 * A ShippingZone can only use a warehouse for fulfillment when that warehouse shares
 * at least one channel with the zone. shippingZoneUpdate(addWarehouses: ...) enforces
 * this at write time and rejects the field with INVALID if the shared channel is
 * missing (see saleor/saleor issue #17029). But nothing revalidates the link later:
 * if a channel is removed from the warehouse or from the zone afterward, the warehouse
 * stays listed on the zone with zero shared channels, and its stock silently drops out
 * of that zone's fulfillment. This queries every shipping zone with its channels and
 * warehouses, and every channel with its warehouses, builds a warehouse-to-channels
 * map, and reports every zone-warehouse pair whose channel intersection is empty.
 * It never writes by default. An optional --repair flag detaches the orphaned pair
 * with shippingZoneUpdate(removeWarehouses: ...); attaching a new shared channel is
 * left to a human, since that depends on merchant intent.
 *
 * Guide: https://www.allanninal.dev/saleor/warehouse-zone-assignment-needs-shared-channel/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ZONES_AND_CHANNELS_QUERY = `
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id slug }
        warehouses { id name }
      }
    }
  }
  channels(first: 100) {
    edges {
      node {
        id slug
        warehouses(first: 100) { edges { node { id } } }
      }
    }
  }
}`;

const REMOVE_WAREHOUSES_MUTATION = `
mutation($id: ID!, $warehouseIds: [ID!]!) {
  shippingZoneUpdate(id: $id, input: { removeWarehouses: $warehouseIds }) {
    shippingZone { id }
    shippingErrors { field message }
  }
}`;

export function buildWarehouseChannelMap(channels) {
  const warehouseChannelMap = new Map();
  for (const channel of channels) {
    const slug = channel.slug;
    for (const edge of channel.warehouses?.edges || []) {
      const wid = edge.node.id;
      if (!warehouseChannelMap.has(wid)) warehouseChannelMap.set(wid, new Set());
      warehouseChannelMap.get(wid).add(slug);
    }
  }
  return warehouseChannelMap;
}

export function findOrphanedWarehouseZoneLinks(shippingZones, warehouseChannelMap) {
  const orphaned = [];
  for (const zone of shippingZones) {
    const zoneChannelSlugs = new Set((zone.channels || []).map((c) => c.slug));
    for (const warehouse of zone.warehouses || []) {
      const warehouseChannelSlugs = warehouseChannelMap.get(warehouse.id) || new Set();
      const sharesChannel = [...zoneChannelSlugs].some((slug) => warehouseChannelSlugs.has(slug));
      if (!sharesChannel) {
        orphaned.push({
          zoneId: zone.id,
          zoneName: zone.name,
          warehouseId: warehouse.id,
          warehouseName: warehouse.name,
          zoneChannelSlugs: [...zoneChannelSlugs].sort(),
        });
      }
    }
  }
  return orphaned;
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

async function fetchZonesAndChannels() {
  const data = await gql(ZONES_AND_CHANNELS_QUERY);
  const zones = data.shippingZones.edges.map((e) => e.node);
  const channels = data.channels.edges.map((e) => e.node);
  return { zones, channels };
}

async function detachWarehouse(zoneId, warehouseId) {
  const result = (await gql(REMOVE_WAREHOUSES_MUTATION, { id: zoneId, warehouseIds: [warehouseId] })).shippingZoneUpdate;
  if (result.shippingErrors.length) throw new Error(JSON.stringify(result.shippingErrors));
}

export async function run() {
  const repair = process.argv.includes("--repair");

  const { zones, channels } = await fetchZonesAndChannels();
  const warehouseChannelMap = buildWarehouseChannelMap(channels);
  const orphaned = findOrphanedWarehouseZoneLinks(zones, warehouseChannelMap);

  if (orphaned.length === 0) {
    console.log("Every zone's warehouses share at least one channel with the zone.");
    return;
  }

  for (const pair of orphaned) {
    console.warn(`Zone ${pair.zoneName} has warehouse ${pair.warehouseName} with no shared channel (zone channels: ${pair.zoneChannelSlugs})`);
    if (repair) {
      console.log(`${DRY_RUN ? "Would" : "Will"} remove warehouse ${pair.warehouseName} from zone ${pair.zoneName}`);
      if (!DRY_RUN) await detachWarehouse(pair.zoneId, pair.warehouseId);
    } else {
      console.log("Add a shared channel or rerun with --repair to detach. Not modified.");
    }
  }

  console.log(`Done. ${orphaned.length} orphaned warehouse-zone pair(s) found.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The intersection rule is the part most worth testing, because it decides which warehouse-zone pairs get flagged. Because we kept find_orphaned_warehouse_zone_links pure, the test needs no network and no Saleor store. It just feeds in plain data structures and a prebuilt map, then checks the answer.

test_warehouse_zone_links.py
from find_orphaned_warehouse_zone_links import (
    find_orphaned_warehouse_zone_links,
    build_warehouse_channel_map,
)

WH_1 = {"id": "V2FyZWhvdXNlOjE=", "name": "Main warehouse"}
WH_2 = {"id": "V2FyZWhvdXNlOjI=", "name": "Overflow warehouse"}


def zone(**over):
    base = {
        "id": "U2hpcHBpbmdab25lOjE=",
        "name": "EU zone",
        "channels": [{"id": "Q2hhbm5lbDox", "slug": "default-channel"}],
        "warehouses": [WH_1],
    }
    base.update(over)
    return base


def test_shared_channel_is_not_flagged():
    warehouse_channel_map = {WH_1["id"]: {"default-channel"}}
    assert find_orphaned_warehouse_zone_links([zone()], warehouse_channel_map) == []


def test_no_shared_channel_is_flagged():
    warehouse_channel_map = {WH_1["id"]: {"other-channel"}}
    result = find_orphaned_warehouse_zone_links([zone()], warehouse_channel_map)
    assert result == [{
        "zoneId": "U2hpcHBpbmdab25lOjE=",
        "zoneName": "EU zone",
        "warehouseId": WH_1["id"],
        "warehouseName": "Main warehouse",
        "zoneChannelSlugs": ["default-channel"],
    }]


def test_warehouse_missing_from_map_is_flagged():
    result = find_orphaned_warehouse_zone_links([zone()], {})
    assert result == [{
        "zoneId": "U2hpcHBpbmdab25lOjE=",
        "zoneName": "EU zone",
        "warehouseId": WH_1["id"],
        "warehouseName": "Main warehouse",
        "zoneChannelSlugs": ["default-channel"],
    }]


def test_zone_with_no_channels_flags_every_warehouse():
    z = zone(channels=[], warehouses=[WH_1, WH_2])
    warehouse_channel_map = {WH_1["id"]: {"default-channel"}, WH_2["id"]: {"default-channel"}}
    result = find_orphaned_warehouse_zone_links([z], warehouse_channel_map)
    assert {r["warehouseId"] for r in result} == {WH_1["id"], WH_2["id"]}


def test_only_the_orphaned_warehouse_is_flagged_among_several():
    z = zone(warehouses=[WH_1, WH_2])
    warehouse_channel_map = {
        WH_1["id"]: {"default-channel"},
        WH_2["id"]: {"wholesale-channel"},
    }
    result = find_orphaned_warehouse_zone_links([z], warehouse_channel_map)
    assert [r["warehouseId"] for r in result] == [WH_2["id"]]


def test_build_warehouse_channel_map_inverts_channel_warehouses():
    channels = [
        {"slug": "default-channel", "warehouses": {"edges": [{"node": {"id": WH_1["id"]}}]}},
        {"slug": "wholesale-channel", "warehouses": {"edges": [
            {"node": {"id": WH_1["id"]}}, {"node": {"id": WH_2["id"]}},
        ]}},
    ]
    result = build_warehouse_channel_map(channels)
    assert result == {
        WH_1["id"]: {"default-channel", "wholesale-channel"},
        WH_2["id"]: {"wholesale-channel"},
    }
warehouse-zone-links.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  findOrphanedWarehouseZoneLinks,
  buildWarehouseChannelMap,
} from "./find-orphaned-warehouse-zone-links.js";

const WH_1 = { id: "V2FyZWhvdXNlOjE=", name: "Main warehouse" };
const WH_2 = { id: "V2FyZWhvdXNlOjI=", name: "Overflow warehouse" };

const zone = (over = {}) => ({
  id: "U2hpcHBpbmdab25lOjE=",
  name: "EU zone",
  channels: [{ id: "Q2hhbm5lbDox", slug: "default-channel" }],
  warehouses: [WH_1],
  ...over,
});

test("shared channel is not flagged", () => {
  const map = new Map([[WH_1.id, new Set(["default-channel"])]]);
  assert.deepEqual(findOrphanedWarehouseZoneLinks([zone()], map), []);
});

test("no shared channel is flagged", () => {
  const map = new Map([[WH_1.id, new Set(["other-channel"])]]);
  const result = findOrphanedWarehouseZoneLinks([zone()], map);
  assert.deepEqual(result, [{
    zoneId: "U2hpcHBpbmdab25lOjE=",
    zoneName: "EU zone",
    warehouseId: WH_1.id,
    warehouseName: "Main warehouse",
    zoneChannelSlugs: ["default-channel"],
  }]);
});

test("warehouse missing from map is flagged", () => {
  const result = findOrphanedWarehouseZoneLinks([zone()], new Map());
  assert.deepEqual(result, [{
    zoneId: "U2hpcHBpbmdab25lOjE=",
    zoneName: "EU zone",
    warehouseId: WH_1.id,
    warehouseName: "Main warehouse",
    zoneChannelSlugs: ["default-channel"],
  }]);
});

test("zone with no channels flags every warehouse", () => {
  const z = zone({ channels: [], warehouses: [WH_1, WH_2] });
  const map = new Map([
    [WH_1.id, new Set(["default-channel"])],
    [WH_2.id, new Set(["default-channel"])],
  ]);
  const result = findOrphanedWarehouseZoneLinks([z], map);
  assert.deepEqual(new Set(result.map((r) => r.warehouseId)), new Set([WH_1.id, WH_2.id]));
});

test("only the orphaned warehouse is flagged among several", () => {
  const z = zone({ warehouses: [WH_1, WH_2] });
  const map = new Map([
    [WH_1.id, new Set(["default-channel"])],
    [WH_2.id, new Set(["wholesale-channel"])],
  ]);
  const result = findOrphanedWarehouseZoneLinks([z], map);
  assert.deepEqual(result.map((r) => r.warehouseId), [WH_2.id]);
});

test("buildWarehouseChannelMap inverts channel warehouses", () => {
  const channels = [
    { slug: "default-channel", warehouses: { edges: [{ node: { id: WH_1.id } }] } },
    { slug: "wholesale-channel", warehouses: { edges: [
      { node: { id: WH_1.id } }, { node: { id: WH_2.id } },
    ] } },
  ];
  const result = buildWarehouseChannelMap(channels);
  assert.deepEqual(result, new Map([
    [WH_1.id, new Set(["default-channel", "wholesale-channel"])],
    [WH_2.id, new Set(["wholesale-channel"])],
  ]));
});

Case studies

Channel retirement

The wholesale channel that quietly took a warehouse with it

A store retired a seasonal wholesale channel and cleaned it up with channelUpdate, removing the warehouse that only served that channel. Nobody thought to check the shipping zone that warehouse had been added to months earlier for a completely different, still-active channel launch.

The zone's warehouse list still showed the warehouse. Fulfillment for that zone quietly started skipping it, and stock that should have counted toward availability there did not. Running the script against the zone data surfaced the exact pair in seconds, with the zone's remaining channel slugs printed right next to it, which made it obvious the warehouse needed a shared channel added back, not a removal.

Zone reorganization

The zone that lost a channel during a market split

A merchant split one shipping zone covering two countries into two separate zones, one per country, and moved channels around with shippingZoneUpdate(removeChannels: ...) to match. One old warehouse assignment was left behind on the original zone after its only shared channel moved to the new zone.

Nothing errored during the reorganization, because removing a channel from a zone is a normal, allowed operation on its own. The orphaned pair only showed up when the report ran afterward, at which point the team decided the warehouse no longer belonged on that zone at all and used --repair to detach it cleanly.

What good looks like

After running this on a schedule, every shipping zone's warehouses are provably able to fulfill that zone, because the channel intersection is recomputed from current data instead of trusted from a link made months ago. Orphaned pairs get reported with the exact zone, warehouse, and remaining channels, so whoever reviews it can add a shared channel back or detach the pairing with full context, and stock never silently drops out of a zone's fulfillment again.

FAQ

Why does shippingZoneUpdate reject addWarehouses with an INVALID error?

Saleor only allows a warehouse to join a shipping zone's usable warehouses when that warehouse shares at least one channel with the zone. If you call shippingZoneUpdate with addWarehouses before the zone and the warehouse share a channel, or after a channel was removed from either side, Saleor rejects the field outright with an INVALID error instead of accepting a link it cannot use.

Can a warehouse stay linked to a zone after it silently stops sharing a channel?

Yes. Saleor validates the shared channel only at the moment addWarehouses or addChannels runs. If a channel is later removed from the warehouse with channelUpdate, or removed from the zone, the warehouse stays in the zone's warehouses list but now shares zero channels with it. There is no ongoing revalidation, so the link goes orphaned silently and the warehouse's stock quietly drops out of that zone's fulfillment.

Should a script automatically fix an orphaned warehouse-zone link?

Not automatically. The correct fix depends on merchant intent: adding a shared channel back with channelUpdate or shippingZoneUpdate keeps the pairing alive, while removing the warehouse from the zone with removeWarehouses accepts that it no longer belongs there. A script should run in dry run by default and only report the orphaned pairs, leaving that choice to a human, with an optional explicit repair flag for the detach-only case.

Related field notes

Citations

On the problem:

  1. Bug: Only warehouses that have common channel with shipping zone can be assigned. Issue #17029, saleor/saleor. github.com/saleor/saleor/issues/17029
  2. Warehouses & Shipping Zones. Discussion #2103, saleor/saleor-dashboard. github.com/saleor/saleor-dashboard/discussions/2103
  3. Saleor Developer Docs: Stock Overview. docs.saleor.io/developer/stock/overview

On the solution:

  1. Saleor Developer Docs: Shipping Zone. docs.saleor.io/developer/shipping/shipping-zone
  2. Saleor API Reference: the ShippingZone object. docs.saleor.io/api-reference/shipping/objects/shipping-zone
  3. Saleor Developer Docs: Channel Configuration. docs.saleor.io/developer/channels/configuration

Fighting a Saleor bug right now?

If you have a problem in Saleor checkout, channels, shipping, 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.

Contact me on LinkedIn

Did this explain your missing warehouse?

If this saved you a night chasing stock that quietly disappeared from a zone, 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

Back to all Saleor field notes