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.

Python and Node.js Runs on a schedule Safe by default (dry run)
Film reel products
Photo by Markus Winkler on Unsplash
The short answer

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.

Product published categories: [] Shop queries by category taxonomy no match, nothing to list Stranded product not on any page No browsing traffic at all
The product is real and published, but nothing that lists by category ever finds it, because it belongs to none.

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:

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.

The key insight

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.

Scheduled job once a day List published WooCommerce products Check categories array on each product Empty and fallback set? no, skip yes Assign fallback category, log it
The script only ever touches a product that is published and has zero categories, and only ever assigns the one category a human already configured as the fallback.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

step2.py
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
step2.js
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++;
  }
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

decide.py
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")
decide.js
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"];
}
5

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.

apply.py
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()
apply.js
async function assignFallbackCategory(productId, fallbackCategoryId) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({ categories: [{ id: fallbackCategoryId }] }),
  });
}
6

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.

Run it safe

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_fallback_category.py
"""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-fallback-category.js
/**
 * 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.

test_stranded_decide.py
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
assign-fallback-category.test.js
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

Bulk import

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.

Category cleanup

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.

What good looks like

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:

  1. WooCommerce docs: managing product categories and how they control storefront browsing. woocommerce.com/document/managing-product-categories
  2. WooCommerce docs: product CSV importer and how column mapping affects taxonomy fields like category. woocommerce.com/document/product-csv-importer-exporter
  3. 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:

  1. WooCommerce REST API: list and update products, including the categories field. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: product category endpoints for creating and reading categories. woocommerce.github.io/woocommerce-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes