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.
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.
Why it happens
- Variant creation and channel listing creation are genuinely separate mutations in Saleor's data model, and a bulk import script that copies its logic from a simpler single-channel test store can miss that the listing step is not implicit, a gap discussed directly in saleor/saleor discussion #9731.
- Import tooling that only sends base variant fields,
sku, attributes, andstocks, and omits thechannelListingsinput onproductVariantBulkCreate, or never follows up withproductVariantChannelListingUpdate, leaves the variant with no price row at all, a pattern tracked in requests like saleor/saleor#8589 for making variants sellable right after creation. - Multi-channel stores where the import wires up pricing for the channel the script was tested against but not the others the product is actually listed in, so the variant sells in one channel and is silently absent from the rest.
- A partial failure mid-batch, where some rows in a large bulk create succeed fully, listings included, and others do not, leaving an inconsistent subset of variants unsellable without any single hard error to point at.
- Querying without a channel context returns
pricingas null for reasons that have nothing to do with a missing listing, which has confused more than one investigation, as seen in saleor/saleor discussion #13045. That is why the channel listing array itself, not the pricing field, has to be the authoritative check.
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.
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.
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 and write products, 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 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 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 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.
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"]
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 };
}
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.
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
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;
}
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.
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"]
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;
}
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.
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.
"""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 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.
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 == {}
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
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.
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.
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:
- Variants without any channel reference. github.com/saleor/saleor/discussions/9731
- Make variants after initial creation. github.com/saleor/saleor/issues/8589
- GraphQL returns NULL for channel, pricing and others. github.com/saleor/saleor/discussions/13045
On the solution:
- Saleor Commerce Documentation: productVariantChannelListingUpdate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-channel-listing-update
- Saleor Commerce Documentation: Bulk Product Import. docs.saleor.io/developer/bulks/bulk-products
- 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.
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