Diagnostic Products, Variants & Channels
Product published but invisible from a missing channel price
The dashboard says the product is published to the channel. The API even reports isPublished: true. And yet the storefront never shows it, and checkout will not sell it. In Saleor, publishing a product to a channel and pricing it for that channel are two different steps, and it is easy to finish one without the other. Here is why that gap is silent and a script that finds every product left in it.
Publishing a product or variant to a channel sets ProductChannelListing.isPublished, but selling it needs a separate ProductVariantChannelListing.price row for that same channel. A variant can be published without ever being priced, which happens easily when onboarding a channel or bulk importing. Saleor's pricing resolvers return null when no price exists, so the storefront quietly shows nothing and checkout cannot buy it, while the API still calls the product published. Run a Python or Node.js script that queries products with their channel listings and each variant's channel listings, cross references them by channel slug, and flags every variant that is published without a usable price. Full code, tests, and a dry run guard are below.
The problem in plain words
Saleor treats a product's presence on a channel as two independent facts. The first is visibility: ProductChannelListing.isPublished, set with productChannelListingUpdate, says whether the product should appear on that channel at all. The second is sellability: ProductVariantChannelListing.price, set with productVariantChannelListingUpdate, says what the variant costs on that channel.
Nothing in the API stops you from setting the first without the second. A product can be marked published for a channel while its variants were never given a price row for that channel, most often when a new channel is being onboarded or a bulk import script assigns and publishes products in bulk but skips the pricing step for some of them. Saleor does not raise an error here. The storefront-facing resolvers, ProductChannelListing.pricing and ProductVariantChannelListing.pricing, simply return null when there is no price row for the channel, and isAvailableForPurchase comes back empty too. The product looks published in the dashboard and in the API, but it cannot actually be shown or bought.
Why it happens
Publishing and pricing live in different mutations and different objects, so it is easy to complete one and forget the other. A few common ways teams end up here:
- Onboarding a new channel: products are bulk-assigned and published to the new channel, but the pricing step for that channel is done separately, or missed, for some variants.
- Bulk import scripts that call
productChannelListingUpdateto publish in one pass andproductVariantChannelListingUpdateto price in another, where the second pass fails partway or is never run for every variant (see saleor/saleor discussion #9731 on variants left without any channel reference, and issue #8589 on making variants after initial creation). - A variant created after the product was already published to a channel, where the new variant inherits the product's published status but has no channel listing of its own yet.
- Multi-channel setups where a variant is deliberately priced on one channel but the team assumes publishing to a second channel automatically carries a price over, which it does not (see discussion #9112 on the unclarity around multi channel setup).
None of these raise an error anywhere in the flow. The mutations succeed, the dashboard shows the product as published, and the gap only surfaces as a quiet absence on the storefront or a checkout that refuses to add the item. See the citations at the end for the exact threads and docs.
isPublished: true only means the product is allowed to appear on a channel. It does not mean the product can be shown or sold there. Those are two separate rows, ProductChannelListing for visibility and ProductVariantChannelListing for price, and only the second one determines whether pricing and isAvailableForPurchase come back with real data. A published listing with no price is not a bug in Saleor, it is an incomplete setup that the API will not flag for you.
The fix, as a flow
We do not touch checkout or storefront code. We query every product for the channel, alongside its channel listings and each variant's channel listings, cross reference them in memory by channel slug with one pure function, and report every variant that is published without a usable price. Setting the correct price is left to a human, since that is a business decision. Only unpublishing the broken listing so it stops misreporting is offered, gated behind a dry run and a confirmed human decision.
Build it step by step
Get an app token with product and channel scopes
Create an app in the Saleor dashboard, or use tokenCreate with staff credentials, and grant it permission to manage products and read channels. 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="..."
export DRY_RUN="true" # start safe, change to false to allow the optional unpublish
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true" // start safe, change to false to allow the optional unpublish
Talk to the Saleor GraphQL endpoint
Every call goes to the single GraphQL endpoint with your token in the Authorization: Bearer header. A small helper sends a query and returns the data, and raises if Saleor reports an error.
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;
}
Fetch products with channel listings and variant channel listings
Query products for the channel with their channelListings (channel slug, isPublished, isAvailableForPurchase, pricing) and each variant's own channelListings (channel slug and price). Page through with first and after so the job handles a large catalog.
PRODUCTS_QUERY = """
query($channel: String!, $cursor: String) {
products(channel: $channel, first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
channelListings { channel { slug } isPublished isAvailableForPurchase
pricing { priceRange { start { gross { amount currency } } } } }
variants {
id
name
channelListings { channel { slug } price { amount currency } costPrice { amount } }
}
}
}
}
}"""
def fetch_products(channel_slug):
cursor = None
products = []
while True:
data = gql(PRODUCTS_QUERY, {"channel": channel_slug, "cursor": cursor})["products"]
products.extend(e["node"] for e in data["edges"])
if not data["pageInfo"]["hasNextPage"]:
return products
cursor = data["pageInfo"]["endCursor"]
const PRODUCTS_QUERY = `
query($channel: String!, $cursor: String) {
products(channel: $channel, first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
channelListings { channel { slug } isPublished isAvailableForPurchase
pricing { priceRange { start { gross { amount currency } } } } }
variants {
id
name
channelListings { channel { slug } price { amount currency } costPrice { amount } }
}
}
}
}
}`;
async function fetchProducts(channelSlug) {
let cursor = null;
const products = [];
while (true) {
const data = (await gql(PRODUCTS_QUERY, { channel: channelSlug, cursor })).products;
for (const edge of data.edges) products.push(edge.node);
if (!data.pageInfo.hasNextPage) return products;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the products already fetched, with their channel listings and variant channel listings normalized to a channel slug plus a price amount, and returns the flagged list. A pure function like this is easy to read and easy to test, which we do later. For each product, for each channel listing where isPublished is true, look at every variant's channel listing for that same channel slug. If it is missing, the reason is missing_price. If it is present but the price amount is null, also missing_price. If the price amount is zero or negative, the reason is zero_price. Anything else is not flagged.
def find_mispriced_published_listings(products):
flagged = []
for product in products:
for listing in product.get("channelListings", []):
if not listing.get("isPublished"):
continue
channel_slug = listing["channelSlug"]
for variant in product.get("variants", []):
variant_listing = next(
(cl for cl in variant.get("channelListings", [])
if cl["channelSlug"] == channel_slug),
None,
)
if variant_listing is None or variant_listing.get("priceAmount") is None:
reason = "missing_price"
elif variant_listing["priceAmount"] <= 0:
reason = "zero_price"
else:
continue
flagged.append({
"productId": product["id"],
"variantId": variant["id"],
"channelSlug": channel_slug,
"reason": reason,
})
return flagged
export function findMispricedPublishedListings(products) {
const flagged = [];
for (const product of products) {
for (const listing of product.channelListings || []) {
if (!listing.isPublished) continue;
const channelSlug = listing.channelSlug;
for (const variant of product.variants || []) {
const variantListing = (variant.channelListings || []).find(
(cl) => cl.channelSlug === channelSlug
);
let reason;
if (!variantListing || variantListing.priceAmount === null || variantListing.priceAmount === undefined) {
reason = "missing_price";
} else if (variantListing.priceAmount <= 0) {
reason = "zero_price";
} else {
continue;
}
flagged.push({
productId: product.id,
variantId: variant.id,
channelSlug,
reason,
});
}
}
}
return flagged;
}
Normalize the raw GraphQL shape before deciding
The query returns nested channel objects, but the pure function above works on flat channel slugs and plain price numbers so it stays simple and easy to test. A small adapter reshapes each product's channel listings and variant channel listings before calling the decision function.
def normalize_product(raw):
return {
"id": raw["id"],
"channelListings": [
{"channelSlug": cl["channel"]["slug"], "isPublished": cl["isPublished"]}
for cl in raw.get("channelListings", [])
],
"variants": [
{
"id": v["id"],
"channelListings": [
{
"channelSlug": cl["channel"]["slug"],
"priceAmount": (cl.get("price") or {}).get("amount"),
}
for cl in v.get("channelListings", [])
],
}
for v in raw.get("variants", [])
],
}
function normalizeProduct(raw) {
return {
id: raw.id,
channelListings: (raw.channelListings || []).map((cl) => ({
channelSlug: cl.channel.slug,
isPublished: cl.isPublished,
})),
variants: (raw.variants || []).map((v) => ({
id: v.id,
channelListings: (v.channelListings || []).map((cl) => ({
channelSlug: cl.channel.slug,
priceAmount: cl.price ? cl.price.amount : null,
})),
})),
};
}
Report first, optionally unpublish only under a confirmed dry run
The default action is to print every flagged variant with its channel slug and reason. Never guess a price and write it, since the correct price is a business decision. The only optional write is a suppressive one: unpublishing the broken listing with productChannelListingUpdate so it stops misleadingly reporting as published. That write only ever happens when DRY_RUN is false and a human has separately confirmed suppressing visibility is the right call.
Always start with DRY_RUN=true. This script never calls productVariantChannelListingUpdate to invent a price. Setting the correct price and cost is left to a merchandiser. The one guarded write it can perform, unpublishing a mispriced listing, only runs when DRY_RUN=false and a human has confirmed it in the code that calls the repair function.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never writes a price it cannot justify.
"""Flag Saleor products published to a channel with no usable price there.
Publishing a product to a channel (ProductChannelListing.isPublished = true) and
pricing it for that channel (ProductVariantChannelListing.price) are two separate
steps in Saleor. A variant can be published without ever being priced, most often
while onboarding a new channel or bulk importing. The pricing resolvers then return
null and the storefront cannot show or sell the product, while the API still calls
it published. This queries products for a channel with their channel listings and
each variant's channel listings, cross references them by channel slug with a pure
function, and reports every variant that is published without a usable price. It
never invents a price. The only write it can perform, unpublishing the broken
listing, is gated by DRY_RUN and meant to run only after a human has decided
suppressing visibility is the right call.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_mispriced_published_listings")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy")
CHANNEL_SLUG = os.environ.get("SALEOR_CHANNEL_SLUG", "default-channel")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PRODUCTS_QUERY = """
query($channel: String!, $cursor: String) {
products(channel: $channel, first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
channelListings { channel { slug } isPublished isAvailableForPurchase
pricing { priceRange { start { gross { amount currency } } } } }
variants {
id
name
channelListings { channel { slug } price { amount currency } costPrice { amount } }
}
}
}
}
}"""
UNPUBLISH_MUTATION = """
mutation($productId: ID!, $channelId: ID!) {
productChannelListingUpdate(id: $productId, input: {
updateChannels: [{ channelId: $channelId, isPublished: false }]
}) {
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 normalize_product(raw):
return {
"id": raw["id"],
"channelListings": [
{"channelSlug": cl["channel"]["slug"], "isPublished": cl["isPublished"]}
for cl in raw.get("channelListings", [])
],
"variants": [
{
"id": v["id"],
"channelListings": [
{
"channelSlug": cl["channel"]["slug"],
"priceAmount": (cl.get("price") or {}).get("amount"),
}
for cl in v.get("channelListings", [])
],
}
for v in raw.get("variants", [])
],
}
def find_mispriced_published_listings(products):
flagged = []
for product in products:
for listing in product.get("channelListings", []):
if not listing.get("isPublished"):
continue
channel_slug = listing["channelSlug"]
for variant in product.get("variants", []):
variant_listing = next(
(cl for cl in variant.get("channelListings", [])
if cl["channelSlug"] == channel_slug),
None,
)
if variant_listing is None or variant_listing.get("priceAmount") is None:
reason = "missing_price"
elif variant_listing["priceAmount"] <= 0:
reason = "zero_price"
else:
continue
flagged.append({
"productId": product["id"],
"variantId": variant["id"],
"channelSlug": channel_slug,
"reason": reason,
})
return flagged
def fetch_products(channel_slug):
cursor = None
products = []
while True:
data = gql(PRODUCTS_QUERY, {"channel": channel_slug, "cursor": cursor})["products"]
products.extend(e["node"] for e in data["edges"])
if not data["pageInfo"]["hasNextPage"]:
return products
cursor = data["pageInfo"]["endCursor"]
def unpublish_listing(product_id, channel_id):
"""Only call this after a human has confirmed suppressing visibility is wanted."""
result = gql(UNPUBLISH_MUTATION, {"productId": product_id, "channelId": channel_id})[
"productChannelListingUpdate"
]
if result["errors"]:
raise RuntimeError(result["errors"])
def run():
raw_products = fetch_products(CHANNEL_SLUG)
products = [normalize_product(p) for p in raw_products]
flagged = find_mispriced_published_listings(products)
if not flagged:
log.info("Every published listing on channel %s has a usable price.", CHANNEL_SLUG)
return
for item in flagged:
log.warning(
"Product %s variant %s is published on channel %s with %s.",
item["productId"], item["variantId"], item["channelSlug"], item["reason"],
)
if not DRY_RUN:
log.info(
"DRY_RUN is false, but this script only reports by default. "
"Call unpublish_listing(product_id, channel_id) yourself once a "
"human has confirmed suppressing visibility is the right call. "
"The correct fix is productVariantChannelListingUpdate with a real price, "
"run by a merchandiser."
)
log.info("Done. %d variant listing(s) flagged.", len(flagged))
if __name__ == "__main__":
run()
/**
* Flag Saleor products published to a channel with no usable price there.
*
* Publishing a product to a channel (ProductChannelListing.isPublished = true) and
* pricing it for that channel (ProductVariantChannelListing.price) are two separate
* steps in Saleor. A variant can be published without ever being priced, most often
* while onboarding a new channel or bulk importing. The pricing resolvers then return
* null and the storefront cannot show or sell the product, while the API still calls
* it published. This queries products for a channel with their channel listings and
* each variant's channel listings, cross references them by channel slug with a pure
* function, and reports every variant that is published without a usable price. It
* never invents a price. The only write it can perform, unpublishing the broken
* listing, is gated by DRY_RUN and meant to run only after a human has decided
* suppressing visibility is the right call.
*
* Guide: https://www.allanninal.dev/saleor/product-invisible-missing-channel-price/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy";
const CHANNEL_SLUG = process.env.SALEOR_CHANNEL_SLUG || "default-channel";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PRODUCTS_QUERY = `
query($channel: String!, $cursor: String) {
products(channel: $channel, first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
channelListings { channel { slug } isPublished isAvailableForPurchase
pricing { priceRange { start { gross { amount currency } } } } }
variants {
id
name
channelListings { channel { slug } price { amount currency } costPrice { amount } }
}
}
}
}
}`;
const UNPUBLISH_MUTATION = `
mutation($productId: ID!, $channelId: ID!) {
productChannelListingUpdate(id: $productId, input: {
updateChannels: [{ channelId: $channelId, isPublished: false }]
}) {
errors { field message code }
}
}`;
export function normalizeProduct(raw) {
return {
id: raw.id,
channelListings: (raw.channelListings || []).map((cl) => ({
channelSlug: cl.channel.slug,
isPublished: cl.isPublished,
})),
variants: (raw.variants || []).map((v) => ({
id: v.id,
channelListings: (v.channelListings || []).map((cl) => ({
channelSlug: cl.channel.slug,
priceAmount: cl.price ? cl.price.amount : null,
})),
})),
};
}
export function findMispricedPublishedListings(products) {
const flagged = [];
for (const product of products) {
for (const listing of product.channelListings || []) {
if (!listing.isPublished) continue;
const channelSlug = listing.channelSlug;
for (const variant of product.variants || []) {
const variantListing = (variant.channelListings || []).find(
(cl) => cl.channelSlug === channelSlug
);
let reason;
if (!variantListing || variantListing.priceAmount === null || variantListing.priceAmount === undefined) {
reason = "missing_price";
} else if (variantListing.priceAmount <= 0) {
reason = "zero_price";
} else {
continue;
}
flagged.push({
productId: product.id,
variantId: variant.id,
channelSlug,
reason,
});
}
}
}
return flagged;
}
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
async function fetchProducts(channelSlug) {
let cursor = null;
const products = [];
while (true) {
const data = (await gql(PRODUCTS_QUERY, { channel: channelSlug, cursor })).products;
for (const edge of data.edges) products.push(edge.node);
if (!data.pageInfo.hasNextPage) return products;
cursor = data.pageInfo.endCursor;
}
}
// Only call this after a human has confirmed suppressing visibility is wanted.
export async function unpublishListing(productId, channelId) {
const result = (await gql(UNPUBLISH_MUTATION, { productId, channelId })).productChannelListingUpdate;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
}
export async function run() {
const rawProducts = await fetchProducts(CHANNEL_SLUG);
const products = rawProducts.map(normalizeProduct);
const flagged = findMispricedPublishedListings(products);
if (flagged.length === 0) {
console.log(`Every published listing on channel ${CHANNEL_SLUG} has a usable price.`);
return;
}
for (const item of flagged) {
console.warn(
`Product ${item.productId} variant ${item.variantId} is published on channel ${item.channelSlug} with ${item.reason}.`
);
if (!DRY_RUN) {
console.log(
"DRY_RUN is false, but this script only reports by default. "
+ "Call unpublishListing(productId, channelId) yourself once a human has "
+ "confirmed suppressing visibility is the right call. The correct fix is "
+ "productVariantChannelListingUpdate with a real price, run by a merchandiser."
);
}
}
console.log(`Done. ${flagged.length} variant listing(s) flagged.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The cross reference rule is the part most worth testing, because it decides which products get flagged as invisible. Because we kept find_mispriced_published_listings pure, the test needs no network and no Saleor store. It just feeds in plain data structures and checks the answer.
from find_mispriced_published_listings import find_mispriced_published_listings
def product(**over):
base = {
"id": "UHJvZHVjdDox",
"channelListings": [{"channelSlug": "default-channel", "isPublished": True}],
"variants": [
{
"id": "UHJvZHVjdFZhcmlhbnQ6MQ==",
"channelListings": [{"channelSlug": "default-channel", "priceAmount": 19.99}],
}
],
}
base.update(over)
return base
def test_fully_priced_published_product_is_not_flagged():
assert find_mispriced_published_listings([product()]) == []
def test_published_variant_with_no_channel_listing_is_flagged_missing_price():
p = product(variants=[{"id": "UHJvZHVjdFZhcmlhbnQ6MQ==", "channelListings": []}])
result = find_mispriced_published_listings([p])
assert result == [{
"productId": "UHJvZHVjdDox",
"variantId": "UHJvZHVjdFZhcmlhbnQ6MQ==",
"channelSlug": "default-channel",
"reason": "missing_price",
}]
def test_published_variant_with_null_price_is_flagged_missing_price():
p = product(variants=[{
"id": "UHJvZHVjdFZhcmlhbnQ6MQ==",
"channelListings": [{"channelSlug": "default-channel", "priceAmount": None}],
}])
result = find_mispriced_published_listings([p])
assert result[0]["reason"] == "missing_price"
def test_published_variant_with_zero_price_is_flagged_zero_price():
p = product(variants=[{
"id": "UHJvZHVjdFZhcmlhbnQ6MQ==",
"channelListings": [{"channelSlug": "default-channel", "priceAmount": 0}],
}])
result = find_mispriced_published_listings([p])
assert result[0]["reason"] == "zero_price"
def test_unpublished_listing_is_never_flagged():
p = product(channelListings=[{"channelSlug": "default-channel", "isPublished": False}])
assert find_mispriced_published_listings([p]) == []
def test_only_the_matching_channel_slug_is_checked():
p = product(
channelListings=[{"channelSlug": "default-channel", "isPublished": True}],
variants=[{
"id": "UHJvZHVjdFZhcmlhbnQ6MQ==",
"channelListings": [{"channelSlug": "other-channel", "priceAmount": 9.99}],
}],
)
result = find_mispriced_published_listings([p])
assert result[0]["reason"] == "missing_price"
import { test } from "node:test";
import assert from "node:assert/strict";
import { findMispricedPublishedListings } from "./find-mispriced-published-listings.js";
const product = (over = {}) => ({
id: "UHJvZHVjdDox",
channelListings: [{ channelSlug: "default-channel", isPublished: true }],
variants: [
{
id: "UHJvZHVjdFZhcmlhbnQ6MQ==",
channelListings: [{ channelSlug: "default-channel", priceAmount: 19.99 }],
},
],
...over,
});
test("fully priced published product is not flagged", () => {
assert.deepEqual(findMispricedPublishedListings([product()]), []);
});
test("published variant with no channel listing is flagged missing_price", () => {
const p = product({ variants: [{ id: "UHJvZHVjdFZhcmlhbnQ6MQ==", channelListings: [] }] });
const result = findMispricedPublishedListings([p]);
assert.deepEqual(result, [{
productId: "UHJvZHVjdDox",
variantId: "UHJvZHVjdFZhcmlhbnQ6MQ==",
channelSlug: "default-channel",
reason: "missing_price",
}]);
});
test("published variant with null price is flagged missing_price", () => {
const p = product({
variants: [{
id: "UHJvZHVjdFZhcmlhbnQ6MQ==",
channelListings: [{ channelSlug: "default-channel", priceAmount: null }],
}],
});
const result = findMispricedPublishedListings([p]);
assert.equal(result[0].reason, "missing_price");
});
test("published variant with zero price is flagged zero_price", () => {
const p = product({
variants: [{
id: "UHJvZHVjdFZhcmlhbnQ6MQ==",
channelListings: [{ channelSlug: "default-channel", priceAmount: 0 }],
}],
});
const result = findMispricedPublishedListings([p]);
assert.equal(result[0].reason, "zero_price");
});
test("unpublished listing is never flagged", () => {
const p = product({ channelListings: [{ channelSlug: "default-channel", isPublished: false }] });
assert.deepEqual(findMispricedPublishedListings([p]), []);
});
test("only the matching channel slug is checked", () => {
const p = product({
channelListings: [{ channelSlug: "default-channel", isPublished: true }],
variants: [{
id: "UHJvZHVjdFZhcmlhbnQ6MQ==",
channelListings: [{ channelSlug: "other-channel", priceAmount: 9.99 }],
}],
});
const result = findMispricedPublishedListings([p]);
assert.equal(result[0].reason, "missing_price");
});
Case studies
The second storefront that launched empty
A store added a new channel for a regional storefront. The catalog team ran a bulk script to assign and publish every product to the new channel, and planned to price them the next day once the currency conversion sheet was ready. The next day slipped into the following week, and nobody flagged that the products were live but invisible.
Running the classifier against the new channel turned up hundreds of variants flagged missing_price in seconds, all correctly published but with no price row yet. The pricing sheet was finished, a merchandiser ran productVariantChannelListingUpdate for each variant with the converted price, and the storefront started showing products the same afternoon.
The import that priced most variants but not all
A migration script imported a large catalog into Saleor, published every product to the main channel, then looped over variants to set prices from a source CSV. A handful of rows in that CSV had a blank price cell, and the script skipped them silently instead of failing the whole run.
Weeks later, a handful of products were reported as ranking in search but showing no buy button. The script found the exact variants with a zero_price or missing_price reason, which pointed straight at the CSV rows that needed a real price rather than a broader investigation into search indexing or the storefront code.
After running this on a schedule, every product that reports as published but cannot actually be shown or sold gets flagged with the exact variant and channel. A merchandiser reviews the list and sets the real price with productVariantChannelListingUpdate, or the team makes an informed call to unpublish a listing that should not be live yet. Nothing is ever marked sellable with a guessed price, and nothing silently sits published and invisible for weeks.
FAQ
Why is my Saleor product published but not showing on the storefront?
Publishing a product to a channel and pricing it for that channel are two separate steps in Saleor. ProductChannelListing.isPublished can be true while the matching ProductVariantChannelListing has no price row for that channel. The pricing and isAvailableForPurchase resolvers return null when there is no price, so the product silently cannot be shown or bought on that channel even though it reports as published.
How do I find every product that is published but missing a channel price?
Query products for the channel with their channelListings and each variant's channelListings, then compare by channel slug. A product is affected when a channel listing has isPublished true but the corresponding variant channel listing for that same channel is missing entirely, or its price is null or zero. This is a pure cross reference that needs no guessing about intent.
Can a script safely fix a missing channel price automatically?
No, not by inventing a price. The right price is a business decision that belongs to a merchandiser using productVariantChannelListingUpdate. A script can safely flag every affected variant, and optionally, behind a dry run guard and a human decision, unpublish the broken listing with productChannelListingUpdate so it stops misleadingly reporting as published.
Related field notes
Citations
On the problem:
- Variants without any channel reference. Discussion #9731, saleor/saleor. github.com/saleor/saleor/discussions/9731
- Make variants after initial creation. Issue #8589, saleor/saleor. github.com/saleor/saleor/issues/8589
- Unclarity about multi channels setup. Discussion #9112, saleor/saleor. github.com/saleor/saleor/discussions/9112
On the solution:
- Saleor Docs: the
productVariantChannelListingUpdatemutation. docs.saleor.io/api-reference/products/mutations/product-variant-channel-listing-update - Saleor API Reference: the
ProductChannelListingobject. docs.saleor.io/api-reference/products/objects/product-channel-listing - Saleor Developer Docs: Product Configuration guide. docs.saleor.io/developer/products/configuration
Fighting a Saleor bug right now?
If you have a problem in Saleor checkout, channels, shipping, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this find your invisible product?
If this saved you a support thread or a night chasing a product that would not show up, 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