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.
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.
Why it happens
- A direct
productVariantCreatecall that omits thechannelListingsinput, which is optional on that mutation, leaves the variant with no price anywhere, a gap raised in saleor/saleor discussion #9731. productVariantBulkCreateused to create many variants at once, where the script authors a loop of variant inputs but forgets to attach a per channel price to each one, so the whole batch lands unsellable at once.- A CSV or spreadsheet import that creates a product and a default variant in the same pass, discussed in saleor/saleor discussion #9422, where the importer's template has a column for stock and SKU but never maps a price column into a channel listing call.
- A workflow that intentionally creates variants ahead of pricing, planning to price them later in a second pass, as described in saleor/saleor#8589, where that second pass is a manual dashboard step that gets missed for a handful of SKUs in a large batch.
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.
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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
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.
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"]
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;
}
}
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.
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
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;
}
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.
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)
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;
}
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.
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.
"""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()
/**
* 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.
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"]}]
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
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.
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.
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:
- Variants without any channel reference. github.com/saleor/saleor/discussions/9731
- Make variants after initial creation. github.com/saleor/saleor/issues/8589
- Creating a new product and default variant while importing from CSV. github.com/saleor/saleor/discussions/9422
On the solution:
- Saleor Commerce Documentation: productVariantChannelListingUpdate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-channel-listing-update
- Saleor Commerce Documentation: productVariantBulkCreate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-bulk-create
- 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.
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