Skip to content

Reconciler Products, Variants & Channels

Variant has no channel listing after creation

A new variant just went in, through the dashboard, an import script, or a bulk mutation. The product page loads, the product looks published, and everyone moves on. Then someone tries to buy that exact variant and it is nowhere to be found, or the storefront shows no price at all. The variant exists. It just never got a price on any channel, and Saleor never warns you about it. Here is why that row goes missing and a script that finds every variant left behind.

Python and Node.js Saleor GraphQL API Report by default, guarded repair
Labeled packs on a shelf
Photo by Franki Chamaki on Unsplash
The short answer

Creating a variant through productVariantCreate, productVariantBulkCreate, or a CSV import only creates the ProductVariant row itself. That variant becomes sellable on a channel only once a separate ProductVariantChannelListing row exists for that channel, carrying a price, created either through a channelListings input at creation time or a follow up productVariantChannelListingUpdate call. Because ProductChannelListing, the product level publication flag, and ProductVariantChannelListing, the variant level price and channel row, are tracked independently, a product can look published while one of its variants has zero channel listings and is completely unsellable. Run a small Python or Node.js script that pages through variants, compares the channels the product is published on against the channels the variant actually has a price on, and reports or repairs the gap. Full code, tests, and a dry run guard are below.

The problem in plain words

Saleor splits a product's presence on a channel into two separate records. ProductChannelListing says the product itself is published and visible on that channel. ProductVariantChannelListing is a different row, one per variant and channel, and it is the only place a price lives. Nothing forces these two to be created together.

When you create a variant, whether by hand through the dashboard, with a single productVariantCreate call, with productVariantBulkCreate for a batch, or through a CSV importer that wraps one of those mutations, Saleor writes the ProductVariant row and stops there unless you also pass a channelListings input in the same call. Plenty of scripts and import tools do not. They create the variant, maybe set stock, and move on, assuming a later step or the dashboard default will handle pricing. It usually does not happen automatically, because Saleor has no default price to fall back on. The result is a variant that is technically part of a published product, has no price on any channel, and is invisible to a storefront cart even though the product page itself renders fine.

Variant created productVariantCreate / bulk / CSV ProductVariant row exists in the catalog no channelListings step No ChannelListing zero rows, no price Unsell- able meanwhile, the product still shows Published
The variant is real and part of a published product, but nothing wrote a price for it on any channel, so it cannot be sold.

Why it happens

None of these throw an error at creation time. The mutation succeeds, the variant shows up in the dashboard, and the product's own ProductChannelListing can still say published, because that flag was set earlier and does not depend on any variant having a price. The only way to notice is to check every variant's channel listings against what its product claims to be published on.

The key insight

Publication and pricing are two different records in Saleor, and creating a variant never assumes a price for you. The safe pattern is not to publish variants blind. It is to compare, for every variant, the channels its product is published on against the channels the variant actually has a ProductVariantChannelListing row for, and treat any channel present on one side but missing on the other as a gap. A price only gets written once you have a source you trust, a sibling variant's price on the same channel or a configured default, never a guess.

The fix, as a flow

The script pages through variants on a channel, reading back the product's own channel listings alongside the variant's channel listings. For each variant it computes which of the product's published channels the variant has no listing for. Under DRY_RUN, the default, it only reports those variant IDs, SKUs, and missing channels. When you turn it off, it looks for a price from a sibling variant on the same product and channel, or a configured default price, and calls productVariantChannelListingUpdate once per missing channel. If no safe price is available anywhere, it skips the write and flags that variant for manual pricing instead of inventing a number.

Scheduled job runs on a timer Page productVariants plus product listings Diff product channels vs variant channels channel missing? yes no, fully listed Report, or repair with sibling price productVariantChannelListingUpdate
The script always reports the gap first. It only repairs with productVariantChannelListingUpdate when DRY_RUN is off and a safe price is available, and skips and flags a variant otherwise.

Build it step by step

1

Get an app token with read and write access to products

Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read products and channels, plus write access to products if you plan to run the repair. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. 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="your-app-or-staff-token"
export SALEOR_CHANNEL="default-channel"
export DRY_RUN="true"   # start safe, this script never writes without it off
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export SALEOR_CHANNEL="default-channel"
export DRY_RUN="true"   // start safe, this script never writes without it off
2

Talk to the Saleor GraphQL API

Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.

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

Page through variants with product and variant channel listings

Ask for productVariants filtered to a channel, reading back both the product's own channelListings, which shows every channel it is published on, and the variant's own channelListings, which shows every channel it actually has a price for. Page with a cursor so the job handles a full catalog.

step3.py
VARIANTS_QUERY = """
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        product {
          id
          name
          channelListings { channel { slug } isPublished }
        }
        channelListings { channel { slug } price { amount currency } }
      }
    }
  }
}"""

def variant_snapshot(channel):
    cursor = None
    rows = []
    while True:
        data = gql(VARIANTS_QUERY, {"channel": channel, "cursor": cursor})["productVariants"]
        for edge in data["edges"]:
            node = edge["node"]
            rows.append({
                "id": node["id"],
                "sku": node["sku"],
                "productChannelSlugs": [
                    cl["channel"]["slug"] for cl in node["product"]["channelListings"] if cl["isPublished"]
                ],
                "variantChannelSlugs": [cl["channel"]["slug"] for cl in node["channelListings"]],
            })
        if not data["pageInfo"]["hasNextPage"]:
            return rows
        cursor = data["pageInfo"]["endCursor"]
step3.js
const VARIANTS_QUERY = `
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        product {
          id
          name
          channelListings { channel { slug } isPublished }
        }
        channelListings { channel { slug } price { amount currency } }
      }
    }
  }
}`;

async function variantSnapshot(channel) {
  let cursor = null;
  const rows = [];
  while (true) {
    const data = (await gql(VARIANTS_QUERY, { channel, cursor })).productVariants;
    for (const edge of data.edges) {
      const node = edge.node;
      rows.push({
        id: node.id,
        sku: node.sku,
        productChannelSlugs: node.product.channelListings
          .filter((cl) => cl.isPublished)
          .map((cl) => cl.channel.slug),
        variantChannelSlugs: node.channelListings.map((cl) => cl.channel.slug),
      });
    }
    if (!data.pageInfo.hasNextPage) return rows;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the plain variant rows from the snapshot and returns only the ones missing a listing. A pure function like this is easy to read and test, which we do later. For each variant it computes missingChannels as the product's published channel slugs that are not present in the variant's own channel slugs, and only variants with at least one missing channel are returned.

decide.py
def find_variants_missing_channel_listing(variants):
    flagged = []
    for variant in variants:
        product_slugs = variant["productChannelSlugs"]
        variant_slugs = set(variant["variantChannelSlugs"])
        missing = [slug for slug in product_slugs if slug not in variant_slugs]
        if missing:
            flagged.append({"id": variant["id"], "sku": variant["sku"], "missingChannels": missing})
    return flagged
decide.js
export function findVariantsMissingChannelListing(variants) {
  const flagged = [];
  for (const variant of variants) {
    const variantSlugs = new Set(variant.variantChannelSlugs);
    const missingChannels = variant.productChannelSlugs.filter((slug) => !variantSlugs.has(slug));
    if (missingChannels.length > 0) {
      flagged.push({ id: variant.id, sku: variant.sku, missingChannels });
    }
  }
  return flagged;
}
5

Source a safe price before writing anything

Never invent a price. For each flagged variant and missing channel, look for a sibling variant on the same product that already has a price on that channel, and reuse it. If no sibling has one, fall back to a configured default price for that channel. If neither exists, do not write, skip the channel and keep it in the report for a human to price by hand.

price_source.py
def find_sibling_price(product_id, channel_slug, product_variants_index):
    for sibling in product_variants_index.get(product_id, []):
        for listing in sibling.get("channelListingsRaw", []):
            if listing["channel"]["slug"] == channel_slug and listing.get("price"):
                return listing["price"]["amount"]
    return None

def resolve_price(product_id, channel_slug, product_variants_index, default_prices):
    sibling_price = find_sibling_price(product_id, channel_slug, product_variants_index)
    if sibling_price is not None:
        return sibling_price
    return default_prices.get(channel_slug)
price-source.js
export function findSiblingPrice(productId, channelSlug, productVariantsIndex) {
  const siblings = productVariantsIndex[productId] || [];
  for (const sibling of siblings) {
    for (const listing of sibling.channelListingsRaw || []) {
      if (listing.channel.slug === channelSlug && listing.price) {
        return listing.price.amount;
      }
    }
  }
  return null;
}

export function resolvePrice(productId, channelSlug, productVariantsIndex, defaultPrices) {
  const siblingPrice = findSiblingPrice(productId, channelSlug, productVariantsIndex);
  if (siblingPrice !== null) return siblingPrice;
  return defaultPrices[channelSlug] ?? null;
}
6

Report under dry run, repair when it is off

Under DRY_RUN=true, the default, the script only logs each flagged variant's ID, SKU, and missing channels. When DRY_RUN=false, for every missing channel it resolves a price, and only when one is found does it call productVariantChannelListingUpdate with the channel ID, price, and cost price. Channels with no safe price found are skipped and reported separately for manual pricing.

Run it safe

Never guess a price to unblock a sale. This script only writes productVariantChannelListingUpdate when a sibling variant on the same product and channel already has a trustworthy price, or a configured default exists for that channel. Anything else stays a report, not a write, until a human sets the number.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through variants on a channel, flags every variant missing a channel listing the product is published on, and only writes a repair when a safe price is available and DRY_RUN is off.

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.
fix_missing_channel_listing.py
"""Find Saleor variants that have zero ProductVariantChannelListing rows
for a channel their own product is published on (saleor/saleor discussions
#9731, #9422, and issue #8589). productVariantCreate, productVariantBulkCreate,
and CSV importers can all create a variant without attaching a channel price,
leaving it unsellable while the product still looks published.

This script never guesses a price. Under DRY_RUN=true (the default) it only
reports flagged variants and their missing channels. When DRY_RUN=false it
looks for a price from a sibling variant on the same product and channel, or
a configured default, and calls productVariantChannelListingUpdate only when
one of those exists. Channels with no safe price are skipped and reported.
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("fix_missing_channel_listing")

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

VARIANTS_QUERY = """
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        product {
          id
          name
          channelListings { channel { id slug } isPublished }
        }
        channelListings { channel { id slug } price { amount currency } }
      }
    }
  }
}"""

CHANNEL_LISTING_UPDATE = """
mutation($id: ID!, $input: [ProductVariantChannelListingAddInput!]!) {
  productVariantChannelListingUpdate(id: $id, input: $input) {
    variant { id }
    errors { field message code }
  }
}"""


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_variants_missing_channel_listing(variants):
    flagged = []
    for variant in variants:
        product_slugs = variant["productChannelSlugs"]
        variant_slugs = set(variant["variantChannelSlugs"])
        missing = [slug for slug in product_slugs if slug not in variant_slugs]
        if missing:
            flagged.append({"id": variant["id"], "sku": variant["sku"], "missingChannels": missing})
    return flagged


def find_sibling_price(product_id, channel_slug, product_variants_index):
    for sibling in product_variants_index.get(product_id, []):
        for listing in sibling.get("channelListingsRaw", []):
            if listing["channel"]["slug"] == channel_slug and listing.get("price"):
                return listing["price"]["amount"]
    return None


def resolve_price(product_id, channel_slug, product_variants_index, default_prices):
    sibling_price = find_sibling_price(product_id, channel_slug, product_variants_index)
    if sibling_price is not None:
        return sibling_price
    return default_prices.get(channel_slug)


def variant_snapshot(channel):
    cursor = None
    rows = []
    raw_by_product = {}
    channel_ids_by_slug = {}
    while True:
        data = gql(VARIANTS_QUERY, {"channel": channel, "cursor": cursor})["productVariants"]
        for edge in data["edges"]:
            node = edge["node"]
            product_id = node["product"]["id"]
            for cl in node["product"]["channelListings"]:
                channel_ids_by_slug[cl["channel"]["slug"]] = cl["channel"]["id"]
            for cl in node["channelListings"]:
                channel_ids_by_slug[cl["channel"]["slug"]] = cl["channel"]["id"]
            raw_by_product.setdefault(product_id, []).append({
                "sku": node["sku"],
                "channelListingsRaw": node["channelListings"],
            })
            rows.append({
                "id": node["id"],
                "sku": node["sku"],
                "productId": product_id,
                "productChannelSlugs": [
                    cl["channel"]["slug"] for cl in node["product"]["channelListings"] if cl["isPublished"]
                ],
                "variantChannelSlugs": [cl["channel"]["slug"] for cl in node["channelListings"]],
            })
        if not data["pageInfo"]["hasNextPage"]:
            return rows, raw_by_product, channel_ids_by_slug
        cursor = data["pageInfo"]["endCursor"]


def apply_listing(variant_id, channel_id, price, cost_price=None):
    entry = {"channelId": channel_id, "price": price}
    if cost_price is not None:
        entry["costPrice"] = cost_price
    result = gql(CHANNEL_LISTING_UPDATE, {"id": variant_id, "input": [entry]})["productVariantChannelListingUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["variant"]


def run(default_prices=None):
    default_prices = default_prices or {}
    variants, raw_by_product, channel_ids_by_slug = variant_snapshot(CHANNEL)
    flagged = find_variants_missing_channel_listing(variants)
    by_id = {v["id"]: v for v in variants}

    if DRY_RUN:
        for row in flagged:
            log.warning("MISSING sku=%s variant=%s missing_channels=%s", row["sku"], row["id"], row["missingChannels"])
        log.info("Done (dry run). %d variant(s) missing at least one channel listing.", len(flagged))
        return flagged

    repaired = 0
    for row in flagged:
        product_id = by_id[row["id"]]["productId"]
        for slug in row["missingChannels"]:
            price = resolve_price(product_id, slug, raw_by_product, default_prices)
            channel_id = channel_ids_by_slug.get(slug)
            if price is None or channel_id is None:
                log.info("Skipping %s on %s, no safe price found. Flag for manual pricing.", row["sku"], slug)
                continue
            apply_listing(row["id"], channel_id, price)
            log.info("Listed %s on %s at %s.", row["sku"], slug, price)
            repaired += 1

    log.info("Done. %d variant(s) flagged, %d channel listing(s) repaired.", len(flagged), repaired)
    return flagged


if __name__ == "__main__":
    run()
fix-missing-channel-listing.js
/**
 * Find Saleor variants that have zero ProductVariantChannelListing rows
 * for a channel their own product is published on (saleor/saleor discussions
 * #9731, #9422, and issue #8589). productVariantCreate, productVariantBulkCreate,
 * and CSV importers can all create a variant without attaching a channel price,
 * leaving it unsellable while the product still looks published.
 *
 * This script never guesses a price. Under DRY_RUN=true (the default) it only
 * reports flagged variants and their missing channels. When DRY_RUN=false it
 * looks for a price from a sibling variant on the same product and channel, or
 * a configured default, and calls productVariantChannelListingUpdate only when
 * one of those exists. Channels with no safe price are skipped and reported.
 * Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/saleor/variant-missing-channel-listing/
 */
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-token";
const CHANNEL = process.env.SALEOR_CHANNEL || "default-channel";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function findVariantsMissingChannelListing(variants) {
  const flagged = [];
  for (const variant of variants) {
    const variantSlugs = new Set(variant.variantChannelSlugs);
    const missingChannels = variant.productChannelSlugs.filter((slug) => !variantSlugs.has(slug));
    if (missingChannels.length > 0) {
      flagged.push({ id: variant.id, sku: variant.sku, missingChannels });
    }
  }
  return flagged;
}

export function findSiblingPrice(productId, channelSlug, productVariantsIndex) {
  const siblings = productVariantsIndex[productId] || [];
  for (const sibling of siblings) {
    for (const listing of sibling.channelListingsRaw || []) {
      if (listing.channel.slug === channelSlug && listing.price) {
        return listing.price.amount;
      }
    }
  }
  return null;
}

export function resolvePrice(productId, channelSlug, productVariantsIndex, defaultPrices) {
  const siblingPrice = findSiblingPrice(productId, channelSlug, productVariantsIndex);
  if (siblingPrice !== null) return siblingPrice;
  return defaultPrices[channelSlug] ?? null;
}

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;
}

const VARIANTS_QUERY = `
query($channel: String, $cursor: String) {
  productVariants(first: 100, after: $cursor, channel: $channel) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        product {
          id
          name
          channelListings { channel { id slug } isPublished }
        }
        channelListings { channel { id slug } price { amount currency } }
      }
    }
  }
}`;

const CHANNEL_LISTING_UPDATE = `
mutation($id: ID!, $input: [ProductVariantChannelListingAddInput!]!) {
  productVariantChannelListingUpdate(id: $id, input: $input) {
    variant { id }
    errors { field message code }
  }
}`;

async function variantSnapshot(channel) {
  let cursor = null;
  const rows = [];
  const rawByProduct = {};
  const channelIdsBySlug = {};
  while (true) {
    const data = (await gql(VARIANTS_QUERY, { channel, cursor })).productVariants;
    for (const edge of data.edges) {
      const node = edge.node;
      const productId = node.product.id;
      for (const cl of node.product.channelListings) channelIdsBySlug[cl.channel.slug] = cl.channel.id;
      for (const cl of node.channelListings) channelIdsBySlug[cl.channel.slug] = cl.channel.id;
      if (!rawByProduct[productId]) rawByProduct[productId] = [];
      rawByProduct[productId].push({ sku: node.sku, channelListingsRaw: node.channelListings });
      rows.push({
        id: node.id,
        sku: node.sku,
        productId,
        productChannelSlugs: node.product.channelListings
          .filter((cl) => cl.isPublished)
          .map((cl) => cl.channel.slug),
        variantChannelSlugs: node.channelListings.map((cl) => cl.channel.slug),
      });
    }
    if (!data.pageInfo.hasNextPage) return { rows, rawByProduct, channelIdsBySlug };
    cursor = data.pageInfo.endCursor;
  }
}

async function applyListing(variantId, channelId, price) {
  const entry = { channelId, price };
  const result = (await gql(CHANNEL_LISTING_UPDATE, { id: variantId, input: [entry] })).productVariantChannelListingUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.variant;
}

export async function run(defaultPrices = {}) {
  const { rows, rawByProduct, channelIdsBySlug } = await variantSnapshot(CHANNEL);
  const flagged = findVariantsMissingChannelListing(rows);
  const byId = Object.fromEntries(rows.map((v) => [v.id, v]));

  if (DRY_RUN) {
    for (const row of flagged) {
      console.warn(`MISSING sku=${row.sku} variant=${row.id} missing_channels=${row.missingChannels.join(",")}`);
    }
    console.log(`Done (dry run). ${flagged.length} variant(s) missing at least one channel listing.`);
    return flagged;
  }

  let repaired = 0;
  for (const row of flagged) {
    const productId = byId[row.id].productId;
    for (const slug of row.missingChannels) {
      const price = resolvePrice(productId, slug, rawByProduct, defaultPrices);
      const channelId = channelIdsBySlug[slug];
      if (price === null || !channelId) {
        console.log(`Skipping ${row.sku} on ${slug}, no safe price found. Flag for manual pricing.`);
        continue;
      }
      await applyListing(row.id, channelId, price);
      console.log(`Listed ${row.sku} on ${slug} at ${price}.`);
      repaired++;
    }
  }

  console.log(`Done. ${flagged.length} variant(s) flagged, ${repaired} channel listing(s) repaired.`);
  return flagged;
}

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 which variants get flagged and, eventually, repaired. Because find_variants_missing_channel_listing is pure, the test needs no network and no Saleor account. It just feeds in plain variant rows and checks the answer.

test_variant_channel_listing.py
from fix_missing_channel_listing import find_variants_missing_channel_listing


def variant(**over):
    base = {
        "id": "gid://saleor/ProductVariant/1",
        "sku": "SKU-1",
        "productChannelSlugs": ["default-channel", "us"],
        "variantChannelSlugs": ["default-channel"],
    }
    base.update(over)
    return base


def test_flags_variant_missing_a_channel():
    result = find_variants_missing_channel_listing([variant()])
    assert result == [{"id": "gid://saleor/ProductVariant/1", "sku": "SKU-1", "missingChannels": ["us"]}]


def test_no_flag_when_fully_listed():
    result = find_variants_missing_channel_listing([
        variant(variantChannelSlugs=["default-channel", "us"])
    ])
    assert result == []


def test_no_flag_when_product_has_no_published_channels():
    result = find_variants_missing_channel_listing([
        variant(productChannelSlugs=[], variantChannelSlugs=[])
    ])
    assert result == []


def test_flags_variant_with_zero_channel_listings():
    result = find_variants_missing_channel_listing([
        variant(variantChannelSlugs=[])
    ])
    assert result == [{"id": "gid://saleor/ProductVariant/1", "sku": "SKU-1", "missingChannels": ["default-channel", "us"]}]


def test_multiple_variants_mixed_results():
    ok = variant(id="gid://saleor/ProductVariant/2", sku="SKU-2", variantChannelSlugs=["default-channel", "us"])
    bad = variant(id="gid://saleor/ProductVariant/3", sku="SKU-3", variantChannelSlugs=[])
    result = find_variants_missing_channel_listing([ok, bad])
    assert result == [{"id": "gid://saleor/ProductVariant/3", "sku": "SKU-3", "missingChannels": ["default-channel", "us"]}]
variant-channel-listing.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findVariantsMissingChannelListing } from "./fix-missing-channel-listing.js";

const variant = (over = {}) => ({
  id: "gid://saleor/ProductVariant/1",
  sku: "SKU-1",
  productChannelSlugs: ["default-channel", "us"],
  variantChannelSlugs: ["default-channel"],
  ...over,
});

test("flags variant missing a channel", () => {
  const result = findVariantsMissingChannelListing([variant()]);
  assert.deepEqual(result, [{ id: "gid://saleor/ProductVariant/1", sku: "SKU-1", missingChannels: ["us"] }]);
});

test("no flag when fully listed", () => {
  const result = findVariantsMissingChannelListing([
    variant({ variantChannelSlugs: ["default-channel", "us"] }),
  ]);
  assert.deepEqual(result, []);
});

test("no flag when product has no published channels", () => {
  const result = findVariantsMissingChannelListing([
    variant({ productChannelSlugs: [], variantChannelSlugs: [] }),
  ]);
  assert.deepEqual(result, []);
});

test("flags variant with zero channel listings", () => {
  const result = findVariantsMissingChannelListing([variant({ variantChannelSlugs: [] })]);
  assert.deepEqual(result, [
    { id: "gid://saleor/ProductVariant/1", sku: "SKU-1", missingChannels: ["default-channel", "us"] },
  ]);
});

test("multiple variants mixed results", () => {
  const ok = variant({ id: "gid://saleor/ProductVariant/2", sku: "SKU-2", variantChannelSlugs: ["default-channel", "us"] });
  const bad = variant({ id: "gid://saleor/ProductVariant/3", sku: "SKU-3", variantChannelSlugs: [] });
  const result = findVariantsMissingChannelListing([ok, bad]);
  assert.deepEqual(result, [
    { id: "gid://saleor/ProductVariant/3", sku: "SKU-3", missingChannels: ["default-channel", "us"] },
  ]);
});

Case studies

Bulk import

A CSV import left two hundred new SKUs unsellable

An apparel brand imported a new season through a CSV that created products and variants for every size and color combination using productVariantBulkCreate, mapping SKU, stock, and attributes but not price, planning to price the whole season in a second pass through the dashboard. That second pass covered the parent products but silently skipped a couple hundred variants nested three levels down in the catalog view, and the storefront quietly rendered those sizes as unavailable.

Running the detector against the channel the season launched on flagged every one of those variants by SKU with its missing channel in a single report. The team resolved most through the sibling price rule, since other sizes of the same product already carried the season's price, and manually priced only the handful where a product had no priced sibling at all.

Multi-channel expansion

A new channel launch missed half the catalog's variants

A retailer expanded from one channel to a second regional channel and ran a migration that published every product on the new channel through productChannelListingUpdate, believing that step alone made the catalog live. Products showed up and looked published, but a large slice of variants had never received a ProductVariantChannelListing row for the new channel specifically, so browsing worked but adding those variants to a cart failed.

The reconciler run against the new channel's slug surfaced the exact gap: products published, variants unlisted. Because most variants had a sibling already priced on the original channel, the repair carried that price straight across to the new channel for the majority of SKUs, with only currency-sensitive items flagged for manual review.

What good looks like

After this runs on a schedule, a variant that slipped through creation without a channel listing gets caught the same day instead of being discovered when a customer cannot check out or a merchandiser notices a sales report with a suspicious zero. The team gets the exact variant, SKU, and missing channels to work from, and a price only ever gets applied from a source someone already trusted, a sibling variant or a configured default, never a script's guess.

FAQ

Why does a new Saleor variant show as unsellable even though the product is published?

Creating a variant only writes the ProductVariant row. It becomes sellable on a channel only once a separate ProductVariantChannelListing row exists for that channel with a price. Product publication and variant pricing are tracked independently, so a productVariantCreate, productVariantBulkCreate, or CSV import that skips the channel listing step leaves the product looking published while the variant has no price anywhere and cannot be added to a cart.

What is the difference between ProductChannelListing and ProductVariantChannelListing?

ProductChannelListing controls whether the product itself is published and visible on a channel. ProductVariantChannelListing is a separate row per variant and channel that carries the price and cost price. A product can be published on a channel while one or more of its variants have zero ProductVariantChannelListing rows, which makes those specific variants invisible to purchase even though the product page loads.

Is it safe to script a fix for variants missing a channel listing?

Detecting the gap is safe, it only reads productVariants and channelListings. Writing a price is not something a script should guess. The safe pattern is to source the price from a sibling variant already listed on the same product and channel, or a configured default, and call productVariantChannelListingUpdate only for that missing channel. When no safe price exists, skip the write and report the variant for manual pricing instead of guessing a number.

Related field notes

Citations

On the problem:

  1. Variants without any channel reference. github.com/saleor/saleor/discussions/9731
  2. Make variants after initial creation. github.com/saleor/saleor/issues/8589
  3. Creating a new product and default variant while importing from CSV. github.com/saleor/saleor/discussions/9422

On the solution:

  1. Saleor Commerce Documentation: productVariantChannelListingUpdate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-channel-listing-update
  2. Saleor Commerce Documentation: productVariantBulkCreate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-bulk-create
  3. Saleor Commerce Documentation: ProductVariantChannelListing Object. docs.saleor.io/api-reference/products/objects/product-variant-channel-listing

Stuck on a tricky one?

If you have a problem in Saleor checkout, stock, channels, 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 catch a listing gap for you?

If this saved you from a season launch with silent gaps, or gave your ops team the report they needed, 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