Reconciler Inventory and catalog

Products stranded with no category

A product was created through the API, a bulk import, or a migration script, and it saved without an error. It has a name, a price, a SKU, everything looks right in the admin. But no shopper ever finds it by browsing. It never shows up under any category, any navigation link, any facet. The product is not broken. It is just invisible, because nothing ever pointed a category at it. Here is why BigCommerce quietly lets this happen and a small script that finds every stranded product and gives it a fallback category so it can be found again.

Python and Node.js V3 Catalog REST Safe by default (dry run)
The short answer

BigCommerce builds category pages, navigation, and most storefront browsing by walking each category's assigned products, not the other way around. A product's categories field is just an array of category ids, and nothing forces it to be non-empty when the product is created through POST /v3/catalog/products or a bulk import. If that array ships empty, the product saves fine and stays completely reachable by direct link, but it never appears in any category, menu, or facet a normal shopper would use. Run a small Python or Node.js script that scans the catalog, flags every product whose categories array is empty, and assigns a clearly labeled fallback category so the product becomes findable again while a human decides where it really belongs. Full code, tests, and a dry run guard are below.

The problem in plain words

In BigCommerce, a product and a category are two separate objects, and the link between them is just a list of ids sitting on the product's categories field. Creating a product never requires that list to have anything in it. The API accepts an empty array, and so does a CSV import that leaves the category column blank.

The storefront, though, is built around categories, not around one giant product list. Category pages, most navigation menus, and a lot of search facets all work by asking a category for its assigned products and rendering those. A product that no category has ever claimed is not broken and not hidden by a visibility flag. It simply never comes up in that walk. It still has a working product page if you know the exact URL, but nobody arrives there by browsing, filtering, or clicking through the site the way an ordinary shopper does.

Product created API or bulk import categories: [] saves without error no category claims it No category page no menu, no facet Product stranded
The product page itself still works. It is simply never reached, because nothing in the storefront's category structure points to it.

Why it happens

The categories field is just an array of ids on the product, and nothing about creating or updating a product requires it to be filled in. A few common ways stores end up with stranded products:

This is a quiet failure. The product looks completely normal in the admin, the price and stock are correct, and nothing in the UI screams that it is invisible to browsing. Merchants usually find out only when a customer says they searched for something and could not find it, or when someone notices sales on a product have gone flat despite good stock. See the citations at the end for the exact support articles and API references.

The key insight

Assigning a category to a product a script has never seen before is a guess, and a wrong guess buries the product under the wrong heading just as effectively as no heading at all. So the safe pattern is not "put every uncategorized product somewhere plausible." It is "put every uncategorized product into one clearly labeled holding category, and let a human sort it from there." That keeps every product findable immediately without the script pretending to know where it truly belongs.

The fix, as a flow

We do not touch products that already have at least one category, and we never invent a specific category for a product based on its name or description. We add a job that scans the catalog, flags every product whose categories array is empty, and assigns a single fallback category that exists just to make sure nothing stays invisible.

Scan products GET /v3/catalog/products Read categories field per product categories empty? no, already fine yes, stranded Assign fallback one holding category PUT product
Only products whose categories array is truly empty get touched, and every one of them gets the same clearly labeled fallback category, never a guess.

Build it step by step

1

Get a store hash and an API account token

In your BigCommerce control panel, go to Settings, API, Store-level API accounts, and create an account with the Products scope set to modify. Note the store hash from your control panel URL and the access token from the API account. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export FALLBACK_CATEGORY_ID="99"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export FALLBACK_CATEGORY_ID="99"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST API

Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET and PUT, raises on a non-2xx response, and unwraps the V3 {"data": ...} envelope so callers get plain objects back.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}
3

Scan every product with its categories

Call GET /v3/catalog/products?limit=250 and page through the whole catalog. Each product comes back with its own id, name, and categories array of category ids. This step never writes anything, it just reads.

step3.py
def all_products():
    page = 1
    limit = 250
    while True:
        batch = bc("GET", f"/v3/catalog/products?limit={limit}&page={page}")
        if not batch:
            return
        for product in batch:
            yield product
        if len(batch) < limit:
            return
        page += 1
step3.js
async function* allProducts() {
  let page = 1;
  const limit = 250;
  while (true) {
    const batch = await bc("GET", `/v3/catalog/products?limit=${limit}&page=${page}`);
    if (!batch || !batch.length) return;
    for (const product of batch) yield product;
    if (batch.length < limit) return;
    page += 1;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes a product and returns whether it is stranded. A product is stranded only when its categories array is missing or empty. A product that already carries even one category id is left completely alone, since the script's job is to catch products with none, not to reorganize a catalog that already has structure.

decide.py
def is_stranded(product):
    categories = product.get("categories") or []
    return len(categories) == 0
decide.js
export function isStranded(product) {
  const categories = product.categories || [];
  return categories.length === 0;
}
5

Assign the fallback category with a single field

When a product is stranded, call PUT /v3/catalog/products/{product_id} with categories set to a single fallback category id, one you create ahead of time and label clearly, such as Uncategorized. That one write makes the product reachable from a real category page immediately, without the script pretending to know its true home.

apply.py
def assign_fallback_category(product_id, fallback_category_id):
    return bc("PUT", f"/v3/catalog/products/{product_id}", json={"categories": [fallback_category_id]})
apply.js
async function assignFallbackCategory(productId, fallbackCategoryId) {
  return bc("PUT", `/v3/catalog/products/${productId}`, { categories: [fallbackCategoryId] });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs which products it would assign to the fallback category. Read the list, confirm it really is your uncategorized backlog, then switch it off to let it write. Run the job after each import or on a nightly schedule so nothing sits invisible for long.

Run it safe

Always start with DRY_RUN=true, and only ever assign the one fallback category, never a guessed one. Treat the fallback category as a holding pen. A human still has to look through it and move each product to where it truly belongs, this script only guarantees nothing stays invisible in the meantime.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only touches products whose categories array is truly empty.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

fix_stranded_products.py
"""Find and safely repair BigCommerce products with no category assigned.

A product's categories field is just an array of category ids, and nothing
about creating a product through POST /v3/catalog/products or a bulk import
requires that array to be non-empty. When it ships empty, the product saves
fine and stays reachable by direct link, but it never appears on any category
page, navigation menu, or facet a normal shopper uses to browse.

This scans every product with GET /v3/catalog/products, classifies each one
with a pure function, and for every stranded product (an empty categories
array) assigns one clearly labeled fallback category with a single PUT. It
never guesses a specific category from the product name, and it never touches
a product that already has at least one category. Guarded by DRY_RUN. Safe to
run again and again.
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
FALLBACK_CATEGORY_ID = int(os.environ.get("FALLBACK_CATEGORY_ID", "0"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body


def is_stranded(product):
    """Pure classification. No network calls.

    product: {"id": int, "name": str, "categories": [int, ...]}

    Returns True only when the product's categories array is missing or
    empty. A product with even one category id already is not stranded and
    is left completely alone.
    """
    categories = product.get("categories") or []
    return len(categories) == 0


def all_products():
    page = 1
    limit = 250
    while True:
        batch = bc("GET", f"/v3/catalog/products?limit={limit}&page={page}")
        if not batch:
            return
        for product in batch:
            yield product
        if len(batch) < limit:
            return
        page += 1


def assign_fallback_category(product_id, fallback_category_id):
    return bc("PUT", f"/v3/catalog/products/{product_id}", json={"categories": [fallback_category_id]})


def run():
    if not FALLBACK_CATEGORY_ID:
        raise RuntimeError("FALLBACK_CATEGORY_ID must be set to a real category id before running.")

    fixed = 0
    for product in all_products():
        if not is_stranded(product):
            continue
        log.info(
            "Product %s (%s) has no category. %s",
            product["id"], product.get("name"),
            "would assign fallback" if DRY_RUN else "assigning fallback",
        )
        if not DRY_RUN:
            assign_fallback_category(product["id"], FALLBACK_CATEGORY_ID)
        fixed += 1
    log.info("Done. %d product(s) %s.", fixed, "to assign" if DRY_RUN else "assigned a fallback category")


if __name__ == "__main__":
    run()
fix-stranded-products.js
/**
 * Find and safely repair BigCommerce products with no category assigned.
 *
 * A product's categories field is just an array of category ids, and nothing
 * about creating a product through POST /v3/catalog/products or a bulk import
 * requires that array to be non-empty. When it ships empty, the product saves
 * fine and stays reachable by direct link, but it never appears on any category
 * page, navigation menu, or facet a normal shopper uses to browse.
 *
 * This scans every product with GET /v3/catalog/products, classifies each one
 * with a pure function, and for every stranded product (an empty categories
 * array) assigns one clearly labeled fallback category with a single PUT. It
 * never guesses a specific category from the product name, and it never touches
 * a product that already has at least one category. Guarded by DRY_RUN. Safe to
 * run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/products-stranded-with-no-category/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store-hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const FALLBACK_CATEGORY_ID = Number(process.env.FALLBACK_CATEGORY_ID || 0);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure classification. No network calls.
 *
 * product: { id, name, categories: [int, ...] }
 *
 * Returns true only when the product's categories array is missing or
 * empty. A product with even one category id already is not stranded and
 * is left completely alone.
 */
export function isStranded(product) {
  const categories = product.categories || [];
  return categories.length === 0;
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}

async function* allProducts() {
  let page = 1;
  const limit = 250;
  while (true) {
    const batch = await bc("GET", `/v3/catalog/products?limit=${limit}&page=${page}`);
    if (!batch || !batch.length) return;
    for (const product of batch) yield product;
    if (batch.length < limit) return;
    page += 1;
  }
}

async function assignFallbackCategory(productId, fallbackCategoryId) {
  return bc("PUT", `/v3/catalog/products/${productId}`, { categories: [fallbackCategoryId] });
}

export async function run() {
  if (!FALLBACK_CATEGORY_ID) {
    throw new Error("FALLBACK_CATEGORY_ID must be set to a real category id before running.");
  }

  let fixed = 0;
  for await (const product of allProducts()) {
    if (!isStranded(product)) continue;
    console.log(
      `Product ${product.id} (${product.name}) has no category. ${DRY_RUN ? "would assign fallback" : "assigning fallback"}`
    );
    if (!DRY_RUN) await assignFallbackCategory(product.id, FALLBACK_CATEGORY_ID);
    fixed++;
  }
  console.log(`Done. ${fixed} product(s) ${DRY_RUN ? "to assign" : "assigned a fallback category"}.`);
}

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 whether a product gets written to at all. Because we kept is_stranded pure, the test needs no network and no BigCommerce store. It just feeds in plain product objects and checks the answer.

test_products_stranded_classification.py
from fix_stranded_products import is_stranded


def product(**over):
    base = {"id": 501, "name": "Sample Widget", "categories": [12]}
    base.update(over)
    return base


def test_not_stranded_when_it_has_a_category():
    assert is_stranded(product()) is False


def test_stranded_when_categories_is_empty_list():
    assert is_stranded(product(categories=[])) is True


def test_stranded_when_categories_is_missing():
    p = product()
    del p["categories"]
    assert is_stranded(p) is True


def test_stranded_when_categories_is_none():
    assert is_stranded(product(categories=None)) is True


def test_not_stranded_with_multiple_categories():
    assert is_stranded(product(categories=[3, 7, 12])) is False
products-stranded-classification.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStranded } from "./fix-stranded-products.js";

const product = (over = {}) => ({ id: 501, name: "Sample Widget", categories: [12], ...over });

test("not stranded when it has a category", () => {
  assert.equal(isStranded(product()), false);
});

test("stranded when categories is empty list", () => {
  assert.equal(isStranded(product({ categories: [] })), true);
});

test("stranded when categories is missing", () => {
  const p = product();
  delete p.categories;
  assert.equal(isStranded(p), true);
});

test("stranded when categories is null", () => {
  assert.equal(isStranded(product({ categories: null })), true);
});

test("not stranded with multiple categories", () => {
  assert.equal(isStranded(product({ categories: [3, 7, 12] })), false);
});

Case studies

Migration

The platform migration that dropped every category link

A store moved from a different platform using a migration tool that recreated every product through the API. Prices, images, and descriptions all copied over cleanly, but the tool had no mapping for the source platform's category tree, so it left the categories array empty on every single product it created.

The storefront looked empty even though the admin showed thousands of products in stock. The scan found every one of them stranded in a single pass, assigned the fallback category so search and a temporary landing page could surface them immediately, and the team spent the next two weeks sorting products into a real category tree without any of them sitting invisible in the meantime.

Category cleanup

The seasonal category that got deleted too early

A merchant deleted a seasonal category the week after a promotion ended, not realizing a couple dozen products only had that one category assigned. BigCommerce removed the id from each product's categories array along with the category, and those products dropped out of every page they used to appear on.

Nobody noticed until a regular customer asked why a product they always bought had disappeared. Running the scan surfaced the exact list of products that had lost their only category, and the fallback assignment made them findable again the same day, while the merchant went through and gave each one a proper permanent category.

What good looks like

After this runs on a schedule, no product can quietly fall out of every category page just because an import or a script forgot to set one. Every stranded product lands in one clearly labeled holding category within a day, stays fully sellable and findable in the meantime, and a human still makes the final call on where it truly belongs. Nothing gets buried under a guessed category, and nothing stays invisible for long.

FAQ

Why does a BigCommerce product not show up anywhere on the storefront?

The most common reason is that the product's categories array is empty. BigCommerce category pages, and most navigation menus and search facets, list products by walking each category's assigned products. A product with no category assigned has nothing to walk to it, so it only shows up if a shopper lands on its direct product URL or it is added manually to a featured list.

Is it safe to assign a fallback category automatically?

Yes, when the script only touches products whose categories array is truly empty, never removes or reorders any category a product already has, and assigns one clearly named fallback category such as Uncategorized that a human can review and re-sort later. It should never guess a specific category from the product name.

How do I find every product with no category without changing anything?

Call GET /v3/catalog/products with an empty categories array in the response, or GET /v3/catalog/products?include=... and check each product's categories field client side. A product with an empty list is stranded. This is a read only check, so you can run it safely and review the full list before deciding to write anything.

Related field notes

Citations

On the problem:

  1. a. a.com

On the solution:

  1. a. a.com

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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 find some stranded products?

If this rescued a product nobody could browse to, 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 BigCommerce field notes