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.
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.
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:
- A product is created through
POST /v3/catalog/productsby an internal tool or migration script that never set thecategoriesfield, so it defaults to an empty array. - A bulk CSV import leaves the category column blank for some rows, either because the source system had no equivalent field or because a mapping step silently dropped it.
- A category itself gets deleted, and BigCommerce removes that id from every product's
categoriesarray, leaving some products with nothing left if that was their only category. - A channel or catalog sync duplicates products into a store without carrying over the category assignments from the source, since categories are store specific.
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.
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.
Build it step by step
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.
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
// 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
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.
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
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;
}
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.
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
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;
}
}
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.
def is_stranded(product):
categories = product.get("categories") or []
return len(categories) == 0
export function isStranded(product) {
const categories = product.categories || [];
return categories.length === 0;
}
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.
def assign_fallback_category(product_id, fallback_category_id):
return bc("PUT", f"/v3/catalog/products/{product_id}", json={"categories": [fallback_category_id]})
async function assignFallbackCategory(productId, fallbackCategoryId) {
return bc("PUT", `/v3/catalog/products/${productId}`, { categories: [fallbackCategoryId] });
}
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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
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.
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