Skip to content

Diagnostic Shipping & Warehouses

Product unavailable from a warehouse zone channel mismatch

The stock is real. The warehouse has plenty of it. But the storefront shows the product as unavailable, or it just does not show up at all for that channel, and there is no error to point at anything. In Saleor a variant's stock is only reachable from a channel when a small graph of links agrees: warehouse to channel, and sometimes warehouse to zone to channel too. Miss one link and the product goes quiet. Here is why, and a script that finds every stock row a channel cannot actually reach.

Python and Node.js Saleor GraphQL API Safe by default (dry run)
A forklift in a warehouse
Photo by Kseniia Ilinykh on Unsplash
The short answer

A Saleor variant is purchasable in a channel only if its stock is reachable from that channel's warehouse graph. Stock.warehouse must be directly assigned to the channel through Channel.warehouses, and, only if the store still has Shop.useLegacyShippingZoneStockAvailability enabled, the warehouse's ShippingZone must also be attached to that same channel and cover the destination country. Warehouse-to-channel and warehouse-to-zone-to-channel are separate many-to-many links managed independently, so it is easy to add a warehouse with real stock and forget one of them (see saleor/saleor#17029). Run a Python or Node.js script that queries the shop's legacy flag, the channel's warehouses, and each variant's stocks with their warehouse channels and zones, and flags every stock row that is unreachable from the channel. Full code, tests, and a dry run guard are below.

The problem in plain words

Saleor does not ask "does this warehouse have stock." It asks "can this channel see this warehouse." Those are different questions, and the gap between them is where products quietly disappear.

A warehouse only counts for a channel when the warehouse is listed in that channel's warehouses field. That link has to be added on purpose, through channelUpdate with addWarehouses, or through the dashboard. If a warehouse is created, stocked, and never explicitly attached to the channel, every variant sitting in it resolves quantityAvailable: 0 for that channel, and the product either shows as unavailable or drops out of channel and stock-availability filters entirely. Stock.quantity is still sitting there, positive, untouched. Saleor just never told that channel it could look.

Warehouse Stock.quantity > 0 Real stock exists in the raw Stock row not in Channel.warehouses Channel cannot see the warehouse quantityAvailable resolves to 0 Product looks unavailable
The stock is real and sitting in the warehouse, but without a link from Channel.warehouses to that warehouse, the channel never sees it.

Why it happens

Warehouse to channel, and warehouse to shipping zone to channel, are managed as separate many-to-many relationships in both the dashboard and the API. Nothing in Saleor enforces that they stay in sync with each other. A few common ways stores end up with orphaned stock:

None of this raises an error anywhere. quantityAvailable just quietly returns 0, or the product drops out of a stockAvailability: IN_STOCK filter, and support tickets start arriving asking why an in-stock product will not sell. See the citations at the end for the exact issue and docs.

The key insight

A channel does not automatically see every warehouse that has stock. It only sees the warehouses it has been explicitly given through Channel.warehouses, and in legacy stock mode it also needs the covering shipping zone attached to the same channel. Two separate links, two separate places to forget one. The fix is not to guess which link is missing, it is to walk both checks in order for every stock row and report exactly which one failed.

The fix, as a flow

We do not touch checkout or the storefront. We query the shop's legacy stock flag, the target channel's assigned warehouses, and every relevant variant's stocks with their warehouse's channels and shipping zones. One pure function then walks each stock row: is the warehouse in the channel's warehouse list, and, only in legacy mode, does a shipping zone covering the destination also belong to the channel. Anything that fails either check gets reported with the specific reason, and only after a human confirms the fix does the script print the exact repair mutation.

Query shop, channel and variant stocks Flatten stock rows warehouse channels, zones findOrphanedStock channel, then zone if legacy Reachable from the channel? yes, nothing to do no, flag reason Report or, if confirmed, dry run
Every stock row is checked against the channel's warehouse graph. A gap is reported with its exact reason, and a repair mutation only ever prints under dry run once a human has confirmed the intended link.

Build it step by step

1

Get an app token that can read warehouses, channels, and shop settings

Create an app in the Saleor dashboard, or use tokenCreate with staff credentials, and grant it permission to read products, warehouses, channels, and shop settings. 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 SALEOR_CHANNEL_ID="Q2hhbm5lbDox"
export SALEOR_VARIANT_IDS="UHJvZHVjdFZhcmlhbnQ6MQ==,UHJvZHVjdFZhcmlhbnQ6Mg=="
export DRY_RUN="true"   # start safe, change to false to print repair mutations
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 SALEOR_CHANNEL_ID="Q2hhbm5lbDox"
export SALEOR_VARIANT_IDS="UHJvZHVjdFZhcmlhbnQ6MQ==,UHJvZHVjdFZhcmlhbnQ6Mg=="
export DRY_RUN="true"   // start safe, change to false to print repair mutations
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

Pull the legacy flag, the channel's warehouses, and each variant's stocks

Read shop.useLegacyShippingZoneStockAvailability once, since it decides which rule applies. Read the target channel's warehouses so you know which warehouse ids the channel can already see. Then, for each variant you care about, query productVariant(id, channel) for stocks, with each stock's warehouse including its channels and its shippingZones with their own channels and countries, per the Warehouse object.

step3.py
SHOP_QUERY = """
query { shop { useLegacyShippingZoneStockAvailability } }"""

CHANNEL_QUERY = """
query($id: ID!) {
  channel(id: $id) {
    id slug
    warehouses { id }
  }
}"""

VARIANT_STOCKS_QUERY = """
query($id: ID!, $channel: String!) {
  productVariant(id: $id, channel: $channel) {
    id
    quantityAvailable
    stocks {
      quantity
      warehouse {
        id name
        channels { slug }
        shippingZones(first: 100) {
          edges { node { id channels { slug } countries { code } } }
        }
      }
    }
  }
}"""

def fetch_legacy_mode():
    return gql(SHOP_QUERY)["shop"]["useLegacyShippingZoneStockAvailability"]

def fetch_channel_graph(channel_id):
    data = gql(CHANNEL_QUERY, {"id": channel_id})["channel"]
    return {"slug": data["slug"], "warehouseIds": {w["id"] for w in data.get("warehouses", [])}}
step3.js
const SHOP_QUERY = `
query { shop { useLegacyShippingZoneStockAvailability } }`;

const CHANNEL_QUERY = `
query($id: ID!) {
  channel(id: $id) {
    id slug
    warehouses { id }
  }
}`;

const VARIANT_STOCKS_QUERY = `
query($id: ID!, $channel: String!) {
  productVariant(id: $id, channel: $channel) {
    id
    quantityAvailable
    stocks {
      quantity
      warehouse {
        id name
        channels { slug }
        shippingZones(first: 100) {
          edges { node { id channels { slug } countries { code } } }
        }
      }
    }
  }
}`;

async function fetchLegacyMode() {
  return (await gql(SHOP_QUERY)).shop.useLegacyShippingZoneStockAvailability;
}

async function fetchChannelGraph(channelId) {
  const data = (await gql(CHANNEL_QUERY, { id: channelId })).channel;
  return { slug: data.slug, warehouseIds: new Set((data.warehouses || []).map((w) => w.id)) };
}
4

Flatten each variant's stocks into plain records

The GraphQL response nests warehouses inside stocks inside a variant. Flatten that into one flat record per stock row before deciding anything, so the decision function never has to know about GraphQL shapes.

step4.py
def to_variant_stock_records(variant_id, variant_data):
    records = []
    for stock in variant_data.get("stocks", []):
        warehouse = stock.get("warehouse") or {}
        records.append({
            "variantId": variant_id,
            "warehouseId": warehouse.get("id"),
            "quantity": stock.get("quantity", 0),
            "warehouseChannelSlugs": [c["slug"] for c in warehouse.get("channels", [])],
            "warehouseZones": [
                {
                    "id": edge["node"]["id"],
                    "channelSlugs": [c["slug"] for c in edge["node"].get("channels", [])],
                    "countries": [c["code"] for c in edge["node"].get("countries", [])],
                }
                for edge in (warehouse.get("shippingZones") or {}).get("edges", [])
            ],
        })
    return records
step4.js
export function toVariantStockRecords(variantId, variantData) {
  const stocks = variantData?.stocks || [];
  return stocks.map((stock) => {
    const warehouse = stock.warehouse || {};
    return {
      variantId,
      warehouseId: warehouse.id,
      quantity: stock.quantity || 0,
      warehouseChannelSlugs: (warehouse.channels || []).map((c) => c.slug),
      warehouseZones: ((warehouse.shippingZones || {}).edges || []).map((edge) => ({
        id: edge.node.id,
        channelSlugs: (edge.node.channels || []).map((c) => c.slug),
        countries: (edge.node.countries || []).map((c) => c.code),
      })),
    };
  });
}
5

Decide, with one pure function

Keep the decision in its own function that takes the flattened stock records, the channel graph, the legacy mode flag, and an optional destination country, and returns every unreachable row with a reason. A pure function like this is easy to read and easy to test, which we do later. Only rows with positive quantity are checked at all, since a genuinely empty warehouse is not an orphaned stock problem. For each remaining row, first check whether the channel's slug appears in the warehouse's own channel slugs; if not, the reason is "warehouse not linked to channel". Only when legacy mode is on does the function also look for a shipping zone on that warehouse whose channels include the channel and whose countries include the destination, if one was given; if no such zone exists, the reason is "warehouse zone not linked to channel/destination".

decide.py
def find_orphaned_stock(variants, channel, legacy_mode, destination_country=None):
    issues = []
    for record in variants:
        if record.get("quantity", 0) <= 0:
            continue

        if channel["slug"] not in record.get("warehouseChannelSlugs", []):
            issues.append({
                "variantId": record["variantId"],
                "warehouseId": record["warehouseId"],
                "reason": "warehouse not linked to channel",
            })
            continue

        if legacy_mode:
            matching_zone = next(
                (
                    z for z in record.get("warehouseZones", [])
                    if channel["slug"] in z.get("channelSlugs", [])
                    and (not destination_country or destination_country in z.get("countries", []))
                ),
                None,
            )
            if matching_zone is None:
                issues.append({
                    "variantId": record["variantId"],
                    "warehouseId": record["warehouseId"],
                    "reason": "warehouse zone not linked to channel/destination",
                })

    return issues
decide.js
export function findOrphanedStock(variants, channel, legacyMode, destinationCountry) {
  const issues = [];
  for (const record of variants) {
    if (!(record.quantity > 0)) continue;

    if (!(record.warehouseChannelSlugs || []).includes(channel.slug)) {
      issues.push({
        variantId: record.variantId,
        warehouseId: record.warehouseId,
        reason: "warehouse not linked to channel",
      });
      continue;
    }

    if (legacyMode) {
      const matchingZone = (record.warehouseZones || []).find(
        (z) =>
          (z.channelSlugs || []).includes(channel.slug) &&
          (!destinationCountry || (z.countries || []).includes(destinationCountry))
      );
      if (!matchingZone) {
        issues.push({
          variantId: record.variantId,
          warehouseId: record.warehouseId,
          reason: "warehouse zone not linked to channel/destination",
        });
      }
    }
  }
  return issues;
}
6

Report first, repair only after a human confirms the link

The default action is to print every flagged variant, its warehouse, and the exact reason. Do not auto-attach a warehouse to a channel or a shipping zone to a channel, since that changes what the merchant intends to sell where. Once a human confirms the intended fix, an --apply path (guarded by DRY_RUN=false) can call channelUpdate with addWarehouses for a warehouse gap, or shippingZoneUpdate with addWarehouses and addChannels for a zone gap in legacy mode, logging the before and after warehouse { channels shippingZones } diff.

Run it safe

Always start with DRY_RUN=true. This script never sends channelUpdate or shippingZoneUpdate on its own. It reports the gap and, under DRY_RUN=true, only prints the mutation it would run for a warehouse-to-channel gap so a human can review the change before anything is sent.

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 ever prints a planned mutation rather than sending a write it cannot justify.

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_stock.py
"""Flag Saleor variants whose stock is unreachable from a channel, and why.

A ProductVariant is only purchasable in a channel when its Stock.warehouse is
directly assigned to that channel (Channel.warehouses), and, only when the store
still has Shop.useLegacyShippingZoneStockAvailability enabled, when the warehouse's
ShippingZone is also attached to that channel and covers the customer's destination
country. Because warehouse-to-channel and warehouse-to-zone-to-channel are separate
many-to-many links, it is easy to add a warehouse with real stock and forget one of
them. quantityAvailable then resolves to 0 even though Stock.quantity is positive.

This queries the shop's legacy stock flag, one channel's assigned warehouses, and
each variant's stocks with their warehouse channels and shipping zones, then runs a
pure decision function to report every stock row that is unreachable from the
requested channel. It never mutates merchant topology by default: channelUpdate and
shippingZoneUpdate are only ever printed under DRY_RUN, after a human confirms the
intended zone/channel.
"""
import os
import logging
import requests

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

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"

SHOP_QUERY = """
query { shop { useLegacyShippingZoneStockAvailability } }"""

CHANNEL_QUERY = """
query($id: ID!) {
  channel(id: $id) {
    id slug
    warehouses { id }
  }
}"""

VARIANT_STOCKS_QUERY = """
query($id: ID!, $channel: String!) {
  productVariant(id: $id, channel: $channel) {
    id
    quantityAvailable
    stocks {
      quantity
      warehouse {
        id
        name
        channels { slug }
        shippingZones(first: 100) {
          edges {
            node {
              id
              channels { slug }
              countries { code }
            }
          }
        }
      }
    }
  }
}"""

CHANNEL_UPDATE = """
mutation($id: ID!, $input: ChannelUpdateInput!) {
  channelUpdate(id: $id, input: $input) {
    channel { id warehouses { id } }
    errors { field message }
  }
}"""

SHIPPING_ZONE_UPDATE = """
mutation($id: ID!, $input: ShippingZoneUpdateInput!) {
  shippingZoneUpdate(id: $id, input: $input) {
    shippingZone { id channels { slug } warehouses { id } }
    errors { 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 find_orphaned_stock(variants, channel, legacy_mode, destination_country=None):
    """Pure decision function. No I/O, fully deterministic.

    variants: list of VariantStockRecord dicts:
        { variantId, warehouseId, quantity, warehouseChannelSlugs: [str],
          warehouseZones: [{ id, channelSlugs: [str], countries: [str] }] }
    channel: { slug, warehouseIds: set-like of str }

    Returns a list of {variantId, warehouseId, reason} for every stock row
    that is unreachable from the requested channel, i.e. would report
    quantityAvailable=0 despite quantity > 0 in the raw Stock row.
    """
    issues = []
    for record in variants:
        if record.get("quantity", 0) <= 0:
            continue

        if channel["slug"] not in record.get("warehouseChannelSlugs", []):
            issues.append({
                "variantId": record["variantId"],
                "warehouseId": record["warehouseId"],
                "reason": "warehouse not linked to channel",
            })
            continue

        if legacy_mode:
            matching_zone = next(
                (
                    z for z in record.get("warehouseZones", [])
                    if channel["slug"] in z.get("channelSlugs", [])
                    and (not destination_country or destination_country in z.get("countries", []))
                ),
                None,
            )
            if matching_zone is None:
                issues.append({
                    "variantId": record["variantId"],
                    "warehouseId": record["warehouseId"],
                    "reason": "warehouse zone not linked to channel/destination",
                })

    return issues


def to_variant_stock_records(variant_id, variant_data):
    """Flatten one productVariant GraphQL response into VariantStockRecord rows."""
    records = []
    for stock in variant_data.get("stocks", []):
        warehouse = stock.get("warehouse") or {}
        records.append({
            "variantId": variant_id,
            "warehouseId": warehouse.get("id"),
            "quantity": stock.get("quantity", 0),
            "warehouseChannelSlugs": [c["slug"] for c in warehouse.get("channels", [])],
            "warehouseZones": [
                {
                    "id": edge["node"]["id"],
                    "channelSlugs": [c["slug"] for c in edge["node"].get("channels", [])],
                    "countries": [c["code"] for c in edge["node"].get("countries", [])],
                }
                for edge in (warehouse.get("shippingZones") or {}).get("edges", [])
            ],
        })
    return records


def fetch_legacy_mode():
    return gql(SHOP_QUERY)["shop"]["useLegacyShippingZoneStockAvailability"]


def fetch_channel_graph(channel_id):
    data = gql(CHANNEL_QUERY, {"id": channel_id})["channel"]
    return {
        "slug": data["slug"],
        "warehouseIds": {w["id"] for w in data.get("warehouses", [])},
    }


def fetch_variant_records(variant_id, channel_slug):
    data = gql(VARIANT_STOCKS_QUERY, {"id": variant_id, "channel": channel_slug})["productVariant"]
    if not data:
        return []
    return to_variant_stock_records(variant_id, data)


def print_planned_channel_update(channel_id, warehouse_id):
    variables = {"id": channel_id, "input": {"addWarehouses": [warehouse_id]}}
    log.info("DRY RUN would call channelUpdate: %s", variables)


def print_planned_zone_update(zone_id, warehouse_id, channel_id):
    variables = {"id": zone_id, "input": {"addWarehouses": [warehouse_id], "addChannels": [channel_id]}}
    log.info("DRY RUN would call shippingZoneUpdate: %s", variables)


def run():
    channel_id = os.environ["SALEOR_CHANNEL_ID"]
    variant_ids = [v for v in os.environ.get("SALEOR_VARIANT_IDS", "").split(",") if v]

    legacy_mode = fetch_legacy_mode()
    channel = fetch_channel_graph(channel_id)

    all_records = []
    for variant_id in variant_ids:
        all_records.extend(fetch_variant_records(variant_id, channel["slug"]))

    issues = find_orphaned_stock(all_records, channel, legacy_mode)

    if not issues:
        log.info("No orphaned stock found for channel %s.", channel["slug"])
        return

    for issue in issues:
        log.warning(
            "Variant %s has stock in warehouse %s that is unreachable from channel %s: %s",
            issue["variantId"], issue["warehouseId"], channel["slug"], issue["reason"],
        )
        if DRY_RUN:
            if issue["reason"] == "warehouse not linked to channel":
                print_planned_channel_update(channel_id, issue["warehouseId"])
            else:
                log.info(
                    "Zone repair needs a zone id, which this report does not choose "
                    "automatically. Review the warehouse's shippingZones and pick the "
                    "correct one before calling shippingZoneUpdate."
                )

    log.info("Done. %d orphaned stock row(s) flagged.", len(issues))


if __name__ == "__main__":
    run()
find-orphaned-stock.js
/**
 * Flag Saleor variants whose stock is unreachable from a channel, and why.
 *
 * A ProductVariant is only purchasable in a channel when its Stock.warehouse is
 * directly assigned to that channel (Channel.warehouses), and, only when the store
 * still has Shop.useLegacyShippingZoneStockAvailability enabled, when the warehouse's
 * ShippingZone is also attached to that channel and covers the customer's destination
 * country. Because warehouse-to-channel and warehouse-to-zone-to-channel are separate
 * many-to-many links, it is easy to add a warehouse with real stock and forget one of
 * them. quantityAvailable then resolves to 0 even though Stock.quantity is positive.
 *
 * This queries the shop's legacy stock flag, one channel's assigned warehouses, and
 * each variant's stocks with their warehouse channels and shipping zones, then runs a
 * pure decision function to report every stock row that is unreachable from the
 * requested channel. It never mutates merchant topology by default: channelUpdate and
 * shippingZoneUpdate are only ever printed under DRY_RUN, after a human confirms the
 * intended zone/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 SHOP_QUERY = `
query { shop { useLegacyShippingZoneStockAvailability } }`;

const CHANNEL_QUERY = `
query($id: ID!) {
  channel(id: $id) {
    id slug
    warehouses { id }
  }
}`;

const VARIANT_STOCKS_QUERY = `
query($id: ID!, $channel: String!) {
  productVariant(id: $id, channel: $channel) {
    id
    quantityAvailable
    stocks {
      quantity
      warehouse {
        id
        name
        channels { slug }
        shippingZones(first: 100) {
          edges {
            node {
              id
              channels { slug }
              countries { code }
            }
          }
        }
      }
    }
  }
}`;

const CHANNEL_UPDATE = `
mutation($id: ID!, $input: ChannelUpdateInput!) {
  channelUpdate(id: $id, input: $input) {
    channel { id warehouses { id } }
    errors { field message }
  }
}`;

const SHIPPING_ZONE_UPDATE = `
mutation($id: ID!, $input: ShippingZoneUpdateInput!) {
  shippingZoneUpdate(id: $id, input: $input) {
    shippingZone { id channels { slug } warehouses { id } }
    errors { field message }
  }
}`;

/**
 * Pure decision function. No I/O, fully deterministic.
 *
 * variants: VariantStockRecord[]
 *   { variantId, warehouseId, quantity, warehouseChannelSlugs: string[],
 *     warehouseZones: { id, channelSlugs: string[], countries: string[] }[] }
 * channel: { slug, warehouseIds: Set<string> }
 *
 * Returns a list of {variantId, warehouseId, reason} for every stock row that
 * is unreachable from the requested channel, i.e. would report
 * quantityAvailable=0 despite quantity > 0 in the raw Stock row.
 */
export function findOrphanedStock(variants, channel, legacyMode, destinationCountry) {
  const issues = [];
  for (const record of variants) {
    if (!(record.quantity > 0)) continue;

    if (!(record.warehouseChannelSlugs || []).includes(channel.slug)) {
      issues.push({
        variantId: record.variantId,
        warehouseId: record.warehouseId,
        reason: "warehouse not linked to channel",
      });
      continue;
    }

    if (legacyMode) {
      const matchingZone = (record.warehouseZones || []).find(
        (z) =>
          (z.channelSlugs || []).includes(channel.slug) &&
          (!destinationCountry || (z.countries || []).includes(destinationCountry))
      );
      if (!matchingZone) {
        issues.push({
          variantId: record.variantId,
          warehouseId: record.warehouseId,
          reason: "warehouse zone not linked to channel/destination",
        });
      }
    }
  }
  return issues;
}

/** Flatten one productVariant GraphQL response into VariantStockRecord rows. */
export function toVariantStockRecords(variantId, variantData) {
  const stocks = variantData?.stocks || [];
  return stocks.map((stock) => {
    const warehouse = stock.warehouse || {};
    return {
      variantId,
      warehouseId: warehouse.id,
      quantity: stock.quantity || 0,
      warehouseChannelSlugs: (warehouse.channels || []).map((c) => c.slug),
      warehouseZones: ((warehouse.shippingZones || {}).edges || []).map((edge) => ({
        id: edge.node.id,
        channelSlugs: (edge.node.channels || []).map((c) => c.slug),
        countries: (edge.node.countries || []).map((c) => c.code),
      })),
    };
  });
}

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 fetchLegacyMode() {
  return (await gql(SHOP_QUERY)).shop.useLegacyShippingZoneStockAvailability;
}

async function fetchChannelGraph(channelId) {
  const data = (await gql(CHANNEL_QUERY, { id: channelId })).channel;
  return {
    slug: data.slug,
    warehouseIds: new Set((data.warehouses || []).map((w) => w.id)),
  };
}

async function fetchVariantRecords(variantId, channelSlug) {
  const data = (await gql(VARIANT_STOCKS_QUERY, { id: variantId, channel: channelSlug })).productVariant;
  if (!data) return [];
  return toVariantStockRecords(variantId, data);
}

function printPlannedChannelUpdate(channelId, warehouseId) {
  const variables = { id: channelId, input: { addWarehouses: [warehouseId] } };
  console.log("DRY RUN would call channelUpdate:", JSON.stringify(variables));
}

function printPlannedZoneUpdate(zoneId, warehouseId, channelId) {
  const variables = { id: zoneId, input: { addWarehouses: [warehouseId], addChannels: [channelId] } };
  console.log("DRY RUN would call shippingZoneUpdate:", JSON.stringify(variables));
}

export async function run() {
  const channelId = process.env.SALEOR_CHANNEL_ID;
  const variantIds = (process.env.SALEOR_VARIANT_IDS || "").split(",").filter(Boolean);

  const legacyMode = await fetchLegacyMode();
  const channel = await fetchChannelGraph(channelId);

  const allRecords = [];
  for (const variantId of variantIds) {
    allRecords.push(...(await fetchVariantRecords(variantId, channel.slug)));
  }

  const issues = findOrphanedStock(allRecords, channel, legacyMode);

  if (issues.length === 0) {
    console.log(`No orphaned stock found for channel ${channel.slug}.`);
    return;
  }

  for (const issue of issues) {
    console.warn(
      `Variant ${issue.variantId} has stock in warehouse ${issue.warehouseId} that is unreachable from channel ${channel.slug}: ${issue.reason}`
    );
    if (DRY_RUN) {
      if (issue.reason === "warehouse not linked to channel") {
        printPlannedChannelUpdate(channelId, issue.warehouseId);
      } else {
        console.log(
          "Zone repair needs a zone id, which this report does not choose automatically. "
            + "Review the warehouse's shippingZones and pick the correct one before calling shippingZoneUpdate."
        );
      }
    }
  }

  console.log(`Done. ${issues.length} orphaned stock row(s) flagged.`);
}

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

Add a test

The reachability rule is the part most worth testing, because it decides whether a variant gets flagged as orphaned and why. Because we kept find_orphaned_stock pure, the test needs no network and no Saleor store. It just feeds in plain data structures and checks the answer.

test_orphaned_stock.py
from find_orphaned_stock import find_orphaned_stock

CHANNEL = {"slug": "default-channel", "warehouseIds": {"V2FyZWhvdXNlOjE="}}


def record(**over):
    base = {
        "variantId": "UHJvZHVjdFZhcmlhbnQ6MQ==",
        "warehouseId": "V2FyZWhvdXNlOjE=",
        "quantity": 10,
        "warehouseChannelSlugs": ["default-channel"],
        "warehouseZones": [
            {"id": "U2hpcHBpbmdab25lOjE=", "channelSlugs": ["default-channel"], "countries": ["US"]}
        ],
    }
    base.update(over)
    return base


def test_reachable_stock_is_not_flagged():
    assert find_orphaned_stock([record()], CHANNEL, legacy_mode=False) == []


def test_zero_quantity_is_ignored_even_if_unlinked():
    r = record(quantity=0, warehouseChannelSlugs=[])
    assert find_orphaned_stock([r], CHANNEL, legacy_mode=False) == []


def test_warehouse_not_linked_to_channel_is_flagged():
    r = record(warehouseChannelSlugs=[])
    result = find_orphaned_stock([r], CHANNEL, legacy_mode=False)
    assert result == [{
        "variantId": "UHJvZHVjdFZhcmlhbnQ6MQ==",
        "warehouseId": "V2FyZWhvdXNlOjE=",
        "reason": "warehouse not linked to channel",
    }]


def test_legacy_mode_off_ignores_zone_gap():
    r = record(warehouseZones=[])
    assert find_orphaned_stock([r], CHANNEL, legacy_mode=False) == []


def test_legacy_mode_on_flags_missing_zone_channel_link():
    r = record(warehouseZones=[{"id": "Z1", "channelSlugs": [], "countries": ["US"]}])
    result = find_orphaned_stock([r], CHANNEL, legacy_mode=True)
    assert result == [{
        "variantId": "UHJvZHVjdFZhcmlhbnQ6MQ==",
        "warehouseId": "V2FyZWhvdXNlOjE=",
        "reason": "warehouse zone not linked to channel/destination",
    }]


def test_legacy_mode_on_and_zone_matches_is_not_flagged():
    assert find_orphaned_stock([record()], CHANNEL, legacy_mode=True) == []
orphaned-stock.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanedStock } from "./find-orphaned-stock.js";

const CHANNEL = { slug: "default-channel", warehouseIds: new Set(["V2FyZWhvdXNlOjE="]) };

const record = (over = {}) => ({
  variantId: "UHJvZHVjdFZhcmlhbnQ6MQ==",
  warehouseId: "V2FyZWhvdXNlOjE=",
  quantity: 10,
  warehouseChannelSlugs: ["default-channel"],
  warehouseZones: [
    { id: "U2hpcHBpbmdab25lOjE=", channelSlugs: ["default-channel"], countries: ["US"] },
  ],
  ...over,
});

test("reachable stock is not flagged", () => {
  assert.deepEqual(findOrphanedStock([record()], CHANNEL, false), []);
});

test("zero quantity is ignored even if unlinked", () => {
  const r = record({ quantity: 0, warehouseChannelSlugs: [] });
  assert.deepEqual(findOrphanedStock([r], CHANNEL, false), []);
});

test("warehouse not linked to channel is flagged", () => {
  const r = record({ warehouseChannelSlugs: [] });
  const result = findOrphanedStock([r], CHANNEL, false);
  assert.deepEqual(result, [{
    variantId: "UHJvZHVjdFZhcmlhbnQ6MQ==",
    warehouseId: "V2FyZWhvdXNlOjE=",
    reason: "warehouse not linked to channel",
  }]);
});

test("legacy mode off ignores zone gap", () => {
  const r = record({ warehouseZones: [] });
  assert.deepEqual(findOrphanedStock([r], CHANNEL, false), []);
});

test("legacy mode on flags missing zone channel link", () => {
  const r = record({ warehouseZones: [{ id: "Z1", channelSlugs: [], countries: ["US"] }] });
  const result = findOrphanedStock([r], CHANNEL, true);
  assert.deepEqual(result, [{
    variantId: "UHJvZHVjdFZhcmlhbnQ6MQ==",
    warehouseId: "V2FyZWhvdXNlOjE=",
    reason: "warehouse zone not linked to channel/destination",
  }]);
});

test("legacy mode on and zone matches is not flagged", () => {
  assert.deepEqual(findOrphanedStock([record()], CHANNEL, true), []);
});

Case studies

New region launch

The regional warehouse that shipped to nobody

A store opened a new fulfillment warehouse for a country it had just launched a channel for. Stock was loaded, the shipping zone was cloned from an existing region, and the launch went ahead. Every product from that warehouse showed as unavailable on the new channel, and support assumed the import had failed.

Running the script against the new channel's variants showed the real reason immediately: warehouse not linked to channel on every row from the new warehouse. The shipping zone had the warehouse, but nobody had run channelUpdate with addWarehouses for the channel itself. One confirmed mutation and the whole catalog came back.

Legacy stock mode

The channel that had the warehouse but not the zone

A merchant still running with useLegacyShippingZoneStockAvailability enabled added a wholesale channel and attached an existing warehouse to it directly. Everything looked right in the warehouse settings, but a handful of SKUs stayed unavailable for buyers in one country.

The script flagged those variants with warehouse zone not linked to channel/destination, which pointed the team at the shipping zone rather than the warehouse. The zone covering that country existed, it just was never linked to the new wholesale channel. Attaching it with shippingZoneUpdate closed the gap without touching a single stock row.

What good looks like

After running this against your channels, every silently unavailable product traces back to one of two exact reasons instead of a guess: a warehouse missing from Channel.warehouses, or, in legacy mode, a shipping zone missing from the channel or the destination country. Each gets fixed with a reviewed channelUpdate or shippingZoneUpdate call, never a blind write, and no customer finds an in-stock product that quietly refuses to sell.

FAQ

Why does a product with real stock show as unavailable in a Saleor channel?

A variant is only purchasable in a channel when its Stock.warehouse is directly assigned to that channel through Channel.warehouses. If the warehouse holding the stock was never attached to the channel, quantityAvailable resolves to 0 for that channel no matter how much Stock.quantity actually exists.

What does useLegacyShippingZoneStockAvailability change about stock availability?

When this Shop setting is enabled, a warehouse is not enough on its own. The warehouse's ShippingZone must also be attached to the same channel and must cover the customer's destination country, or the stock is still treated as unreachable even though the warehouse itself is linked to the channel.

Is it safe to auto fix a warehouse that is missing from a channel?

Attaching a warehouse to a channel with channelUpdate, or attaching a shipping zone to a channel with shippingZoneUpdate, changes what a merchant intends to sell where, so a script should report the gap by default. Only run the addWarehouses or addChannels mutation after a human confirms the intended zone and channel, and start with DRY_RUN=true so you see the exact call before it executes.

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-dashboard. github.com/saleor/saleor-dashboard/discussions/2103
  3. Saleor Commerce Documentation: Products troubleshooting guide. docs.saleor.io/developer/products/troubleshooting

On the solution:

  1. Saleor API Reference: the Warehouse object. docs.saleor.io/api-reference/products/objects/warehouse
  2. Saleor Commerce Documentation: Stock Overview. docs.saleor.io/developer/stock/overview
  3. Saleor Commerce Documentation: 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 find your missing warehouse link?

If this saved you a support thread or a night chasing a product that should have been in stock, 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