Diagnostic Products, Variants & Channels
Variant pricing query returns null without a channel argument
You query productVariants { pricing { price { gross { amount } } } }, the request succeeds, and every single row comes back with pricing: null. So a script built on top of that assumes the whole catalog is unpriced and starts flagging or fixing variants that are, in fact, priced correctly in three channels. Here is why Saleor returns null in this exact shape and a scanner that checks pricing the way Saleor actually resolves it, per channel.
ProductVariant.pricing and Product.pricing are resolved per channel, because one variant can carry a different price in every channel and currency. The resolver looks up the matching ProductVariantChannelListing for the channel passed in the channel argument, or discovered from context. Omit channel on productVariants, or on the parent product/products query, and there is no channel to resolve against, so pricing comes back null for every row, whether or not the variant actually has a price. Run a small Python or Node.js scanner that lists the active channels, re-queries each channel with the channel argument set, and reads channelListings alongside pricing to tell a real gap from a query mistake. Full code, tests, and a report-only default are below.
The problem in plain words
The first pass at a pricing audit script usually looks reasonable. Page through productVariants, ask for id, sku, and pricing { price { gross { amount currency } } } }, and treat any row where pricing is null as a variant missing a price. The query runs fine, returns data, and every row shows "pricing": null. That looks like proof the catalog is a mess.
It is not proof of anything. Saleor prices are channel scoped by design, since the same variant can sell for one amount in a USD storefront channel and a different amount in a EUR channel, or not sell in a channel at all. To resolve pricing, Saleor needs to know which channel you mean, and it gets that from the channel argument on the query, or from channel context passed down from a parent product(channel: ...) call. A bare productVariants query, with no channel anywhere in the request, gives the resolver nothing to look up, so it returns null every time, regardless of whether a ProductVariantChannelListing row with a real price exists underneath.
Why it happens
- In Saleor's schema,
ProductVariant.pricingandProduct.pricingresolve against a specific channel's discount and currency context. The channel comes from thechannelargument on the query, or is discovered from a parent query that already carries channel context, such asproduct(id: ..., channel: "default-channel"). - The underlying data that
pricingreads from isProductVariantChannelListing, a per-channel row that storesprice,costPrice, and channel-specific availability. A variant can have several of these rows, one per channel, each with its own price and currency. - When the query omits
channelonproductVariantsand on every parent field, Saleor has no channel to pick aProductVariantChannelListingrow from, so it cannot compute a price, and the field resolves to null. This is documented behavior discussed at length in the Saleor GraphQL null-fields thread and the app pricing issue linked below, not a bug that gets fixed later. - A naive scan that runs
productVariants(first: N)once, with no channel and no per-channel loop, will showpricing: nullfor every variant in the store, including ones that are correctly priced in every channel they are sold in. Treating that null as "unpriced" produces a false positive for the entire catalog.
This gap has come up repeatedly in the Saleor community, both as confusion over why pricing, channel, and other fields return null on an unscoped query, and as an open discussion about whether the pricing API should be easier to use without the channel argument. See the citations at the end for the exact threads.
A null pricing field is not a fact about the variant. It is a fact about the query. The only way to know whether a variant is genuinely unpriced is to ask Saleor once per active channel, with channel set, and look at channelListings directly. A variant is unpriced only when, for every active channel it is published to, the channel listing has no price set or no listing row exists at all. Everything else is a query that forgot to say which channel it meant.
The fix, as a flow
The scanner runs in two passes on purpose, so the contrast is visible in its own output. Pass one repeats the naive, channel-less query as a baseline and shows that it always reports null. Pass two lists every active channel, then re-queries variants once per channel with channel set, reading both channelListings and pricing. A pure decision function turns each variant's per-channel listings into one of four verdicts, and only the genuinely unpriced ones get reported.
Build it step by step
Get an app token with read access to products and channels
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read products, product variants, and channels, 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;
}
Prove the naive query is the problem, not the catalog
Run the channel-less baseline once, exactly the way a first attempt usually looks: productVariants with no channel argument anywhere in the request. Log that pricing is null for every row. This pass exists only to make the contrast visible, it is never used to flag anything.
NAIVE_QUERY = """
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
pricing { price { gross { amount currency } } }
}
}
}
}"""
def naive_scan_sample(sample_size=5):
data = gql(NAIVE_QUERY, {"cursor": None})["productVariants"]
rows = [edge["node"] for edge in data["edges"][:sample_size]]
for row in rows:
print(f"sku={row['sku']} pricing={row['pricing']}") # always None here
return rows
const NAIVE_QUERY = `
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
pricing { price { gross { amount currency } } }
}
}
}
}`;
async function naiveScanSample(sampleSize = 5) {
const data = (await gql(NAIVE_QUERY, { cursor: null })).productVariants;
const rows = data.edges.slice(0, sampleSize).map((edge) => edge.node);
for (const row of rows) {
console.log(`sku=${row.sku} pricing=${JSON.stringify(row.pricing)}`); // always null here
}
return rows;
}
List the active channels, then re-query with channel set
Fetch channels { slug isActive } and keep only the active ones. For each active channel, page through productVariants(channel: $slug) and read channelListings { channel { slug } isPublished price { amount currency } } along with sku, so the decision function has the real per-channel data instead of a channel-less pricing field.
CHANNELS_QUERY = """
query {
channels { slug isActive }
}"""
VARIANTS_BY_CHANNEL_QUERY = """
query($cursor: String, $channel: String!) {
productVariants(first: 50, after: $cursor, channel: $channel) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
channelListings {
channel { slug }
price { amount currency }
}
}
}
}
}"""
def active_channel_slugs():
data = gql(CHANNELS_QUERY)["channels"]
return [c["slug"] for c in data if c["isActive"]]
def variants_for_channel(channel_slug):
cursor = None
while True:
data = gql(VARIANTS_BY_CHANNEL_QUERY, {"cursor": cursor, "channel": channel_slug})["productVariants"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const CHANNELS_QUERY = `
query {
channels { slug isActive }
}`;
const VARIANTS_BY_CHANNEL_QUERY = `
query($cursor: String, $channel: String!) {
productVariants(first: 50, after: $cursor, channel: $channel) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
channelListings {
channel { slug }
price { amount currency }
}
}
}
}
}`;
async function activeChannelSlugs() {
const data = (await gql(CHANNELS_QUERY)).channels;
return data.filter((c) => c.isActive).map((c) => c.slug);
}
async function* variantsForChannel(channelSlug) {
let cursor = null;
while (true) {
const data = (await gql(VARIANTS_BY_CHANNEL_QUERY, { cursor, channel: channelSlug })).productVariants;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes one variant, its channel listings, and the list of active channel slugs, and returns a verdict. No I/O, so it is easy to test. A variant is NOT_SOLD_IN_ACTIVE_CHANNEL when none of its listings are for an active channel, UNPRICED_NULL_PRICE when an active listing exists but its price is null, UNPRICED_MISSING_LISTING when a channel is published but has no listing row at all, and PRICED otherwise.
def classify_variant_pricing(variant, active_channel_slugs):
active = set(active_channel_slugs)
relevant = [cl for cl in variant.get("channelListings", []) if cl.get("channelSlug") in active]
if not relevant:
return "NOT_SOLD_IN_ACTIVE_CHANNEL"
if any(cl.get("price") is None for cl in relevant):
return "UNPRICED_NULL_PRICE"
listed_slugs = {cl.get("channelSlug") for cl in relevant}
missing_listing = any(
cl.get("isPublished") and cl.get("channelSlug") not in listed_slugs
for cl in relevant
)
if missing_listing:
return "UNPRICED_MISSING_LISTING"
return "PRICED"
export function classifyVariantPricing(variant, activeChannelSlugs) {
const active = new Set(activeChannelSlugs);
const relevant = (variant.channelListings || []).filter((cl) => active.has(cl.channelSlug));
if (relevant.length === 0) return "NOT_SOLD_IN_ACTIVE_CHANNEL";
if (relevant.some((cl) => cl.price === null)) return "UNPRICED_NULL_PRICE";
const listedSlugs = new Set(relevant.map((cl) => cl.channelSlug));
const missingListing = relevant.some((cl) => cl.isPublished && !listedSlugs.has(cl.channelSlug));
if (missingListing) return "UNPRICED_MISSING_LISTING";
return "PRICED";
}
Report the gap, and only backfill behind a dry run with a human price
Under DRY_RUN=true, the default, the scanner only logs every variant whose verdict is not PRICED, with its sku, the channel, and the reason. Saleor has no mutation that safely invents a price, so a write only happens when DRY_RUN=false and a human has supplied a price map, in which case productVariantChannelListingUpdate sets the price for that variant and channel.
Never let a script invent a price. This is fundamentally a detection problem, not an auto-repair one, because the correct price is a business decision. Always dry run first, review the full list of flagged {variantId, sku, channelSlug, reason} rows, and only call productVariantChannelListingUpdate or productChannelListingUpdate with a price a human explicitly approved.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, runs the naive baseline for contrast, scans every active channel properly, classifies every variant with the pure decision function, and only writes a price when a human turns off dry run and supplies one.
"""Find Saleor product variants that are genuinely missing a price, without
being fooled by a channel-less ProductVariant.pricing query.
ProductVariant.pricing and Product.pricing resolve against a specific
channel's ProductVariantChannelListing. Omit the channel argument and Saleor
has no channel context to resolve against, so pricing comes back null for
every row, priced or not (see the GraphQL null-fields and app pricing
discussions cited in the guide). This script runs a naive channel-less pass
only to show the contrast, then re-queries productVariants once per active
channel with channel set, reads channelListings directly, and classifies
each variant with a pure function.
This is a detection script, not an auto-repair one. Under DRY_RUN=true (the
default) it only reports flagged variants. When DRY_RUN=false and a
human-supplied price map is provided, it calls
productVariantChannelListingUpdate to backfill the approved price. It never
invents a price on its own. 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("flag_variant_pricing_gaps")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
NAIVE_QUERY = """
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
pricing { price { gross { amount currency } } }
}
}
}
}"""
CHANNELS_QUERY = """
query {
channels { slug isActive }
}"""
VARIANTS_BY_CHANNEL_QUERY = """
query($cursor: String, $channel: String!) {
productVariants(first: 50, after: $cursor, channel: $channel) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
channelListings {
channel { slug }
isPublished
price { amount currency }
}
}
}
}
}"""
CHANNEL_LISTING_UPDATE = """
mutation($id: ID!, $channelId: ID!, $price: PositiveDecimal!) {
productVariantChannelListingUpdate(
id: $id,
input: [{ channelId: $channelId, price: $price }]
) {
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 classify_variant_pricing(variant, active_channel_slugs):
active = set(active_channel_slugs)
relevant = [cl for cl in variant.get("channelListings", []) if cl.get("channelSlug") in active]
if not relevant:
return "NOT_SOLD_IN_ACTIVE_CHANNEL"
if any(cl.get("price") is None for cl in relevant):
return "UNPRICED_NULL_PRICE"
listed_slugs = {cl.get("channelSlug") for cl in relevant}
missing_listing = any(
cl.get("isPublished") and cl.get("channelSlug") not in listed_slugs
for cl in relevant
)
if missing_listing:
return "UNPRICED_MISSING_LISTING"
return "PRICED"
def naive_scan_sample(sample_size=5):
data = gql(NAIVE_QUERY, {"cursor": None})["productVariants"]
rows = [edge["node"] for edge in data["edges"][:sample_size]]
for row in rows:
log.info("naive pass sku=%s pricing=%s (always null here)", row["sku"], row["pricing"])
return rows
def active_channel_slugs():
data = gql(CHANNELS_QUERY)["channels"]
return [c["slug"] for c in data if c["isActive"]]
def variants_for_channel(channel_slug):
cursor = None
while True:
data = gql(VARIANTS_BY_CHANNEL_QUERY, {"cursor": cursor, "channel": channel_slug})["productVariants"]
for edge in data["edges"]:
node = edge["node"]
node["channelListings"] = [
{
"channelSlug": cl["channel"]["slug"],
"isPublished": cl["isPublished"],
"price": cl["price"],
}
for cl in node["channelListings"]
]
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def backfill_price(variant_id, channel_id, price):
result = gql(
CHANNEL_LISTING_UPDATE,
{"id": variant_id, "channelId": channel_id, "price": price},
)["productVariantChannelListingUpdate"]
if result["errors"]:
raise RuntimeError(result["errors"])
def run(approved_price_map=None):
approved_price_map = approved_price_map or {}
naive_scan_sample()
channels = active_channel_slugs()
seen = {}
flagged = []
for slug in channels:
for variant in variants_for_channel(slug):
key = variant["id"]
if key not in seen:
seen[key] = variant
for variant in seen.values():
verdict = classify_variant_pricing(variant, channels)
if verdict == "PRICED":
continue
entry = {
"variantId": variant["id"],
"sku": variant["sku"],
"channelSlug": slug,
"reason": verdict,
}
flagged.append(entry)
log.warning(
"UNPRICED sku=%s channel=%s reason=%s", entry["sku"], entry["channelSlug"], entry["reason"]
)
for entry in flagged:
approved = approved_price_map.get((entry["variantId"], entry["channelSlug"]))
if not approved:
continue
log.info(
"Variant %s eligible for backfill. %s",
entry["sku"], "would backfill" if DRY_RUN else "backfilling",
)
if not DRY_RUN:
backfill_price(entry["variantId"], approved["channelId"], approved["price"])
log.info("Done. %d variant/channel gap(s) found.", len(flagged))
return flagged
if __name__ == "__main__":
run()
/**
* Find Saleor product variants that are genuinely missing a price, without
* being fooled by a channel-less ProductVariant.pricing query.
*
* ProductVariant.pricing and Product.pricing resolve against a specific
* channel's ProductVariantChannelListing. Omit the channel argument and
* Saleor has no channel context to resolve against, so pricing comes back
* null for every row, priced or not. This script runs a naive channel-less
* pass only to show the contrast, then re-queries productVariants once per
* active channel with channel set, reads channelListings directly, and
* classifies each variant with a pure function.
*
* This is a detection script, not an auto-repair one. Under DRY_RUN=true
* (the default) it only reports flagged variants. When DRY_RUN=false and a
* human-supplied price map is provided, it calls
* productVariantChannelListingUpdate to backfill the approved price. It
* never invents a price on its own. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/variant-pricing-null-without-channel-arg/
*/
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 classifyVariantPricing(variant, activeChannelSlugs) {
const active = new Set(activeChannelSlugs);
const relevant = (variant.channelListings || []).filter((cl) => active.has(cl.channelSlug));
if (relevant.length === 0) return "NOT_SOLD_IN_ACTIVE_CHANNEL";
if (relevant.some((cl) => cl.price === null)) return "UNPRICED_NULL_PRICE";
const listedSlugs = new Set(relevant.map((cl) => cl.channelSlug));
const missingListing = relevant.some((cl) => cl.isPublished && !listedSlugs.has(cl.channelSlug));
if (missingListing) return "UNPRICED_MISSING_LISTING";
return "PRICED";
}
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 NAIVE_QUERY = `
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
pricing { price { gross { amount currency } } }
}
}
}
}`;
const CHANNELS_QUERY = `
query {
channels { slug isActive }
}`;
const VARIANTS_BY_CHANNEL_QUERY = `
query($cursor: String, $channel: String!) {
productVariants(first: 50, after: $cursor, channel: $channel) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
channelListings {
channel { slug }
isPublished
price { amount currency }
}
}
}
}
}`;
const CHANNEL_LISTING_UPDATE = `
mutation($id: ID!, $channelId: ID!, $price: PositiveDecimal!) {
productVariantChannelListingUpdate(
id: $id,
input: [{ channelId: $channelId, price: $price }]
) {
variant { id }
errors { field message code }
}
}`;
async function naiveScanSample(sampleSize = 5) {
const data = (await gql(NAIVE_QUERY, { cursor: null })).productVariants;
const rows = data.edges.slice(0, sampleSize).map((edge) => edge.node);
for (const row of rows) {
console.log(`naive pass sku=${row.sku} pricing=${JSON.stringify(row.pricing)} (always null here)`);
}
return rows;
}
async function activeChannelSlugs() {
const data = (await gql(CHANNELS_QUERY)).channels;
return data.filter((c) => c.isActive).map((c) => c.slug);
}
async function* variantsForChannel(channelSlug) {
let cursor = null;
while (true) {
const data = (await gql(VARIANTS_BY_CHANNEL_QUERY, { cursor, channel: channelSlug })).productVariants;
for (const edge of data.edges) {
const node = edge.node;
node.channelListings = node.channelListings.map((cl) => ({
channelSlug: cl.channel.slug,
isPublished: cl.isPublished,
price: cl.price,
}));
yield node;
}
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function backfillPrice(variantId, channelId, price) {
const result = (await gql(CHANNEL_LISTING_UPDATE, { id: variantId, channelId, price })).productVariantChannelListingUpdate;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
}
export async function run(approvedPriceMap = {}) {
await naiveScanSample();
const channels = await activeChannelSlugs();
const seen = new Map();
const flagged = [];
for (const slug of channels) {
for await (const variant of variantsForChannel(slug)) {
if (!seen.has(variant.id)) seen.set(variant.id, variant);
}
for (const variant of seen.values()) {
const verdict = classifyVariantPricing(variant, channels);
if (verdict === "PRICED") continue;
const entry = { variantId: variant.id, sku: variant.sku, channelSlug: slug, reason: verdict };
flagged.push(entry);
console.warn(`UNPRICED sku=${entry.sku} channel=${entry.channelSlug} reason=${entry.reason}`);
}
}
for (const entry of flagged) {
const approved = approvedPriceMap[`${entry.variantId}::${entry.channelSlug}`];
if (!approved) continue;
console.log(`Variant ${entry.sku} eligible for backfill. ${DRY_RUN ? "would backfill" : "backfilling"}`);
if (!DRY_RUN) await backfillPrice(entry.variantId, approved.channelId, approved.price);
}
console.log(`Done. ${flagged.length} variant/channel gap(s) found.`);
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 whether a variant is real proof of a pricing gap or just an artifact of the query. Because classify_variant_pricing is pure, the test needs no network and no Saleor account. It just feeds in plain records and checks the verdict.
from flag_variant_pricing_gaps import classify_variant_pricing
ACTIVE = ["default-channel", "eu-channel"]
def variant(**over):
base = {
"id": "gid://saleor/ProductVariant/1",
"sku": "SKU-1",
"channelListings": [
{"channelSlug": "default-channel", "isPublished": True, "price": {"amount": 19.99, "currency": "USD"}},
],
}
base.update(over)
return base
def test_priced_when_active_listing_has_price():
assert classify_variant_pricing(variant(), ACTIVE) == "PRICED"
def test_unpriced_null_price_when_listing_price_is_none():
v = variant(channelListings=[
{"channelSlug": "default-channel", "isPublished": True, "price": None},
])
assert classify_variant_pricing(v, ACTIVE) == "UNPRICED_NULL_PRICE"
def test_not_sold_in_active_channel_when_no_relevant_listing():
v = variant(channelListings=[
{"channelSlug": "inactive-channel", "isPublished": True, "price": {"amount": 5, "currency": "USD"}},
])
assert classify_variant_pricing(v, ACTIVE) == "NOT_SOLD_IN_ACTIVE_CHANNEL"
def test_not_sold_in_active_channel_when_no_listings_at_all():
v = variant(channelListings=[])
assert classify_variant_pricing(v, ACTIVE) == "NOT_SOLD_IN_ACTIVE_CHANNEL"
def test_priced_when_multiple_active_channels_all_priced():
v = variant(channelListings=[
{"channelSlug": "default-channel", "isPublished": True, "price": {"amount": 19.99, "currency": "USD"}},
{"channelSlug": "eu-channel", "isPublished": True, "price": {"amount": 18.5, "currency": "EUR"}},
])
assert classify_variant_pricing(v, ACTIVE) == "PRICED"
def test_unpriced_null_price_wins_over_priced_channel():
v = variant(channelListings=[
{"channelSlug": "default-channel", "isPublished": True, "price": {"amount": 19.99, "currency": "USD"}},
{"channelSlug": "eu-channel", "isPublished": True, "price": None},
])
assert classify_variant_pricing(v, ACTIVE) == "UNPRICED_NULL_PRICE"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyVariantPricing } from "./flag-variant-pricing-gaps.js";
const ACTIVE = ["default-channel", "eu-channel"];
const variant = (over = {}) => ({
id: "gid://saleor/ProductVariant/1",
sku: "SKU-1",
channelListings: [
{ channelSlug: "default-channel", isPublished: true, price: { amount: 19.99, currency: "USD" } },
],
...over,
});
test("priced when active listing has price", () => {
assert.equal(classifyVariantPricing(variant(), ACTIVE), "PRICED");
});
test("unpriced null price when listing price is null", () => {
const v = variant({ channelListings: [{ channelSlug: "default-channel", isPublished: true, price: null }] });
assert.equal(classifyVariantPricing(v, ACTIVE), "UNPRICED_NULL_PRICE");
});
test("not sold in active channel when no relevant listing", () => {
const v = variant({ channelListings: [{ channelSlug: "inactive-channel", isPublished: true, price: { amount: 5, currency: "USD" } }] });
assert.equal(classifyVariantPricing(v, ACTIVE), "NOT_SOLD_IN_ACTIVE_CHANNEL");
});
test("not sold in active channel when no listings at all", () => {
const v = variant({ channelListings: [] });
assert.equal(classifyVariantPricing(v, ACTIVE), "NOT_SOLD_IN_ACTIVE_CHANNEL");
});
test("priced when multiple active channels all priced", () => {
const v = variant({
channelListings: [
{ channelSlug: "default-channel", isPublished: true, price: { amount: 19.99, currency: "USD" } },
{ channelSlug: "eu-channel", isPublished: true, price: { amount: 18.5, currency: "EUR" } },
],
});
assert.equal(classifyVariantPricing(v, ACTIVE), "PRICED");
});
test("unpriced null price wins over priced channel", () => {
const v = variant({
channelListings: [
{ channelSlug: "default-channel", isPublished: true, price: { amount: 19.99, currency: "USD" } },
{ channelSlug: "eu-channel", isPublished: true, price: null },
],
});
assert.equal(classifyVariantPricing(v, ACTIVE), "UNPRICED_NULL_PRICE");
});
Case studies
A price audit script "found" that half the catalog was unpriced
A marketplace running separate USD and EUR channels wrote a nightly job to flag unpriced variants before they went live. The job queried productVariants without ever passing channel, saw pricing: null everywhere, and opened a ticket claiming half the catalog had no price. The EUR team spent a morning re-checking variants that were priced correctly the whole time.
Switching the scanner to loop over channels and pass channel on every productVariants call collapsed the false positive list to the two variants that genuinely had a null price on their ProductVariantChannelListing row. The rest were left alone.
A launch channel had variants published but never priced
A fashion brand added a new regional channel and used productChannelListingUpdate to publish most of the catalog to it, planning to set prices in a follow-up batch. A launch went out before that batch ran, and the storefront quietly hid several products because their variants had a channel listing with isPublished: true but no price attached.
The scanner's UNPRICED_NULL_PRICE and UNPRICED_MISSING_LISTING verdicts caught exactly those rows in the new channel before the wider marketing push, and the pricing team backfilled them with real, approved prices through productVariantChannelListingUpdate instead of guessing.
After this runs on a schedule, a null pricing field never gets treated as proof of a gap on its own. The scanner reports exactly which variant, in which channel, has no real price set, backed by channelListings rather than a channel-less query, and the only writes it ever makes are backfills a human explicitly approved. No correctly priced variant gets flagged again, and no price gets invented by a script.
FAQ
Why does ProductVariant.pricing return null in my Saleor query?
ProductVariant.pricing and Product.pricing are resolved per channel. The resolver looks up the active ProductVariantChannelListing for the channel passed as the channel argument. If the query never passes channel, there is no channel context to resolve against, so pricing comes back null for every row, even for a variant that has a real, non-null price in one or more channels.
How do I check pricing correctly across multiple channels?
List your active channels with channels { slug isActive }, then re-query productVariants once per channel slug, passing channel: $slug each time, and read channelListings { channel { slug } price { amount currency } } alongside pricing. A variant is genuinely unpriced only if, for every active channel it is published to, the channel listing has no price set or no listing row exists at all.
Can I just backfill a price automatically when pricing is null?
No. Saleor has no mutation that safely invents a correct price, because the right price is a business decision, not derivable data. Treat a null or missing price as a report for a human to review, and only call productVariantChannelListingUpdate or productChannelListingUpdate to set a price when a human has supplied it, behind a dry run guard.
Related field notes
Citations
On the problem:
- GraphQL returns NULL for channel, pricing and others. github.com/saleor/saleor/discussions/13045
- App is not able to get variant pricing. github.com/saleor/saleor/issues/6881
- RFC: Pricing API improvements. github.com/saleor/saleor/discussions/15805
On the solution:
- Saleor Commerce Documentation: ProductVariant Object. docs.saleor.io/api-reference/products/objects/product-variant
- Saleor Commerce Documentation: ProductVariantChannelListing Object. docs.saleor.io/api-reference/products/objects/product-variant-channel-listing
- Saleor Commerce Documentation: productVariantChannelListingUpdate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-channel-listing-update
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 stop a false pricing alarm?
If this saved you from a wrong pricing report or a launch stuck on a missing price, 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