Skip to content

Diagnostic Products, Variants & Channels

Product without any variant crashes queries

Someone created a product through the API, saved it, and moved on without ever calling productVariantCreate. The product exists. It has a name, a slug, maybe a description. What it does not have is a single variant, and in Saleor that is where the price, the SKU, the stock, and the channel price all actually live. So defaultVariant comes back null, pricing falls apart, and any storefront or checkout code that assumed a product always has a variant throws or quietly shows nothing. Here is why Saleor lets this happen and a script that finds every product like it before a shopper does.

Python and Node.js Saleor GraphQL API Safe by default (dry run, report first)
A bottle on a store shelf
Photo by Charles Gao on Unsplash
The short answer

Saleor's data model requires every sellable product to have at least one ProductVariant, because price, SKU, stock, and channel availability all live on the variant, not the product. Product.defaultVariant and Product.pricing are resolved from a product's variants and their channel listings. Create a product without ever calling productVariantCreate and it has zero variants, so defaultVariant resolves to null and pricing or availability resolvers can throw or return null in ways storefronts and checkout code do not guard against. Saleor never enforces a minimum of one variant at the mutation level, so this keeps resurfacing. Run a Python or Node.js script that pages through products, flags any with an empty variants list, cross-references channelListings to prioritize the published ones, and reports them so a merchant can add a real variant or unpublish the product. Full code, tests, and a dry run guard are below.

The problem in plain words

In most commerce platforms a product is the sellable thing. In Saleor it is closer to a folder. The product holds the name, the description, the category, the attributes that describe the line as a whole. But nothing you can actually buy, the price, the SKU, the stock quantity, the per-channel price, lives on the product itself. All of that lives one level down, on a ProductVariant.

That split is normal and useful when a product genuinely has options, like a shirt in three sizes. It becomes a trap the moment someone creates a product through the API and stops after productCreate, without ever calling productVariantCreate. Nothing in Saleor stops you from doing that. The product saves fine. It just has no variant behind it, which means defaultVariant resolves to null, and any pricing or availability resolver that quietly assumed a variant exists can throw or return null in ways the storefront was never written to handle.

productCreate product saved, no variant yet productVariantCreate never called Zero variants no price, SKU, or stock defaultVariant is null pricing resolver has nothing to resolve from Storefront query throws or shows nothing Checkout breaks Meanwhile in Saleor nothing at the mutation level requires a minimum of one variant per product.
Saleor stores price and stock on the variant, not the product. Skip variant creation and defaultVariant resolves to null downstream.

Why it happens

This is not a bug in one code path. It is a gap in the data model that nothing enforces. A few concrete ways stores end up with variant-less products:

This was reported early, in GitHub issue #1734, where products without any variant were shown to crash Saleor outright. The underlying gap, that nothing stops a product from existing with zero variants, has resurfaced in different forms since, including issue #8589 about making variants after initial creation. Saleor's own community has walked through the same confusion in discussion #9367, where someone creating a product via the API hit exactly this wall. The API happily accepts productCreate on its own, so it is easy to assume a product is done at that point. It is not.

The key insight

Saleor cannot invent a SKU, a price, or a stock quantity for you, so there is no safe automated fix for a variant-less product. The only responsible move is to find them, and if one is published where a shopper could actually reach it, take it out of harm's way until a human adds a real variant. Detection and, optionally, a channel unpublish. Never a fabricated variant.

The fix, as a flow

We do not try to guess a price or a SKU. The script pages through every product, checks whether it has any variant at all, and if it does not, checks whether it is published to a channel. Published and variant-less is the urgent case, since that is a product a storefront query or a checkout can actually reach. The script reports every affected product, and only when DRY_RUN is off does it optionally unpublish the published ones per channel.

Scheduled job runs on a timer List products variants, channelListings classifyVariantHealth pure decision function Has any variant? yes, OK no Report, and if published, unpublish when DRY_RUN is off
Every affected product gets reported. Only the published, variant-less ones are candidates for an optional channel unpublish, and only when DRY_RUN is off.

Build it step by step

1

Get an app or staff token

Create an app in the Saleor dashboard with the MANAGE_PRODUCTS permission, or sign in a staff account with tokenCreate. Keep the API URL and the token in environment variables, never hardcoded in the script.

setup (shell)
pip install requests

export SALEOR_API_URL="https://your-store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your app or staff token"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://your-store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your app or staff token"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Saleor GraphQL endpoint

Every call goes to one endpoint with your token in the Authorization: Bearer header. A small helper sends the query and raises if Saleor reports an error, so every other function can stay simple.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List products with their variants and channel listings

Ask for every product with its defaultVariant, its full variants list, and its channelListings with each channel's publication status. We page through with a cursor so the job covers the whole catalog, not just the first page.

step3.py
PRODUCTS_QUERY = """
query($cursor: String, $channel: String) {
  products(first: 100, after: $cursor, channel: $channel) {
    edges {
      node {
        id
        name
        slug
        defaultVariant { id }
        variants { id }
        channelListings { channel { slug } isPublished }
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}"""

def all_products(channel_slug):
    cursor = None
    while True:
        data = gql(PRODUCTS_QUERY, {"cursor": cursor, "channel": channel_slug})["products"]
        for edge in data["edges"]:
            yield edge["node"]
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const PRODUCTS_QUERY = `
query($cursor: String, $channel: String) {
  products(first: 100, after: $cursor, channel: $channel) {
    edges {
      node {
        id
        name
        slug
        defaultVariant { id }
        variants { id }
        channelListings { channel { slug } isPublished }
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}`;

async function* allProducts(channelSlug) {
  let cursor = null;
  while (true) {
    const data = (await gql(PRODUCTS_QUERY, { cursor, channel: channelSlug })).products;
    for (const edge of data.edges) yield edge.node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the classification in its own function that takes a product shape and returns a status. If variants is not empty the product is fine. If it is empty, check channelListings for any channel where isPublished is true. Published and variant-less is the urgent case, since a storefront or checkout query can actually reach that product today.

classify.py
def classify_variant_health(product):
    """
    product: {"id": str, "variants": [{"id": str}],
              "channelListings": [{"channel": {"slug": str}, "isPublished": bool}]}
    Returns {"status": "OK" | "NO_VARIANTS_UNPUBLISHED" | "NO_VARIANTS_PUBLISHED",
             "affectedChannels": [str]}
    """
    if len(product.get("variants") or []) > 0:
        return {"status": "OK", "affectedChannels": []}

    affected_channels = [
        cl["channel"]["slug"]
        for cl in (product.get("channelListings") or [])
        if cl.get("isPublished")
    ]
    status = "NO_VARIANTS_PUBLISHED" if affected_channels else "NO_VARIANTS_UNPUBLISHED"
    return {"status": status, "affectedChannels": affected_channels}
classify.js
export function classifyVariantHealth(product) {
  if ((product.variants || []).length > 0) {
    return { status: "OK", affectedChannels: [] };
  }

  const affectedChannels = (product.channelListings || [])
    .filter((cl) => cl.isPublished)
    .map((cl) => cl.channel.slug);

  const status = affectedChannels.length > 0 ? "NO_VARIANTS_PUBLISHED" : "NO_VARIANTS_UNPUBLISHED";
  return { status, affectedChannels };
}
5

Optionally unpublish the published, variant-less ones

There is no auto-fix that adds a real variant, so the only optional write is taking a published, variant-less product out of the storefront's reach. Call productChannelListingUpdate per affected channel with isPublished: false, guarded by DRY_RUN. A merchant still has to add a variant with productVariantCreate and price it with productVariantChannelListingUpdate before republishing.

apply.py
UNPUBLISH_MUTATION = """
mutation($productId: ID!, $channelId: ID!) {
  productChannelListingUpdate(id: $productId, input: {
    updateChannels: [{ channelId: $channelId, isPublished: false }]
  }) {
    product { id }
    errors { field message }
  }
}"""

def unpublish_product_channel(product_id, channel_id):
    result = gql(UNPUBLISH_MUTATION, {"productId": product_id, "channelId": channel_id})[
        "productChannelListingUpdate"
    ]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["product"]["id"]
apply.js
const UNPUBLISH_MUTATION = `
mutation($productId: ID!, $channelId: ID!) {
  productChannelListingUpdate(id: $productId, input: {
    updateChannels: [{ channelId: $channelId, isPublished: false }]
  }) {
    product { id }
    errors { field message }
  }
}`;

async function unpublishProductChannel(productId, channelId) {
  const result = (await gql(UNPUBLISH_MUTATION, { productId, channelId })).productChannelListingUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.product.id;
}
6

Wire it together with a dry run guard

The loop pages through products, classifies each one, logs every affected product with its status and channels, and only calls the unpublish mutation for NO_VARIANTS_PUBLISHED products when DRY_RUN is off. Everything else stays exactly as it is until a merchant looks at the report and adds a variant.

Run it safe

Always start with DRY_RUN=true and read the report before flipping it off. Unpublishing a product is reversible, but it still changes what a shopper can see, so treat the channel it happened on as something to check before turning it back on.

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 is safe to run again and again because it never invents a variant and only unpublishes a product that was already reported as affected.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
flag_variantless_products.py
"""Find Saleor products that have zero variants, report them, and optionally
unpublish the ones that are still published to a channel.

Saleor stores price, SKU, stock, and channel availability on the ProductVariant,
not the Product. A product created without ever calling productVariantCreate
has defaultVariant == null and can crash or silently break pricing and
availability for storefront and checkout code.

There is no safe auto-fix: Saleor cannot invent a SKU, price, or stock quantity.
This is flag and report, with an optional per-channel unpublish gated by DRY_RUN.
Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/saleor/product-without-variant-crashes-queries/
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_variantless_products")

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
CHANNEL_SLUG = os.environ.get("SALEOR_CHANNEL_SLUG", "default-channel")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PRODUCTS_QUERY = """
query($cursor: String, $channel: String) {
  products(first: 100, after: $cursor, channel: $channel) {
    edges {
      node {
        id
        name
        slug
        defaultVariant { id }
        variants { id }
        channelListings { channel { id slug } isPublished }
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}"""

UNPUBLISH_MUTATION = """
mutation($productId: ID!, $channelId: ID!) {
  productChannelListingUpdate(id: $productId, input: {
    updateChannels: [{ channelId: $channelId, isPublished: false }]
  }) {
    product { id }
    errors { field message }
  }
}"""


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_health(product):
    """
    Pure decision logic, no I/O.
    product: {"id": str, "variants": [{"id": str}],
              "channelListings": [{"channel": {"slug": str}, "isPublished": bool}]}
    Returns {"status": "OK" | "NO_VARIANTS_UNPUBLISHED" | "NO_VARIANTS_PUBLISHED",
             "affectedChannels": [str]}
    """
    if len(product.get("variants") or []) > 0:
        return {"status": "OK", "affectedChannels": []}

    affected_channels = [
        cl["channel"]["slug"]
        for cl in (product.get("channelListings") or [])
        if cl.get("isPublished")
    ]
    status = "NO_VARIANTS_PUBLISHED" if affected_channels else "NO_VARIANTS_UNPUBLISHED"
    return {"status": status, "affectedChannels": affected_channels}


def all_products(channel_slug):
    cursor = None
    while True:
        data = gql(PRODUCTS_QUERY, {"cursor": cursor, "channel": channel_slug})["products"]
        for edge in data["edges"]:
            yield edge["node"]
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def unpublish_product_channel(product_id, channel_id):
    result = gql(UNPUBLISH_MUTATION, {"productId": product_id, "channelId": channel_id})[
        "productChannelListingUpdate"
    ]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["product"]["id"]


def run():
    mode = "dry run" if DRY_RUN else "live"
    log.info("Scanning products on channel %s (%s)", CHANNEL_SLUG, mode)

    flagged = 0
    unpublished = 0
    for node in all_products(CHANNEL_SLUG):
        result = classify_variant_health(node)
        if result["status"] == "OK":
            continue

        flagged += 1
        log.warning(
            "%s product=%s (%s) affectedChannels=%s",
            result["status"], node["name"], node["slug"], ",".join(result["affectedChannels"]),
        )

        if result["status"] == "NO_VARIANTS_PUBLISHED" and not DRY_RUN:
            channels_by_slug = {
                cl["channel"]["slug"]: cl["channel"]["id"] for cl in node["channelListings"]
            }
            for slug in result["affectedChannels"]:
                channel_id = channels_by_slug.get(slug)
                if channel_id:
                    unpublish_product_channel(node["id"], channel_id)
                    unpublished += 1

    log.info(
        "Done. %d product(s) flagged, %d channel listing(s) %s.",
        flagged, unpublished, "would be unpublished" if DRY_RUN else "unpublished",
    )
    return flagged


if __name__ == "__main__":
    run()
flag-variantless-products.js
/**
 * Find Saleor products that have zero variants, report them, and optionally
 * unpublish the ones that are still published to a channel.
 *
 * Saleor stores price, SKU, stock, and channel availability on the
 * ProductVariant, not the Product. A product created without ever calling
 * productVariantCreate has defaultVariant === null and can crash or silently
 * break pricing and availability for storefront and checkout code.
 *
 * There is no safe auto-fix: Saleor cannot invent a SKU, price, or stock
 * quantity. This is flag and report, with an optional per-channel unpublish
 * gated by DRY_RUN. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/saleor/product-without-variant-crashes-queries/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://demo.saleor.io/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "token_dummy";
const CHANNEL_SLUG = process.env.SALEOR_CHANNEL_SLUG || "default-channel";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function classifyVariantHealth(product) {
  if ((product.variants || []).length > 0) {
    return { status: "OK", affectedChannels: [] };
  }

  const affectedChannels = (product.channelListings || [])
    .filter((cl) => cl.isPublished)
    .map((cl) => cl.channel.slug);

  const status = affectedChannels.length > 0 ? "NO_VARIANTS_PUBLISHED" : "NO_VARIANTS_UNPUBLISHED";
  return { status, affectedChannels };
}

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 PRODUCTS_QUERY = `
query($cursor: String, $channel: String) {
  products(first: 100, after: $cursor, channel: $channel) {
    edges {
      node {
        id
        name
        slug
        defaultVariant { id }
        variants { id }
        channelListings { channel { id slug } isPublished }
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}`;

const UNPUBLISH_MUTATION = `
mutation($productId: ID!, $channelId: ID!) {
  productChannelListingUpdate(id: $productId, input: {
    updateChannels: [{ channelId: $channelId, isPublished: false }]
  }) {
    product { id }
    errors { field message }
  }
}`;

async function* allProducts(channelSlug) {
  let cursor = null;
  while (true) {
    const data = (await gql(PRODUCTS_QUERY, { cursor, channel: channelSlug })).products;
    for (const edge of data.edges) yield edge.node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function unpublishProductChannel(productId, channelId) {
  const result = (await gql(UNPUBLISH_MUTATION, { productId, channelId })).productChannelListingUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.product.id;
}

export async function run() {
  const mode = DRY_RUN ? "dry run" : "live";
  console.log(`Scanning products on channel ${CHANNEL_SLUG} (${mode})`);

  let flagged = 0;
  let unpublished = 0;
  for await (const node of allProducts(CHANNEL_SLUG)) {
    const result = classifyVariantHealth(node);
    if (result.status === "OK") continue;

    flagged++;
    console.warn(
      `${result.status} product=${node.name} (${node.slug}) affectedChannels=${result.affectedChannels.join(",")}`
    );

    if (result.status === "NO_VARIANTS_PUBLISHED" && !DRY_RUN) {
      const channelsBySlug = Object.fromEntries(
        node.channelListings.map((cl) => [cl.channel.slug, cl.channel.id])
      );
      for (const slug of result.affectedChannels) {
        const channelId = channelsBySlug[slug];
        if (channelId) {
          await unpublishProductChannel(node.id, channelId);
          unpublished++;
        }
      }
    }
  }

  console.log(
    `Done. ${flagged} product(s) flagged, ${unpublished} channel listing(s) ${DRY_RUN ? "would be unpublished" : "unpublished"}.`
  );
  return flagged;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides which products get reported as urgent and which ones get an unpublish call. Because classifyVariantHealth is pure, the test needs no network and no Saleor store. It just feeds in plain objects and checks the answer.

test_variant_health.py
from flag_variantless_products import classify_variant_health


def product(**over):
    base = {
        "id": "UHJvZHVjdDox",
        "variants": [{"id": "UHJvZHVjdFZhcmlhbnQ6MQ=="}],
        "channelListings": [{"channel": {"slug": "default-channel"}, "isPublished": True}],
    }
    base.update(over)
    return base


def test_ok_when_it_has_a_variant():
    result = classify_variant_health(product())
    assert result == {"status": "OK", "affectedChannels": []}


def test_no_variants_published_when_a_channel_is_published():
    result = classify_variant_health(product(variants=[]))
    assert result["status"] == "NO_VARIANTS_PUBLISHED"
    assert result["affectedChannels"] == ["default-channel"]


def test_no_variants_unpublished_when_no_channel_is_published():
    listings = [{"channel": {"slug": "default-channel"}, "isPublished": False}]
    result = classify_variant_health(product(variants=[], channelListings=listings))
    assert result == {"status": "NO_VARIANTS_UNPUBLISHED", "affectedChannels": []}


def test_multiple_published_channels_are_all_reported():
    listings = [
        {"channel": {"slug": "default-channel"}, "isPublished": True},
        {"channel": {"slug": "pos"}, "isPublished": True},
        {"channel": {"slug": "b2b"}, "isPublished": False},
    ]
    result = classify_variant_health(product(variants=[], channelListings=listings))
    assert result["status"] == "NO_VARIANTS_PUBLISHED"
    assert result["affectedChannels"] == ["default-channel", "pos"]


def test_no_channel_listings_at_all_is_unpublished():
    result = classify_variant_health(product(variants=[], channelListings=[]))
    assert result == {"status": "NO_VARIANTS_UNPUBLISHED", "affectedChannels": []}
variant-health.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyVariantHealth } from "./flag-variantless-products.js";

const product = (over = {}) => ({
  id: "UHJvZHVjdDox",
  variants: [{ id: "UHJvZHVjdFZhcmlhbnQ6MQ==" }],
  channelListings: [{ channel: { slug: "default-channel" }, isPublished: true }],
  ...over,
});

test("OK when it has a variant", () => {
  assert.deepEqual(classifyVariantHealth(product()), { status: "OK", affectedChannels: [] });
});

test("NO_VARIANTS_PUBLISHED when a channel is published", () => {
  const result = classifyVariantHealth(product({ variants: [] }));
  assert.equal(result.status, "NO_VARIANTS_PUBLISHED");
  assert.deepEqual(result.affectedChannels, ["default-channel"]);
});

test("NO_VARIANTS_UNPUBLISHED when no channel is published", () => {
  const listings = [{ channel: { slug: "default-channel" }, isPublished: false }];
  const result = classifyVariantHealth(product({ variants: [], channelListings: listings }));
  assert.deepEqual(result, { status: "NO_VARIANTS_UNPUBLISHED", affectedChannels: [] });
});

test("multiple published channels are all reported", () => {
  const listings = [
    { channel: { slug: "default-channel" }, isPublished: true },
    { channel: { slug: "pos" }, isPublished: true },
    { channel: { slug: "b2b" }, isPublished: false },
  ];
  const result = classifyVariantHealth(product({ variants: [], channelListings: listings }));
  assert.equal(result.status, "NO_VARIANTS_PUBLISHED");
  assert.deepEqual(result.affectedChannels, ["default-channel", "pos"]);
});

test("no channel listings at all is unpublished", () => {
  const result = classifyVariantHealth(product({ variants: [], channelListings: [] }));
  assert.deepEqual(result, { status: "NO_VARIANTS_UNPUBLISHED", affectedChannels: [] });
});

Case studies

Bulk import

A migration script left three hundred products with no variant

A merchant migrating off a legacy platform wrote a script that called productCreate for every SKU in an export, then a second pass to add variants and pricing. The second pass died partway through on a malformed row, and nobody noticed because the first pass had already reported success for every product.

Running the detection script found close to three hundred products with an empty variants list, a little over a third of them published to the storefront channel. The team unpublished those with the script's dry run turned off, fixed the malformed rows, and reran their variant importer before republishing.

Draft product

A merchandiser's placeholder went live by accident

A merchandiser created a product to reserve a name and slug ahead of a launch, intending to add the variant and price once photography was ready. A channel-wide publish job later swept it up and pushed it live along with everything else in the catalog.

The storefront's product page threw on defaultVariant being null, and a customer reported a broken page instead of a missing item. The team now runs this script daily on the storefront channel, and it would have unpublished that placeholder automatically instead of letting it reach a shopper.

What good looks like

After this runs on a schedule, a variant-less product is a report row with a channel list attached, not a crash a customer discovers first. Published ones get pulled out of the storefront's reach automatically, unpublished ones sit safely as drafts, and a merchant always finishes the job by adding a real variant with productVariantCreate before it goes live again.

FAQ

Why does a Saleor product with no variant break pricing and availability?

Saleor stores price, SKU, stock, and channel availability on the ProductVariant, not on the Product itself. Product.defaultVariant and Product.pricing are resolved from the product's variants and their channel listings. If a product was created through the API without ever calling productVariantCreate, it has zero variants, so defaultVariant resolves to null and pricing or isAvailable resolvers that assume a variant exists can throw or return null in ways storefront and checkout code often do not guard against.

Can Saleor auto-fix a product that has no variants?

No. Saleor cannot invent a SKU, a price, or a stock quantity for a variant, so there is no safe automated write for this. The right move is to detect and report every variant-less product, prioritize the ones that are published to a channel since those are reachable by storefront and checkout queries, and optionally unpublish them with productChannelListingUpdate until a merchant adds a real variant with productVariantCreate and sets its price with productVariantChannelListingUpdate.

How do I find every product without a variant in Saleor?

Query products with a nested count of variants and channel listings, for example products(first: 100, channel: "default-channel") with defaultVariant { id }, variants { id }, and channelListings { channel { slug } isPublished } on each node, paginating with after and pageInfo. A product is affected when its variants list is empty, which is the same thing as defaultVariant being null. Cross-reference with channelListings to prioritize published, variant-less products first.

Related field notes

Citations

On the problem:

  1. Products without variant make saleor crash. github.com/saleor/saleor/issues/1734
  2. Make variants after initial creation. github.com/saleor/saleor/issues/8589
  3. Why am I unable to create a product via the API? github.com/saleor/saleor/discussions/9367

On the solution:

  1. Saleor Commerce Documentation: the Product object. docs.saleor.io/api-reference/products/objects/product
  2. Saleor Commerce Documentation: the ProductVariant object. docs.saleor.io/api-reference/products/objects/product-variant
  3. Saleor Commerce Documentation: Products API guide. docs.saleor.io/developer/products/api

Stuck on a tricky one?

If you have a problem in Saleor products, variants, checkout, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a broken product before a shopper did?

If this saved you a support ticket about a product page that would not load, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Saleor field notes