Repair WooCommerce core: products and catalog

Product visibility terms mis-assigned: when the catalog settings and the storefront disagree

A product's edit screen says Catalog visibility is Shop and search, it is not marked Featured, and it is in stock. Yet on the storefront it is missing from search, buried out of the catalog, or stuck looking featured when it should not be. The settings are right. The hidden taxonomy terms WooCommerce actually reads at render time are wrong. Here is why the two drift apart and a small script that recomputes the correct terms for every product and repairs the ones that are wrong.

Python and Node.js Runs once after imports, or on a schedule Safe by default (dry run)
A close up of a computer screen with some stickers on it
Photo by Ed Hardie on Unsplash
The short answer

WooCommerce does not check catalog_visibility, featured, and stock_status directly when it decides what a shopper can see. It reads a hidden taxonomy called product_visibility, made of terms like exclude-from-search, exclude-from-catalog, featured, and outofstock, and those terms only get updated when WooCommerce's own save routine runs. An import, a bulk edit tool, or a direct database write can change the product's fields without ever updating the terms, so the two fall out of sync. Run a small Python or Node.js script that recomputes the correct term set from each product's own fields, compares it to what is assigned, and repairs any product where they differ. Full code, tests, and a dry run guard are below.

The problem in plain words

In the WooCommerce admin, a product has three settings that decide where it shows up: Catalog visibility, which can be Shop and search, Shop only, Search results only, or Hidden; a Featured checkbox; and its stock status. Those look like plain fields, and the REST API returns them as plain fields too, catalog_visibility, featured, and stock_status.

But none of the storefront queries actually filter on those fields. WooCommerce translates them, once, at save time, into terms on a taxonomy called product_visibility that is attached to the product post. The shop loop, the search query, and the "on sale" and "featured" widgets all filter by those terms, not by the fields you see in the admin. As long as every save goes through WooCommerce's normal product save routine, the translation happens automatically and nobody notices the taxonomy exists. The trouble starts when a product's fields change through a path that skips that translation.

Import or bulk edit changes catalog_visibility Product fields updated the postmeta is correct save routine skipped, terms never recomputed Stale terms remain product_visibility taxonomy Wrong shop and search result
The product's own fields are correct. The terms the storefront actually queries by were never recomputed, so shoppers see the old state.

Why it happens

WooCommerce's core docs describe product_visibility as an internal taxonomy the plugin manages for itself, not something a store owner is meant to edit directly. That is exactly why it is fragile. A few common ways the terms stop matching the fields:

This has been reported often enough that WooCommerce ships its own repair tool for it, a Status, Tools entry called "Terms count" and a related "Clear transients" action, and community threads document the same "regenerate product_visibility" fix by hand. See the citations at the end for the exact references.

The key insight

A product's own fields, catalog_visibility, featured, and stock_status, are the source of truth. The product_visibility terms are a cache of what those fields mean, computed once and then left alone. If the fields say one thing and the terms disagree, the terms are wrong, not the fields. A repair script is a safety net that recomputes the correct term set from the fields and only touches the products where that computed set does not match what is actually assigned.

The fix, as a flow

We do not touch the fields shoppers rely on. We add a job that walks products through the WooCommerce REST API, reads each one's own catalog_visibility, featured, and stock_status, computes the exact set of visibility terms WooCommerce should have assigned for that combination, and compares it to the terms actually recorded for the product. When they differ, we push a full visibility resync for that product the same way WooCommerce's own save routine would, and log exactly what changed.

Read product catalog_visibility, featured, stock Compute expected product_visibility terms Read assigned terms from the visibility report Sets equal? yes Leave it alone no, mismatch Resync visibility terms rebuilt from fields
Only products where the computed term set disagrees with the assigned term set get touched. Everything already correct is left alone.

Build it step by step

1

Get access and know the four terms

You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to products, created under WooCommerce, Settings, Advanced, REST API. The product_visibility taxonomy has exactly four terms that matter here: exclude-from-search, exclude-from-catalog, featured, and outofstock. A product's real visibility is the presence or absence of each one, not a single value.

setup (shell)
pip install requests

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   // start safe, change to false to write
2

Page through every product

Ask the WooCommerce REST API for products a page at a time. Each product record already includes catalog_visibility, featured, and stock_status, which is everything we need to compute what the visibility terms should be.

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 all_products():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"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* allProducts() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const product of batch) yield product;
    page++;
  }
}
3

Read the terms currently assigned

The core REST API does not expose product_visibility term slugs directly on the product resource, since it is meant to be internal. Most stores read the assigned terms through a small custom endpoint, a WP-CLI export, or a report plugin that lists wp_term_relationships for the product_visibility taxonomy per product. Whatever the source, the shape we need is simple: the current list of term slugs on that product.

4

Decide, with one pure function

Keep the decision in its own function that takes a product's fields and its currently assigned term slugs and returns an action. Because it is pure, no network calls inside it, it is easy to read and easy to test, which we do later. The rule is simple. Compute the expected term set from catalog_visibility, featured, and stock_status. If it matches the assigned set exactly, leave it alone. If it does not, repair it.

decide.py
VALID_CATALOG_VISIBILITY = {"visible", "catalog", "search", "hidden"}

def expected_terms(product):
    """The exact set of product_visibility term slugs WooCommerce should assign
    for this product's catalog_visibility, featured, and stock_status fields.
    """
    visibility = product.get("catalog_visibility", "visible")
    terms = set()
    # "catalog" means shop only, so search is excluded.
    if visibility in ("catalog", "hidden"):
        terms.add("exclude-from-search")
    # "search" means search only, so the catalog/shop loop is excluded.
    if visibility in ("search", "hidden"):
        terms.add("exclude-from-catalog")
    if product.get("featured"):
        terms.add("featured")
    if product.get("stock_status") == "outofstock":
        terms.add("outofstock")
    return terms

def decide(product, assigned_terms):
    visibility = product.get("catalog_visibility", "visible")
    if visibility not in VALID_CATALOG_VISIBILITY:
        return ("skip", "unrecognized catalog_visibility value")
    expected = expected_terms(product)
    assigned = set(assigned_terms or [])
    if expected == assigned:
        return ("ok", "assigned terms match the product's own fields")
    return ("repair", f"expected {sorted(expected)} but found {sorted(assigned)}")
decide.js
const VALID_CATALOG_VISIBILITY = new Set(["visible", "catalog", "search", "hidden"]);

export function expectedTerms(product) {
  const visibility = product.catalog_visibility || "visible";
  const terms = new Set();
  if (visibility === "catalog" || visibility === "hidden") terms.add("exclude-from-search");
  if (visibility === "search" || visibility === "hidden") terms.add("exclude-from-catalog");
  if (product.featured) terms.add("featured");
  if (product.stock_status === "outofstock") terms.add("outofstock");
  return terms;
}

export function decide(product, assignedTerms) {
  const visibility = product.catalog_visibility || "visible";
  if (!VALID_CATALOG_VISIBILITY.has(visibility)) {
    return ["skip", "unrecognized catalog_visibility value"];
  }
  const expected = expectedTerms(product);
  const assigned = new Set(assignedTerms || []);
  const same = expected.size === assigned.size && [...expected].every((t) => assigned.has(t));
  if (same) return ["ok", "assigned terms match the product's own fields"];
  return ["repair", `expected ${[...expected].sort()} but found ${[...assigned].sort()}`];
}
5

Repair by forcing a real visibility resync

The safest repair is not to write raw taxonomy terms yourself, since a hand-built term relationship can miss a step WooCommerce also does internally, like clearing lookup table caches. Instead, re-save the same catalog_visibility, featured, and stock_status values through the REST API's update endpoint. WooCommerce's own save routine runs on that update and recomputes product_visibility from scratch, which is exactly the sync an import or bulk edit skipped.

apply.py
def resync_visibility(product):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product['id']}",
        json={
            "catalog_visibility": product.get("catalog_visibility", "visible"),
            "featured": bool(product.get("featured")),
            "stock_status": product.get("stock_status", "instock"),
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function resyncVisibility(product) {
  await woo(`/products/${product.id}`, {
    method: "PUT",
    body: JSON.stringify({
      catalog_visibility: product.catalog_visibility || "visible",
      featured: Boolean(product.featured),
      stock_status: product.stock_status || "instock",
    }),
  });
}
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 which products it would resync and why. Read the output, confirm the mismatches make sense, then switch it off to let it write. Run it once right after any import or bulk edit, and optionally on a daily schedule as a safety net.

Run it safe

Always start with DRY_RUN=true. The repair only re-saves fields the product already has, it never invents a new catalog visibility or featured value, but you still want to see the exact list of affected products before it writes.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because it only touches products whose assigned terms disagree with their own catalog visibility, featured, and stock fields.

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

repair_visibility_terms.py
"""Recompute and repair WooCommerce product_visibility terms that have drifted
away from a product's own catalog_visibility, featured, and stock_status fields.

WooCommerce decides what a shopper can see by querying a hidden taxonomy,
product_visibility, built from terms like exclude-from-search, exclude-from-catalog,
featured, and outofstock. Those terms are only ever recomputed when a product goes
through WooCommerce's normal save routine. An import, a bulk edit tool, or a direct
database write can change catalog_visibility, featured, or stock_status without
triggering that recompute, so the terms and the fields disagree and the storefront
follows the (wrong) terms.

This walks every product through the WooCommerce REST API, computes the exact term
set the product's own fields imply, compares it to the terms actually assigned, and
repairs any product where they differ by re-saving its own fields, which forces
WooCommerce to rebuild the terms. Safe by default (dry run). Run once after an
import or bulk edit, or on a schedule as a safety net.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth

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

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

VALID_CATALOG_VISIBILITY = {"visible", "catalog", "search", "hidden"}


def all_products():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"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 assigned_visibility_terms(product_id):
    """The product_visibility term slugs currently assigned to this product.

    The core REST API does not expose this taxonomy directly, since WooCommerce
    treats it as internal. Most stores read it through a small custom endpoint, a
    WP-CLI export, or a reporting plugin that lists wp_term_relationships for the
    product_visibility taxonomy. This wraps whatever that source is behind one call
    so the rest of the script does not need to know about it.
    """
    r = requests.get(
        f"{WOO_URL}/wp-json/custom/v1/product-visibility-terms/{product_id}",
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json().get("terms", [])


def expected_terms(product):
    """The exact set of product_visibility term slugs WooCommerce should assign
    for this product's catalog_visibility, featured, and stock_status fields.
    """
    visibility = product.get("catalog_visibility", "visible")
    terms = set()
    # "catalog" means shop only, so search is excluded.
    if visibility in ("catalog", "hidden"):
        terms.add("exclude-from-search")
    # "search" means search only, so the catalog/shop loop is excluded.
    if visibility in ("search", "hidden"):
        terms.add("exclude-from-catalog")
    if product.get("featured"):
        terms.add("featured")
    if product.get("stock_status") == "outofstock":
        terms.add("outofstock")
    return terms


def decide(product, assigned_terms):
    """Pure decision: given a product's own fields and its currently assigned
    product_visibility term slugs, decide what to do.

    product: a dict with at least "catalog_visibility", "featured", "stock_status".
    assigned_terms: an iterable of term slugs currently on the product, or None.
    """
    visibility = product.get("catalog_visibility", "visible")
    if visibility not in VALID_CATALOG_VISIBILITY:
        return ("skip", "unrecognized catalog_visibility value")
    expected = expected_terms(product)
    assigned = set(assigned_terms or [])
    if expected == assigned:
        return ("ok", "assigned terms match the product's own fields")
    return ("repair", f"expected {sorted(expected)} but found {sorted(assigned)}")


def resync_visibility(product):
    """Re-save the product's own fields so WooCommerce's save routine rebuilds the
    product_visibility terms from scratch. We never write taxonomy terms directly.
    """
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product['id']}",
        json={
            "catalog_visibility": product.get("catalog_visibility", "visible"),
            "featured": bool(product.get("featured")),
            "stock_status": product.get("stock_status", "instock"),
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    repaired = 0
    for product in all_products():
        assigned = assigned_visibility_terms(product["id"])
        action, reason = decide(product, assigned)
        if action != "repair":
            continue
        log.warning("Product %s: %s. %s", product["id"], reason, "would resync" if DRY_RUN else "resyncing")
        if not DRY_RUN:
            resync_visibility(product)
        repaired += 1
    log.info("Done. %d product(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
repair-visibility-terms.js
/**
 * Recompute and repair WooCommerce product_visibility terms that have drifted
 * away from a product's own catalog_visibility, featured, and stock_status fields.
 *
 * WooCommerce decides what a shopper can see by querying a hidden taxonomy,
 * product_visibility, built from terms like exclude-from-search, exclude-from-catalog,
 * featured, and outofstock. Those terms are only ever recomputed when a product goes
 * through WooCommerce's normal save routine. An import, a bulk edit tool, or a direct
 * database write can change catalog_visibility, featured, or stock_status without
 * triggering that recompute, so the terms and the fields disagree and the storefront
 * follows the (wrong) terms.
 *
 * This walks every product through the WooCommerce REST API, computes the exact
 * term set the product's own fields imply, compares it to the terms actually
 * assigned, and repairs any product where they differ by re-saving its own fields,
 * which forces WooCommerce to rebuild the terms. Safe by default (dry run). Run
 * once after an import or bulk edit, or on a schedule as a safety net.
 *
 * Guide: https://www.allanninal.dev/woocommerce/product-visibility-terms-mis-assigned/
 */
import { pathToFileURL } from "node:url";

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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const VALID_CATALOG_VISIBILITY = new Set(["visible", "catalog", "search", "hidden"]);

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* allProducts() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const product of batch) yield product;
    page++;
  }
}

/**
 * The product_visibility term slugs currently assigned to this product.
 *
 * The core REST API does not expose this taxonomy directly, since WooCommerce
 * treats it as internal. Most stores read it through a small custom endpoint, a
 * WP-CLI export, or a reporting plugin that lists wp_term_relationships for the
 * product_visibility taxonomy. This wraps whatever that source is behind one call
 * so the rest of the script does not need to know about it.
 */
export async function assignedVisibilityTerms(productId) {
  const res = await fetch(`${WOO_URL}/wp-json/custom/v1/product-visibility-terms/${productId}`, {
    headers: { Authorization: AUTH },
  });
  if (!res.ok) throw new Error(`visibility terms lookup returned ${res.status}`);
  const data = await res.json();
  return data.terms || [];
}

/**
 * The exact set of product_visibility term slugs WooCommerce should assign for
 * this product's catalog_visibility, featured, and stock_status fields.
 */
export function expectedTerms(product) {
  const visibility = product.catalog_visibility || "visible";
  const terms = new Set();
  if (visibility === "catalog" || visibility === "hidden") terms.add("exclude-from-search");
  if (visibility === "search" || visibility === "hidden") terms.add("exclude-from-catalog");
  if (product.featured) terms.add("featured");
  if (product.stock_status === "outofstock") terms.add("outofstock");
  return terms;
}

/**
 * Pure decision: given a product's own fields and its currently assigned
 * product_visibility term slugs, decide what to do.
 *
 * product: an object with at least catalog_visibility, featured, stock_status.
 * assignedTerms: an array of term slugs currently on the product, or null/undefined.
 */
export function decide(product, assignedTerms) {
  const visibility = product.catalog_visibility || "visible";
  if (!VALID_CATALOG_VISIBILITY.has(visibility)) {
    return ["skip", "unrecognized catalog_visibility value"];
  }
  const expected = expectedTerms(product);
  const assigned = new Set(assignedTerms || []);
  const same = expected.size === assigned.size && [...expected].every((t) => assigned.has(t));
  if (same) return ["ok", "assigned terms match the product's own fields"];
  return ["repair", `expected ${[...expected].sort()} but found ${[...assigned].sort()}`];
}

/**
 * Re-save the product's own fields so WooCommerce's save routine rebuilds the
 * product_visibility terms from scratch. We never write taxonomy terms directly.
 */
async function resyncVisibility(product) {
  await woo(`/products/${product.id}`, {
    method: "PUT",
    body: JSON.stringify({
      catalog_visibility: product.catalog_visibility || "visible",
      featured: Boolean(product.featured),
      stock_status: product.stock_status || "instock",
    }),
  });
}

export async function run() {
  let repaired = 0;
  for await (const product of allProducts()) {
    const assigned = await assignedVisibilityTerms(product.id);
    const [action, reason] = decide(product, assigned);
    if (action !== "repair") continue;
    console.warn(`Product ${product.id}: ${reason}. ${DRY_RUN ? "would resync" : "resyncing"}`);
    if (!DRY_RUN) await resyncVisibility(product);
    repaired++;
  }
  console.log(`Done. ${repaired} product(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}

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 which products get resynced. Because we kept decide and expected_terms pure, the test needs no network and no WooCommerce store. It just feeds in plain objects and checks the action.

test_visibility_decide.py
from repair_visibility_terms import decide, expected_terms


def product(**over):
    base = {"id": 1, "catalog_visibility": "visible", "featured": False, "stock_status": "instock"}
    base.update(over)
    return base


def test_ok_when_terms_match_visible_product():
    assert decide(product(), [])[0] == "ok"


def test_repair_when_hidden_but_no_exclude_terms():
    assert decide(product(catalog_visibility="hidden"), [])[0] == "repair"


def test_ok_when_hidden_and_both_exclude_terms_present():
    assert decide(product(catalog_visibility="hidden"), ["exclude-from-search", "exclude-from-catalog"])[0] == "ok"


def test_repair_when_featured_flag_true_but_term_missing():
    assert decide(product(featured=True), [])[0] == "repair"


def test_repair_when_featured_term_present_but_flag_false():
    assert decide(product(featured=False), ["featured"])[0] == "repair"


def test_repair_when_out_of_stock_but_term_missing():
    assert decide(product(stock_status="outofstock"), [])[0] == "repair"


def test_ok_when_catalog_only_has_exclude_from_search():
    assert decide(product(catalog_visibility="catalog"), ["exclude-from-search"])[0] == "ok"


def test_skip_when_catalog_visibility_unrecognized():
    assert decide(product(catalog_visibility="whoops"), [])[0] == "skip"


def test_expected_terms_for_hidden_featured_out_of_stock():
    p = product(catalog_visibility="hidden", featured=True, stock_status="outofstock")
    assert expected_terms(p) == {"exclude-from-search", "exclude-from-catalog", "featured", "outofstock"}
repair-visibility-terms.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, expectedTerms } from "./repair-visibility-terms.js";

const product = (over = {}) => ({
  id: 1, catalog_visibility: "visible", featured: false, stock_status: "instock", ...over,
});

test("ok when terms match visible product", () => {
  assert.equal(decide(product(), [])[0], "ok");
});

test("repair when hidden but no exclude terms", () => {
  assert.equal(decide(product({ catalog_visibility: "hidden" }), [])[0], "repair");
});

test("ok when hidden and both exclude terms present", () => {
  assert.equal(
    decide(product({ catalog_visibility: "hidden" }), ["exclude-from-search", "exclude-from-catalog"])[0],
    "ok"
  );
});

test("repair when featured flag true but term missing", () => {
  assert.equal(decide(product({ featured: true }), [])[0], "repair");
});

test("repair when featured term present but flag false", () => {
  assert.equal(decide(product({ featured: false }), ["featured"])[0], "repair");
});

test("repair when out of stock but term missing", () => {
  assert.equal(decide(product({ stock_status: "outofstock" }), [])[0], "repair");
});

test("ok when catalog only has exclude-from-search", () => {
  assert.equal(decide(product({ catalog_visibility: "catalog" }), ["exclude-from-search"])[0], "ok");
});

test("skip when catalog_visibility unrecognized", () => {
  assert.equal(decide(product({ catalog_visibility: "whoops" }), [])[0], "skip");
});

test("expectedTerms for hidden, featured, out of stock", () => {
  const p = product({ catalog_visibility: "hidden", featured: true, stock_status: "outofstock" });
  const terms = expectedTerms(p);
  assert.deepEqual([...terms].sort(), ["exclude-from-catalog", "exclude-from-search", "featured", "outofstock"]);
});

Case studies

CSV import

The import that set fields but never touched the taxonomy

A store re-imported its whole catalog from a supplier feed using a lightweight importer that wrote product meta directly for speed. Two hundred products came back with the right catalog visibility and stock status in the admin screen, but the storefront's search results only ever showed about half of them.

The script ran in dry run, found that every affected product still carried an exclude-from-search term left over from a prior "hidden" state that the import's field update never cleared, and resyncing them brought search back to matching the admin settings exactly.

Bulk featured toggle

The promotion that only half applied

A merchandising team used a bulk edit tool to mark eighty products as Featured ahead of a sale, running the update as a direct database query against wp_postmeta to finish quickly before a deadline. The featured badge only appeared on some of them on the storefront.

The report showed the products where the featured field was true but the featured term was still missing. The script flagged all of them, and after the fix the promotion page and the featured products widget matched what the merchandising team actually set.

What good looks like

After a repair pass, what an admin sees on a product's edit screen is exactly what a shopper sees on the storefront. Keep the script around and run it once after any import, bulk edit, or migration, since any process that writes product fields outside WooCommerce's own save routine can reopen the same gap.

FAQ

Why does a product show up in search when it should be hidden, or disappear when it should not be?

WooCommerce decides what shows where using a hidden taxonomy called product_visibility, not the catalog_visibility field itself. If an import, a bulk edit, or a direct database write changes catalog visibility, featured, or stock status without also updating those terms, the terms and the settings disagree, and the storefront follows the terms.

Is it safe to fix product_visibility terms with a script?

Yes, when the script only recomputes the terms from the product's own catalog_visibility, featured, and stock_status fields and only touches products where the computed terms differ from what is currently assigned. Start in dry run mode to review the list before it writes.

How often should the visibility repair job run?

Once after any bulk import, bulk edit, or migration, and optionally on a daily schedule as a safety net. It only touches products whose terms disagree with their own settings, so running it often is safe and cheap.

Related field notes

Citations

On the problem:

  1. WooCommerce developer docs: the product_visibility taxonomy and how catalog visibility, featured, and stock status are translated into its terms. developer.woocommerce.com/docs/category/products
  2. WooCommerce docs: Status, Tools page, including the term counts and transient clearing actions used to recover from taxonomy drift. woocommerce.com/document/woocommerce-status-report
  3. WooCommerce community support: products showing or hiding incorrectly after an import or bulk edit, traced to stale product_visibility terms. wordpress.org/support/plugin/woocommerce

On the solution:

  1. WooCommerce REST API: retrieve and update a product, including catalog_visibility, featured, and stock_status fields. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce docs: catalog visibility options and what Shop and search, Shop only, Search only, and Hidden each mean for a product. woocommerce.com/document/hide-a-product-from-catalog-search
  3. WordPress developer reference: registering and querying custom taxonomies, the mechanism product_visibility itself is built on. developer.wordpress.org/reference/functions/wp_set_object_terms

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 fix your catalog visibility?

If this helped you make sense of a product that hid or showed wrongly, 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 field notes