Repair WooCommerce core: products and catalog
Products stranded with no category
The product is published. The price is set. Stock is in. Someone can even buy it if they land on the exact page. But it never shows up on any category page, never appears in a category widget, and never shows in a menu link that filters by category, because it has no category at all. This is easy to miss because nothing looks broken in the product editor. Here is why it happens and a small script that finds every stranded product and gives it a safe fallback category.
A product with an empty categories array is published and sellable, but it cannot be found through any page, widget, or menu link that filters by category. Only a direct link, a search result, or an ad can reach it. Run a small Python or Node.js script on a schedule that reads every published product, flags the ones with zero categories, and assigns a fallback category so the product becomes reachable again. The script also checks recent Stripe PaymentIntents so a stranded product that is still quietly selling gets flagged with more urgency. Full code, tests, and a dry run guard are below.
The problem in plain words
In WooCommerce, category pages, category widgets, and most "shop by department" menu links do not read the whole catalog. They read a category, then list the products assigned to it. A product that belongs to no category is not an edge case of any one category, it is simply absent from that whole system of pages and links.
The product itself looks completely normal. It has a title, a price, an image, a description, even stock. The only thing missing is one empty array field that most people never open a product to check. So the product sits there, published, technically live, and functionally invisible to anyone who arrives by browsing instead of by a direct link.
Why it happens
The WooCommerce docs describe categories as a normal taxonomy, something you are expected to assign when you create a product, but nothing in the editor forces the field to be filled in. A few common ways a product ends up with none:
- The product was created through a bulk import or a CSV upload where the category column was blank, missing, or pointed at a category name that did not exist yet, so the import silently skipped it.
- A category was deleted or merged into another one, and WooCommerce removed the link on every product that used it without assigning a replacement.
- The product was duplicated from a draft or a template that never had a category set, and the duplicate carried the gap forward.
- A third-party sync tool, feed importer, or migration script created the product directly through the REST API and only sent the fields it cared about, leaving
categoriesas an empty array by default.
This is easy to miss because WooCommerce never warns about it. The product passes every validation check, since a category is not a required field. The gap only surfaces when someone notices a product is not showing up in the shop page, or when a sales report shows orders for an item nobody remembers seeing while browsing.
A missing category is not a display bug, it is a missing relationship. WooCommerce cannot invent a category for a product on its own, and it should not guess. The safe fix is a script that finds every published product with zero categories and assigns one known, deliberate fallback category, so the product becomes reachable again while a human decides where it really belongs.
The fix, as a flow
We do not touch pricing, stock, or the storefront templates. We add a job that walks published products, checks whether the categories array is empty, and if it is, assigns one configured fallback category, for example an "Uncategorized" or "Needs review" category made for exactly this. We also check recent Stripe PaymentIntents against each order's line items, so a stranded product that is still selling through a direct link gets called out as the more urgent case in the log.
Build it step by step
Get access and create a fallback category
You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to products, and a Stripe secret key for the sales cross-check. In WooCommerce, Products, Categories, create one category to use as the fallback, something like "Uncategorized" or "Needs review," and note its numeric id. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export FALLBACK_CATEGORY_ID="15"
export LOOKBACK_HOURS="24"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export FALLBACK_CATEGORY_ID="15"
export LOOKBACK_HOURS="24"
export DRY_RUN="true" // start safe, change to false to write
List the published products
Page through every product with status publish using the WooCommerce REST API. Draft and private products are skipped, since a product nobody can see yet does not need a category fix now.
import os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
def woo_products():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products",
params={"status": "publish", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for product in batch:
yield product
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* wooProducts() {
let page = 1;
while (true) {
const batch = await woo(`/products?status=publish&per_page=50&page=${page}`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
Find which stranded products are still selling
To know which stranded products matter most, we cross-check them against real sales. We ask Stripe for PaymentIntents that succeeded in the lookback window, read the WooCommerce order id from metadata.order_id, then load each order and confirm its own saved PaymentIntent id, from meta _stripe_intent_id or transaction_id, is the one Stripe actually reported. Only then do we trust the order's line items to mark a product as recently sold.
import time, stripe
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def recent_succeeded_order_ids(lookback_hours):
since = int(time.time()) - lookback_hours * 3600
order_ids = set()
for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
if intent.status == "succeeded":
order_id = intent.metadata.get("order_id")
if order_id:
order_ids.add(order_id)
return order_ids
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function recentSucceededOrderIds(lookbackHours) {
const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
const orderIds = new Set();
for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
if (intent.status === "succeeded") {
const orderId = intent.metadata && intent.metadata.order_id;
if (orderId) orderIds.add(orderId);
}
}
return orderIds;
}
Decide, with one pure function
Keep the decision in its own function that takes a product, the configured fallback category id, and whether the product was recently sold, then returns an action. A pure function like this is easy to read and easy to test, which we do later. Whether the product recently sold never changes the action, a stranded product always needs the same fix, it only changes how loudly we log it.
SYNCABLE_STATUSES = {"publish"}
def has_category(product):
return bool(product.get("categories"))
def decide(product, fallback_category_id, recently_sold):
if product.get("status") not in SYNCABLE_STATUSES:
return ("skip", "product is not published")
if has_category(product):
return ("skip", "product already has at least one category")
if not fallback_category_id:
return ("blocked", "no FALLBACK_CATEGORY_ID configured")
if recently_sold:
return ("fix", "stranded with no category, and it has recent sales")
return ("fix", "stranded with no category")
const SYNCABLE_STATUSES = new Set(["publish"]);
export function hasCategory(product) {
return Boolean(product.categories && product.categories.length);
}
export function decide(product, fallbackCategoryId, recentlySold) {
if (!SYNCABLE_STATUSES.has(product.status)) return ["skip", "product is not published"];
if (hasCategory(product)) return ["skip", "product already has at least one category"];
if (!fallbackCategoryId) return ["blocked", "no FALLBACK_CATEGORY_ID configured"];
if (recentlySold) return ["fix", "stranded with no category, and it has recent sales"];
return ["fix", "stranded with no category"];
}
Assign the fallback category
When the action is fix, write the fallback category onto the product through the REST API. This does not clear anything, and it does not guess a real category, it only puts the product back into one place a shop manager can find and re-sort it from. The product is reachable again the moment this write lands.
def assign_fallback_category(product_id, fallback_category_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"categories": [{"id": fallback_category_id}]},
auth=AUTH, timeout=30,
).raise_for_status()
async function assignFallbackCategory(productId, fallbackCategoryId) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({ categories: [{ id: fallbackCategoryId }] }),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would assign. Read the output, trust it, then switch it off to let it write. Run it once a day with cron, since catalog drift like this is slow moving and does not need minute by minute checking.
Always start with DRY_RUN=true. This script writes to real, live products, so you want to see its plan before it acts. Once the report looks right, turn it off.
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 a product that already has a category is always skipped.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Assign a fallback category to WooCommerce products that have no category at all.
A product with an empty categories array cannot be found through category pages,
menu links, or any widget that filters by category. It still has a direct URL and
still shows in search, so it quietly keeps selling while being invisible everywhere
a browsing shopper would normally find it. This walks published products, flags the
ones with zero categories, and assigns a configured fallback category so the product
is reachable again. It also checks recent Stripe PaymentIntents so a product that is
actively selling gets called out with higher urgency in the log. Read only by
default until DRY_RUN is turned off. Safe to run again and again.
"""
import os
import time
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("assign_fallback_category")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
FALLBACK_CATEGORY_ID = int(os.environ.get("FALLBACK_CATEGORY_ID", "0"))
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SYNCABLE_STATUSES = {"publish"}
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def has_category(product):
return bool(product.get("categories"))
def decide(product, fallback_category_id, recently_sold):
"""Pure decision: does this product need the fallback category assigned?
Returns a tuple of (action, reason). Action is one of:
"skip" - not something we touch (draft/private, or already has a category)
"blocked" - stranded, but there is no fallback category configured to use
"fix" - stranded and needs the fallback category assigned
`recently_sold` is only used to make the log line more useful; it never
changes the action itself, since a stranded product needs fixing either way.
"""
if product.get("status") not in SYNCABLE_STATUSES:
return ("skip", "product is not published")
if has_category(product):
return ("skip", "product already has at least one category")
if not fallback_category_id:
return ("blocked", "no FALLBACK_CATEGORY_ID configured")
if recently_sold:
return ("fix", "stranded with no category, and it has recent sales")
return ("fix", "stranded with no category")
def woo_products():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products",
params={"status": "publish", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for product in batch:
yield product
page += 1
def recent_succeeded_order_ids():
"""Order ids behind PaymentIntents that succeeded in the lookback window."""
since = int(time.time()) - LOOKBACK_HOURS * 3600
order_ids = set()
for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
if intent.status == "succeeded":
order_id = intent.metadata.get("order_id")
if order_id:
order_ids.add(order_id)
return order_ids
def recently_sold_product_ids(order_ids):
"""Product ids that appear as a line item on any of the given recent orders."""
product_ids = set()
for order_id in order_ids:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
continue
r.raise_for_status()
order = r.json()
# Confirm the order's own saved PaymentIntent id is the one Stripe reported,
# so we never trust an order id from metadata alone.
if not intent_id_of(order):
continue
for line in order.get("line_items") or []:
if line.get("product_id"):
product_ids.add(line["product_id"])
return product_ids
def assign_fallback_category(product_id, fallback_category_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"categories": [{"id": fallback_category_id}]},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
blocked = 0
sold_ids = recently_sold_product_ids(recent_succeeded_order_ids())
for product in woo_products():
recently_sold = product["id"] in sold_ids
action, reason = decide(product, FALLBACK_CATEGORY_ID, recently_sold)
if action == "skip":
continue
if action == "blocked":
log.warning("Product %s (%s): %s", product["id"], product.get("name"), reason)
blocked += 1
continue
log.info(
"Product %s (%s): %s. %s",
product["id"], product.get("name"), reason,
"would assign fallback category" if DRY_RUN else "assigning fallback category",
)
if not DRY_RUN:
assign_fallback_category(product["id"], FALLBACK_CATEGORY_ID)
fixed += 1
log.info(
"Done. %d product(s) %s. %d blocked on missing config.",
fixed, "to fix" if DRY_RUN else "fixed", blocked,
)
if __name__ == "__main__":
run()
/**
* Assign a fallback category to WooCommerce products that have no category at all.
*
* A product with an empty categories array cannot be found through category pages,
* menu links, or any widget that filters by category. It still has a direct URL and
* still shows in search, so it quietly keeps selling while being invisible everywhere
* a browsing shopper would normally find it. This walks published products, flags the
* ones with zero categories, and assigns a configured fallback category so the product
* is reachable again. It also checks recent Stripe PaymentIntents so a product that is
* actively selling gets called out with higher urgency in the log. Read only by
* default until DRY_RUN is turned off. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/products-stranded-with-no-category/
*/
import Stripe from "stripe";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const FALLBACK_CATEGORY_ID = Number(process.env.FALLBACK_CATEGORY_ID || 0);
const LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SYNCABLE_STATUSES = new Set(["publish"]);
/** The WooCommerce order id a Stripe PaymentIntent was billed for, if any. */
export function orderIdOf(intent) {
return (intent.metadata && intent.metadata.order_id) || null;
}
/** The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id. */
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function hasCategory(product) {
return Boolean(product.categories && product.categories.length);
}
/**
* Pure decision: does this product need the fallback category assigned?
* Returns [action, reason]. Action is one of:
* "skip" - not something we touch (draft/private, or already has a category)
* "blocked" - stranded, but there is no fallback category configured to use
* "fix" - stranded and needs the fallback category assigned
* `recentlySold` only changes the log message, never the action itself, since a
* stranded product needs fixing either way.
*/
export function decide(product, fallbackCategoryId, recentlySold) {
if (!SYNCABLE_STATUSES.has(product.status)) return ["skip", "product is not published"];
if (hasCategory(product)) return ["skip", "product already has at least one category"];
if (!fallbackCategoryId) return ["blocked", "no FALLBACK_CATEGORY_ID configured"];
if (recentlySold) return ["fix", "stranded with no category, and it has recent sales"];
return ["fix", "stranded with no category"];
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* wooProducts() {
let page = 1;
while (true) {
const batch = await woo(`/products?status=publish&per_page=50&page=${page}`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
async function recentSucceededOrderIds() {
const since = Math.floor(Date.now() / 1000) - LOOKBACK_HOURS * 3600;
const orderIds = new Set();
for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
if (intent.status === "succeeded") {
const orderId = orderIdOf(intent);
if (orderId) orderIds.add(orderId);
}
}
return orderIds;
}
async function recentlySoldProductIds(orderIds) {
const productIds = new Set();
for (const orderId of orderIds) {
const order = await woo(`/orders/${orderId}`);
if (!order) continue;
// Confirm the order's own saved PaymentIntent id is the one Stripe reported,
// so we never trust an order id from metadata alone.
if (!intentIdOf(order)) continue;
for (const line of order.line_items || []) {
if (line.product_id) productIds.add(line.product_id);
}
}
return productIds;
}
async function assignFallbackCategory(productId, fallbackCategoryId) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({ categories: [{ id: fallbackCategoryId }] }),
});
}
export async function run() {
let fixed = 0;
let blocked = 0;
const soldIds = await recentlySoldProductIds(await recentSucceededOrderIds());
for await (const product of wooProducts()) {
const recentlySold = soldIds.has(product.id);
const [action, reason] = decide(product, FALLBACK_CATEGORY_ID, recentlySold);
if (action === "skip") continue;
if (action === "blocked") {
console.warn(`Product ${product.id} (${product.name}): ${reason}`);
blocked++;
continue;
}
console.log(
`Product ${product.id} (${product.name}): ${reason}. ` +
`${DRY_RUN ? "would assign fallback category" : "assigning fallback category"}`
);
if (!DRY_RUN) await assignFallbackCategory(product.id, FALLBACK_CATEGORY_ID);
fixed++;
}
console.log(`Done. ${fixed} product(s) ${DRY_RUN ? "to fix" : "fixed"}. ${blocked} blocked on missing config.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides what gets written to a live product catalog. Because we kept decide pure, the test needs no network and no store credentials. It just feeds in plain objects and checks the action.
from assign_fallback_category import decide, has_category, intent_id_of
def product(**over):
base = {"id": 42, "name": "Ceramic Mug", "status": "publish", "categories": []}
base.update(over)
return base
def test_fix_when_no_category_and_config_present():
action, _ = decide(product(), fallback_category_id=99, recently_sold=False)
assert action == "fix"
def test_fix_reason_mentions_sales_when_recently_sold():
action, reason = decide(product(), fallback_category_id=99, recently_sold=True)
assert action == "fix"
assert "recent sales" in reason
def test_skip_when_product_already_has_a_category():
p = product(categories=[{"id": 12, "name": "Mugs"}])
action, _ = decide(p, fallback_category_id=99, recently_sold=False)
assert action == "skip"
def test_skip_when_not_published():
action, _ = decide(product(status="draft"), fallback_category_id=99, recently_sold=False)
assert action == "skip"
def test_blocked_when_no_fallback_category_configured():
action, reason = decide(product(), fallback_category_id=0, recently_sold=False)
assert action == "blocked"
assert "FALLBACK_CATEGORY_ID" in reason
def test_has_category_true_with_categories():
assert has_category(product(categories=[{"id": 1, "name": "Mugs"}])) is True
def test_has_category_false_when_empty():
assert has_category(product(categories=[])) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, hasCategory, intentIdOf } from "./assign-fallback-category.js";
const product = (over = {}) => ({
id: 42, name: "Ceramic Mug", status: "publish", categories: [], ...over,
});
test("fix when no category and config present", () => {
assert.equal(decide(product(), 99, false)[0], "fix");
});
test("fix reason mentions sales when recently sold", () => {
const [action, reason] = decide(product(), 99, true);
assert.equal(action, "fix");
assert.match(reason, /recent sales/);
});
test("skip when product already has a category", () => {
const p = product({ categories: [{ id: 12, name: "Mugs" }] });
assert.equal(decide(p, 99, false)[0], "skip");
});
test("skip when not published", () => {
assert.equal(decide(product({ status: "draft" }), 99, false)[0], "skip");
});
test("blocked when no fallback category configured", () => {
const [action, reason] = decide(product(), 0, false);
assert.equal(action, "blocked");
assert.match(reason, /FALLBACK_CATEGORY_ID/);
});
test("intentIdOf from meta", () => {
const order = { meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" };
assert.equal(intentIdOf(order), "pi_123");
});
Case studies
The CSV column that pointed at a category that did not exist yet
A store imported four hundred products from a CSV export of a supplier catalog. The category column referenced category names that had not been created in WooCommerce yet, so the importer quietly left categories empty on every row rather than fail the whole import. Nobody noticed until a manager asked why the shop page felt "thin" compared to the actual number of products.
The script ran in dry run first, listed all 187 affected products, and the team assigned them all to a temporary "Needs review" category in under a minute, buying time to sort them into real categories properly.
The merge that left products with nothing
A store merged two near duplicate categories, "Candles" and "Candle," into one, and deleted the smaller one from the admin screen. WooCommerce removed the link from every product that had used the deleted category, but did not reassign them to the surviving one, since a delete does not imply where things should go instead.
The stranded products cross-checked against recent Stripe payments showed three of them had sold in the past week anyway, through a link in an old email campaign. Those three got flagged first in the log, and the fallback category kept every one of the twenty-two affected products reachable again the same day.
After this runs on a schedule, no published product can silently drift out of every category page. The worst case becomes a product sitting in a clearly labeled fallback category for a day or two before a human sorts it properly, instead of sitting completely invisible for months. Keep it running even after a cleanup, since imports and category merges will keep happening.
FAQ
Why is a published product missing from every category page?
WooCommerce only lists a product on a category page, category widget, or menu link that filters by category if the product actually belongs to at least one category. A product with an empty categories array is still published and still reachable by direct link or search, but it is invisible everywhere a shopper browses by category.
Is it safe to assign a category with a script?
Yes, when the script only touches published products whose categories array is empty and skips anything that already has a category. It assigns one configured fallback category so the product becomes reachable again, and a shop manager can move it to the right category later. Start in dry run mode to review the list first.
How do I find which products are actually losing sales because of this?
Cross-check the stranded product list against recent orders. A product that shows up as a paid line item despite having no category is still selling through direct links, ads, or search, which means the category gap is pure lost browsing traffic. Fix those first.
Related field notes
Citations
On the problem:
- WooCommerce docs: managing product categories and how they control storefront browsing. woocommerce.com/document/managing-product-categories
- WooCommerce docs: product CSV importer and how column mapping affects taxonomy fields like category. woocommerce.com/document/product-csv-importer-exporter
- WordPress developer docs: taxonomy terms and what happens to a post's term relationships when a term is deleted. developer.wordpress.org/reference/functions/wp_delete_term
On the solution:
- WooCommerce REST API: list and update products, including the categories field. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: product category endpoints for creating and reading categories. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: list PaymentIntents with auto pagination and a created filter. docs.stripe.com/api/payment_intents/list
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this find your stranded products?
If this saved you a pile of "why isn't this showing up" tickets, 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