Skip to content

Reconciler Tax, Pricing & Migration

Bulk imported variants missing channel listings

You ran an import, thousands of variants came in clean, skus matched, attributes matched, stock landed in the right warehouses. Then someone notices a chunk of them are simply not buyable. No error, no crash, just a variant that never shows a price and never shows up at checkout. Here is why Saleor leaves freshly created variants unsellable by default and a script that finds every one of them and repairs only the ones it can safely price.

Python and Node.js Saleor GraphQL API Report first, price only from a real source
A supermarket aisle
Photo by Dominik on Unsplash
The short answer

In Saleor, creating a variant with productVariantCreate or productVariantBulkCreate does not make it sellable anywhere on its own. Channel listings, which hold price, cost price, and publication, live in a separate join-table entity, ProductVariantChannelListing, that must be set explicitly, either through the channelListings input on bulk create or a follow-up productVariantChannelListingUpdate call. Import scripts that only send sku, attributes, and stocks, or that assign listings to only some channels, or that hit a partial failure mid-batch, silently leave variants with zero channel listing rows. A variant without a channel listing for a channel has no price there, so it is excluded from checkout and storefront availability even though the parent product looks published. Run a small Python or Node.js script that diffs every variant against the channels its product is listed in, reports the gaps, and, only with a real price source and dry run off, fills them in with productVariantChannelListingUpdate. Full code, tests, and a dry run guard are below.

The problem in plain words

Creating a product variant and making it sellable are two different actions in Saleor, even though a lot of import tooling treats them as one step. productVariantCreate and productVariantBulkCreate take the variant's own data, its sku, its attributes, and the stock rows for each warehouse, and write a ProductVariant row. That variant now exists. It can be looked up by id, it shows up in the product's variant list, and depending on your dashboard view it can even look complete.

What it does not have yet is a price anywhere. Price, cost price, and whether the variant is published live on ProductVariantChannelListing, a separate row per channel that has to be created on purpose, either by passing channelListings in the same bulk create call or by calling productVariantChannelListingUpdate afterward. A bulk import script that focuses on getting the catalog data right, sku, name, attributes, stock, and treats channel pricing as an afterthought, or that only wires up pricing for one channel while the product sells in three, or that simply hits a partial failure partway through a large batch, ends up with variants that have zero listing rows for one or more channels. The product still looks published. The variant is just invisible where it matters, in checkout and on the storefront, because there is no price to show.

Bulk import sku, attributes, stocks Variant created ProductVariant row exists channelListings step skipped or half-done Zero channel listing rows no price anywhere Invisible at checkout product looks fine
The variant exists and the product still looks published. The gap is invisible until a shopper tries to buy that exact variant and there is no price to show.

Why it happens

None of this throws an exception during import. The batch reports success, the product page in the dashboard looks complete, and the only sign anything is wrong is a support ticket about a variant that will not add to cart, or a quiet gap in sales for a specific option.

The key insight

A product being published in a channel tells you nothing about whether a specific variant is sellable there. Sellability lives on the variant's own channelListings row, one per channel, and that row simply does not exist unless something wrote it. So the reliable check is not "does the product look published," it is "for every channel the product is listed in, does this variant have a matching channel listing." Anything short of that comparison, including trusting a null pricing field on its own, can mislead you, since null pricing can also mean you queried without a channel argument.

The fix, as a flow

The script pages through a product's variants, reads the channels the product itself is listed in, and reads each variant's own channelListings. For every variant and channel pair where the product is listed but the variant has no listing, that pair is a gap. Under dry run, the script only reports the gaps. With dry run off, it fills in a listing only when a price is available from a trusted source, the product's base price or a supplied price map, and never invents one. If no price source exists for a gap, it stays a report, not a guess.

List variants and their channel listings Read product's channel slugs it is listed in Diff listed vs variant's own listings Missing and priceable? yes, dry run off no, report only Channel listing update, priced never a guessed price
Every gap is reported first. The script only writes a channel listing when a real price source covers it, and it never invents a number.

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 and write products, 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 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 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 their channel listings

Ask for the product's own channel listings, which give you the channels it is actually listed in, and each variant's own channel listings, which give you what it already has priced. Page with a cursor so the script handles a large catalog from a big import.

step3.py
VARIANTS_QUERY = """
query($id: ID!, $cursor: String) {
  product(id: $id) {
    id
    name
    channelListings { channel { slug } }
    variants {
      id
      sku
      channelListings { channel { slug } price { amount currency } }
    }
  }
}"""

def product_with_variants(product_id):
    data = gql(VARIANTS_QUERY, {"id": product_id, "cursor": None})["product"]
    product_channel_slugs = [cl["channel"]["slug"] for cl in data["channelListings"]]
    variant_channel_listings = {
        v["id"]: [cl["channel"]["slug"] for cl in v["channelListings"]]
        for v in data["variants"]
    }
    return product_channel_slugs, variant_channel_listings, data["variants"]
step3.js
const VARIANTS_QUERY = `
query($id: ID!) {
  product(id: $id) {
    id
    name
    channelListings { channel { slug } }
    variants {
      id
      sku
      channelListings { channel { slug } price { amount currency } }
    }
  }
}`;

async function productWithVariants(productId) {
  const data = (await gql(VARIANTS_QUERY, { id: productId })).product;
  const productChannelSlugs = data.channelListings.map((cl) => cl.channel.slug);
  const variantChannelListings = {};
  for (const v of data.variants) {
    variantChannelListings[v.id] = v.channelListings.map((cl) => cl.channel.slug);
  }
  return { productChannelSlugs, variantChannelListings, variants: data.variants };
}
4

Decide, with one pure function

Keep the decision in its own function that takes the imported variant ids, a map of each variant's current channel listing slugs, and the full list of channel slugs the product is listed in, then returns the missing channels per variant. No I/O, so it is easy to test with plain dicts and lists.

decide.py
def find_missing_channel_listings(imported_variant_ids, variant_channel_listings, product_channel_slugs):
    result = {}
    for variant_id in imported_variant_ids:
        have = set(variant_channel_listings.get(variant_id, []))
        missing = set(product_channel_slugs) - have
        if missing:
            result[variant_id] = sorted(missing)
    return result
decide.js
export function findMissingChannelListings(importedVariantIds, variantChannelListings, productChannelSlugs) {
  const result = {};
  for (const variantId of importedVariantIds) {
    const have = new Set(variantChannelListings[variantId] || []);
    const missing = productChannelSlugs.filter((slug) => !have.has(slug)).sort();
    if (missing.length) {
      result[variantId] = missing;
    }
  }
  return result;
}
5

Fill the gap only from a real price source

When dry run is off and a flagged variant and channel pair has a price available, from the product's base price or a supplied price map, call productVariantChannelListingUpdate to create the missing listing. If no price source covers a gap, skip the write and leave it in the report. Always read back errors.

apply.py
CHANNEL_LISTING_UPDATE = """
mutation($id: ID!, $input: [ProductVariantChannelListingAddInput!]!) {
  productVariantChannelListingUpdate(id: $id, input: $input) {
    variant { id channelListings { channel { slug } price { amount } } }
    errors { field message code }
  }
}"""

def fill_missing_listing(variant_id, channel_id, price):
    result = gql(CHANNEL_LISTING_UPDATE, {
        "id": variant_id,
        "input": [{"channelId": channel_id, "price": price}],
    })["productVariantChannelListingUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["variant"]
apply.js
const CHANNEL_LISTING_UPDATE = `
mutation($id: ID!, $input: [ProductVariantChannelListingAddInput!]!) {
  productVariantChannelListingUpdate(id: $id, input: $input) {
    variant { id channelListings { channel { slug } price { amount } } }
    errors { field message code }
  }
}`;

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

Wire it together, report first, price only when you can

The loop ties it together. Under DRY_RUN=true, the default, the script only logs each variant id and its missing channel slugs. With DRY_RUN=false, it calls productVariantChannelListingUpdate for each gap, but only when the channel and slug have a price in your supplied price map or the product's base price. A gap with no price source stays reported, never guessed.

Run it safe

Never invent a price to close a gap automatically. A missing channel listing without a price source is a business decision waiting on a human, not a technical default the script should assume. Always start with DRY_RUN=true, review the exact variant and channel gaps, and only enable writes once you have a trustworthy price for every gap you intend to fill.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through a product's variants and their channel listings, finds the gaps with the pure function, and only fills a gap when a 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.
find_missing_listings.py
"""Find Saleor variants that a bulk import left with no channel listing at
all for one or more of the product's channels.

productVariantCreate and productVariantBulkCreate do not automatically make
a variant sellable anywhere. Channel listings, price, cost price, and
publication, live in a separate ProductVariantChannelListing row that must
be set explicitly, either via the channelListings input on bulk create or a
follow-up productVariantChannelListingUpdate call. Import scripts that only
send sku, attributes, and stocks, or that partially fail mid-batch, can
leave a variant with zero listing rows, so it never shows a price and never
appears in checkout even though the product looks published
(saleor/saleor discussion #9731, saleor/saleor#8589).

This script never invents a price. Under DRY_RUN=true (the default) it only
reports the variant and channel gaps. When DRY_RUN=false it fills a gap only
when a price is available from a supplied price map, never guessing.
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("find_missing_listings")

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PRODUCT_QUERY = """
query($id: ID!) {
  product(id: $id) {
    id
    name
    channelListings { channel { id slug } }
    variants {
      id
      sku
      channelListings { channel { slug } price { amount currency } }
    }
  }
}"""

CHANNEL_LISTING_UPDATE = """
mutation($id: ID!, $input: [ProductVariantChannelListingAddInput!]!) {
  productVariantChannelListingUpdate(id: $id, input: $input) {
    variant { id channelListings { channel { slug } price { amount } } }
    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_missing_channel_listings(imported_variant_ids, variant_channel_listings, product_channel_slugs):
    result = {}
    for variant_id in imported_variant_ids:
        have = set(variant_channel_listings.get(variant_id, []))
        missing = set(product_channel_slugs) - have
        if missing:
            result[variant_id] = sorted(missing)
    return result


def product_with_variants(product_id):
    data = gql(PRODUCT_QUERY, {"id": product_id})["product"]
    channel_ids_by_slug = {cl["channel"]["slug"]: cl["channel"]["id"] for cl in data["channelListings"]}
    product_channel_slugs = list(channel_ids_by_slug.keys())
    variant_channel_listings = {
        v["id"]: [cl["channel"]["slug"] for cl in v["channelListings"]]
        for v in data["variants"]
    }
    return product_channel_slugs, channel_ids_by_slug, variant_channel_listings, data["variants"]


def fill_missing_listing(variant_id, channel_id, price):
    result = gql(CHANNEL_LISTING_UPDATE, {
        "id": variant_id,
        "input": [{"channelId": channel_id, "price": price}],
    })["productVariantChannelListingUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["variant"]


def run(product_id, imported_variant_ids=None, price_map=None):
    price_map = price_map or {}
    product_channel_slugs, channel_ids_by_slug, variant_channel_listings, variants = product_with_variants(product_id)
    variant_ids = imported_variant_ids or [v["id"] for v in variants]

    gaps = find_missing_channel_listings(variant_ids, variant_channel_listings, product_channel_slugs)

    for variant_id, missing_slugs in gaps.items():
        log.warning("Variant %s missing channel listing for: %s", variant_id, ", ".join(missing_slugs))

    if not gaps:
        log.info("Done. No missing channel listings found.")
        return gaps

    if DRY_RUN:
        log.info("Done. %d variant(s) with gaps reported, dry run on.", len(gaps))
        return gaps

    filled = 0
    for variant_id, missing_slugs in gaps.items():
        for slug in missing_slugs:
            price = price_map.get((variant_id, slug))
            if price is None:
                log.warning("No price source for variant %s channel %s, skipping.", variant_id, slug)
                continue
            fill_missing_listing(variant_id, channel_ids_by_slug[slug], price)
            filled += 1
    log.info("Done. %d channel listing(s) filled from a real price source.", filled)
    return gaps


if __name__ == "__main__":
    run(os.environ.get("PRODUCT_ID", ""))
find-missing-listings.js
/**
 * Find Saleor variants that a bulk import left with no channel listing at
 * all for one or more of the product's channels.
 *
 * productVariantCreate and productVariantBulkCreate do not automatically
 * make a variant sellable anywhere. Channel listings, price, cost price,
 * and publication, live in a separate ProductVariantChannelListing row that
 * must be set explicitly, either via the channelListings input on bulk
 * create or a follow-up productVariantChannelListingUpdate call. Import
 * scripts that only send sku, attributes, and stocks, or that partially
 * fail mid-batch, can leave a variant with zero listing rows, so it never
 * shows a price and never appears in checkout even though the product
 * looks published (saleor/saleor discussion #9731, saleor/saleor#8589).
 *
 * This script never invents a price. Under DRY_RUN=true (the default) it
 * only reports the variant and channel gaps. When DRY_RUN=false it fills a
 * gap only when a price is available from a supplied price map, never
 * guessing. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/saleor/bulk-imported-variants-missing-channel-listings/
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function findMissingChannelListings(importedVariantIds, variantChannelListings, productChannelSlugs) {
  const result = {};
  for (const variantId of importedVariantIds) {
    const have = new Set(variantChannelListings[variantId] || []);
    const missing = productChannelSlugs.filter((slug) => !have.has(slug)).sort();
    if (missing.length) {
      result[variantId] = missing;
    }
  }
  return result;
}

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 PRODUCT_QUERY = `
query($id: ID!) {
  product(id: $id) {
    id
    name
    channelListings { channel { id slug } }
    variants {
      id
      sku
      channelListings { channel { slug } price { amount currency } }
    }
  }
}`;

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

async function productWithVariants(productId) {
  const data = (await gql(PRODUCT_QUERY, { id: productId })).product;
  const channelIdsBySlug = {};
  for (const cl of data.channelListings) channelIdsBySlug[cl.channel.slug] = cl.channel.id;
  const productChannelSlugs = Object.keys(channelIdsBySlug);
  const variantChannelListings = {};
  for (const v of data.variants) {
    variantChannelListings[v.id] = v.channelListings.map((cl) => cl.channel.slug);
  }
  return { productChannelSlugs, channelIdsBySlug, variantChannelListings, variants: data.variants };
}

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

export async function run(productId, importedVariantIds = null, priceMap = new Map()) {
  const { productChannelSlugs, channelIdsBySlug, variantChannelListings, variants } = await productWithVariants(productId);
  const variantIds = importedVariantIds || variants.map((v) => v.id);

  const gaps = findMissingChannelListings(variantIds, variantChannelListings, productChannelSlugs);

  for (const [variantId, missingSlugs] of Object.entries(gaps)) {
    console.warn(`Variant ${variantId} missing channel listing for: ${missingSlugs.join(", ")}`);
  }

  if (Object.keys(gaps).length === 0) {
    console.log("Done. No missing channel listings found.");
    return gaps;
  }

  if (DRY_RUN) {
    console.log(`Done. ${Object.keys(gaps).length} variant(s) with gaps reported, dry run on.`);
    return gaps;
  }

  let filled = 0;
  for (const [variantId, missingSlugs] of Object.entries(gaps)) {
    for (const slug of missingSlugs) {
      const price = priceMap.get(`${variantId}:${slug}`);
      if (price === undefined) {
        console.warn(`No price source for variant ${variantId} channel ${slug}, skipping.`);
        continue;
      }
      await fillMissingListing(variantId, channelIdsBySlug[slug], price);
      filled++;
    }
  }
  console.log(`Done. ${filled} channel listing(s) filled from a real price source.`);
  return gaps;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run(process.env.PRODUCT_ID || "").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 as unsellable and which are actually fine. Because find_missing_channel_listings is pure, the test needs no network and no Saleor account. It just feeds in plain dicts and lists and checks the answer.

test_bulk_imported_variants.py
from find_missing_listings import find_missing_channel_listings

V1 = "gid://saleor/ProductVariant/1"
V2 = "gid://saleor/ProductVariant/2"
V3 = "gid://saleor/ProductVariant/3"


def test_no_gap_when_variant_has_all_channels():
    variant_channel_listings = {V1: ["default-channel", "b2b"]}
    result = find_missing_channel_listings([V1], variant_channel_listings, ["default-channel", "b2b"])
    assert result == {}


def test_gap_when_variant_missing_one_channel():
    variant_channel_listings = {V1: ["default-channel"]}
    result = find_missing_channel_listings([V1], variant_channel_listings, ["default-channel", "b2b"])
    assert result == {V1: ["b2b"]}


def test_gap_when_variant_has_no_listings_at_all():
    variant_channel_listings = {}
    result = find_missing_channel_listings([V2], variant_channel_listings, ["default-channel"])
    assert result == {V2: ["default-channel"]}


def test_missing_slugs_are_sorted():
    variant_channel_listings = {V1: []}
    result = find_missing_channel_listings([V1], variant_channel_listings, ["b2b", "default-channel", "aa-region"])
    assert result[V1] == ["aa-region", "b2b", "default-channel"]


def test_multiple_variants_get_independent_results():
    variant_channel_listings = {V1: ["default-channel"], V2: ["default-channel", "b2b"]}
    result = find_missing_channel_listings([V1, V2, V3], variant_channel_listings, ["default-channel", "b2b"])
    assert result == {V1: ["b2b"], V3: ["b2b", "default-channel"]}


def test_only_flags_channels_the_product_is_actually_listed_in():
    variant_channel_listings = {V1: ["default-channel"]}
    result = find_missing_channel_listings([V1], variant_channel_listings, ["default-channel"])
    assert result == {}
missing-listings.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findMissingChannelListings } from "./find-missing-listings.js";

const V1 = "gid://saleor/ProductVariant/1";
const V2 = "gid://saleor/ProductVariant/2";
const V3 = "gid://saleor/ProductVariant/3";

test("no gap when variant has all channels", () => {
  const variantChannelListings = { [V1]: ["default-channel", "b2b"] };
  const result = findMissingChannelListings([V1], variantChannelListings, ["default-channel", "b2b"]);
  assert.deepEqual(result, {});
});

test("gap when variant missing one channel", () => {
  const variantChannelListings = { [V1]: ["default-channel"] };
  const result = findMissingChannelListings([V1], variantChannelListings, ["default-channel", "b2b"]);
  assert.deepEqual(result, { [V1]: ["b2b"] });
});

test("gap when variant has no listings at all", () => {
  const variantChannelListings = {};
  const result = findMissingChannelListings([V2], variantChannelListings, ["default-channel"]);
  assert.deepEqual(result, { [V2]: ["default-channel"] });
});

test("missing slugs are sorted", () => {
  const variantChannelListings = { [V1]: [] };
  const result = findMissingChannelListings([V1], variantChannelListings, ["b2b", "default-channel", "aa-region"]);
  assert.deepEqual(result[V1], ["aa-region", "b2b", "default-channel"]);
});

test("multiple variants get independent results", () => {
  const variantChannelListings = { [V1]: ["default-channel"], [V2]: ["default-channel", "b2b"] };
  const result = findMissingChannelListings([V1, V2, V3], variantChannelListings, ["default-channel", "b2b"]);
  assert.deepEqual(result, { [V1]: ["b2b"], [V3]: ["b2b", "default-channel"] });
});

test("only flags channels the product is actually listed in", () => {
  const variantChannelListings = { [V1]: ["default-channel"] };
  const result = findMissingChannelListings([V1], variantChannelListings, ["default-channel"]);
  assert.deepEqual(result, {});
});

Case studies

Multi-channel migration

Only the default channel got priced

A homeware retailer migrated forty thousand variants from a legacy platform with a script that had been written and tested against a single default channel. When the store later turned on a second, wholesale channel for the same catalog, the migration script was never updated to send channel listings for it, since it had already run and nobody thought to re-check.

Wholesale buyers reported that entire product lines would not add to cart, while the same products worked fine on the retail storefront. The reconciler above, run once per product against both channels, immediately surfaced every variant missing the wholesale listing, and the team filled the gap using the wholesale price list they already had on hand.

Partial batch failure

A rate limit cut a bulk create batch in half

An integration synced product variants from a PIM system using productVariantBulkCreate in batches of two hundred. One night, a batch hit a transient rate limit partway through, and roughly sixty variants in that batch were created without their channel listing rows, while the earlier ones in the same batch succeeded fully.

Nothing in the sync's own logs distinguished the two halves, since the batch as a whole reported success. Running the reconciler against the affected product ids the next morning found exactly the sixty gaps, and because the PIM's price feed was a trusted source, the team enabled writes and closed all of them in one run.

What good looks like

After this runs against every product an import touches, a variant that looks fine in the dashboard but cannot actually be bought gets caught before a customer or a sales report finds it first. The team sees the exact variant and channel gap, and any repair only ever uses a price you can stand behind, never a number the script made up. A published product and a sellable variant finally mean the same thing.

FAQ

Why are my bulk imported Saleor variants not showing up in the storefront?

Creating a variant with productVariantCreate or productVariantBulkCreate does not automatically make it sellable in any channel. Channel listings, which hold price, cost price, and publication, are a separate row that must be set explicitly through the channelListings input or a follow-up productVariantChannelListingUpdate call. If an import script only sends sku, attributes, and stocks, or a batch partially fails, the variant ends up with zero ProductVariantChannelListing rows and is invisible in checkout even though the product looks published.

How do I find Saleor variants that are missing a channel listing?

Query productVariants with channelListings { channel { slug } price { amount currency } } alongside the parent product's own channelListings to get the channels the product is actually listed in. For every variant, compare the set of channel slugs it has a listing for against the full set of channels the product is listed in. Any channel missing from that variant's listings is a gap. A null pricing field on the variant is a secondary hint, but the direct channelListings array diff is the authoritative check.

Is it safe to auto-create missing channel listings with a script?

Only when you have a real price source, such as the product's base price or a supplied price map, and only after a human turns off dry run. Never invent a price silently, since pricing is a business decision, not a technical default. When no price source is available for a flagged variant and channel, the script should fall back to reporting only rather than writing a guessed price.

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. GraphQL returns NULL for channel, pricing and others. github.com/saleor/saleor/discussions/13045

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: Bulk Product Import. docs.saleor.io/developer/bulks/bulk-products
  3. Saleor Commerce Documentation: ProductVariant Object. docs.saleor.io/api-reference/products/objects/product-variant

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 missing listing for you?

If this saved you a batch of invisible variants or an awkward support ticket, 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