Diagnostic Indexing
Magento anchor category shows products from disabled subcategories
You disabled a subcategory. It is gone from the menu, gone from the sitemap, nobody can click into it. But its products are still sitting in the storefront listing of the parent anchor category, as if nothing changed. Nobody reassigned anything, and a reindex does not clear it. Here is why Magento's anchor aggregation was never built to check whether a child category is active, and a small script that finds every leaked SKU so you can review it.
Magento's anchor category indexer, catalog_category_product, builds the product list for an anchor category by walking the full category subtree using the category path, and it never checks each child's is_active flag while doing it. is_anchor only controls whether a category aggregates its children's products at all, it was never designed to also respect whether those children are enabled. So a disabled subcategory's assigned products still get pulled into the parent anchor's indexed listing, and reindexing reproduces the same leak every time because this is core aggregation logic, not a stale index. Run a small Python or Node.js script that walks the anchor's subtree over GET /rest/V1/categories, finds every disabled descendant, reads its assigned SKUs, and cross-checks each one against GET /rest/V1/products to confirm it is enabled and visible, meaning it will leak into the anchor's frontend listing. Full code, tests, and sources are below.
The problem in plain words
An anchor category in Magento is one flagged is_anchor, meaning it shows not just its own directly assigned products but also everything assigned to every category underneath it. That is what lets a top level category like Men or Sale display the full combined catalog of all its subcategories in one listing.
To make that fast, Magento does not walk the live category tree on every page load. The catalog_category_product indexer precomputes it, using the category path stored on each category row, and aggregates every descendant's assigned products into the anchor's own indexed product list. The problem is what that aggregation checks, and what it does not. It checks the category path to decide which categories are inside the anchor's subtree. It does not check whether any of those descendant categories have is_active set to 0. A subcategory being disabled makes it disappear from navigation and from its own direct URL, but it does not remove that category from the path tree the anchor indexer walks, and it does not touch the product to category assignment rows either. So the products stay indexed under the anchor, in plain sight on the storefront, even though the category that supposedly organizes them is switched off.
Why it happens
This comes down to what is_anchor was actually designed to do, and what people assume it also does:
is_anchoris a flag that tells Magento's category product indexer,Magento\Catalog\Model\Indexer\Category\Product, to aggregate products from the entire subtree beneath a category, using anchor tree queries inMagento\Catalog\Model\ResourceModel\Category. It was built purely to control aggregation scope, the question it answers is which categories are inside the tree, never whether those categories are switched on.is_activelives on the category entity itself and controls whether that specific category can be browsed directly and whether it appears in navigation and the sitemap. It was never wired into the anchor aggregation query, so setting it to 0 on a child has zero effect on whether the parent anchor still picks up that child's products.- Because the aggregation is index driven rather than a live tree walk at request time, every reindex, whether triggered on save or on the update by schedule cron, rebuilds the same list the same way. There is no version of a normal reindex that adds an active check that was never part of the query in the first place.
- This is a long standing, acknowledged behavior in Magento's core indexing logic, not a corrupted table or a one off bug. It is tracked across multiple Magento versions, for example magento/magento2 issue #9002 and issue #30300 describing anchor categories showing hidden subcategory products, and issue #33398 describing the same is_anchor and is_active mismatch at store view level.
The result is confusing because everything else about the disabled subcategory behaves correctly. Its own URL 404s or redirects, it is gone from the menu, admin users see it greyed out in the category tree. The only place the disabling did not take effect is the one place a merchandiser is least likely to look: the parent anchor's product grid. See the citations at the end for the exact reports.
is_anchor answers "does this category aggregate its subtree." is_active answers "is this specific category reachable." Magento's indexer only ever asks the first question. Detecting the leak means asking the second question yourself, for every descendant of every anchor, and then checking whether that descendant's own assigned products are still enabled and visible enough to actually render on the storefront. GET /rest/V1/categories gives you the tree and both flags, and GET /rest/V1/categories/{id}/products gives you the direct assignments to check.
The fix, as a flow
There is no single REST write that changes how the core indexer aggregates the tree, that logic lives in PHP resource models and only ships in a Magento core patch or a custom plugin. So the script's job is to walk every anchor category's subtree, find the disabled descendants, pull their assigned SKUs, confirm which of those SKUs are enabled and visible enough to leak onto the storefront, and report each leaked triple for merchant review, only unassigning a product from the disabled category if you explicitly opt in after confirming it by hand.
Build it step by step
Get an admin bearer token
Call POST /rest/V1/integration/admin/token with your admin username and password, or use a preconfigured integration token. Either way you end up with a bearer token you send as Authorization: Bearer <token> on every call. Keep the token and the store URL in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export ROOT_CATEGORY_ID="2"
export DRY_RUN="true" # start safe, change to false to allow unassigning leaked SKUs
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export ROOT_CATEGORY_ID="2"
export DRY_RUN="true" // start safe, change to false to allow unassigning leaked SKUs
Talk to the Magento REST API
Every call goes to {MAGENTO_URL}/rest/V1 with your token in the Authorization header. A small helper sends the request and raises on a non success status, and we reuse it for reading the category tree, reading products, and the optional unassign write.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Fetch the anchor category tree and find is_anchor
GET /rest/V1/categories?rootCategoryId={id} returns the category tree with nested children_data. Each category carries a custom_attributes array, and the attribute with code is_anchor tells us whether it aggregates its subtree at all. We only need to inspect the subtrees of categories where that value is "1".
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_category_tree(root_category_id):
return api_get("/categories", {"rootCategoryId": root_category_id})
def is_anchor_category(category):
return str(custom_attr(category.get("custom_attributes"), "is_anchor", "0")) == "1"
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function fetchCategoryTree(rootCategoryId) {
return apiGet("/categories", { rootCategoryId });
}
function isAnchorCategory(category) {
return String(customAttr(category.custom_attributes, "is_anchor", "0")) === "1";
}
Walk the subtree and read each child's assigned SKUs
For every anchor category, walk children_data recursively. For each descendant where is_active is false, call GET /rest/V1/categories/{childId}/products, which returns CategoryProductLinkInterface[] with each assigned sku and position. Those are the SKUs at risk of leaking into the anchor.
def is_active_category(category):
return str(custom_attr(category.get("custom_attributes"), "is_active", "1")) == "1"
def category_products(category_id):
return api_get(f"/categories/{category_id}/products")
def walk_disabled_descendants(node):
"""Yield every descendant category dict where is_active is false."""
for child in node.get("children_data") or []:
if not is_active_category(child):
yield child
yield from walk_disabled_descendants(child)
function isActiveCategory(category) {
return String(customAttr(category.custom_attributes, "is_active", "1")) === "1";
}
async function categoryProducts(categoryId) {
return apiGet(`/categories/${categoryId}/products`);
}
function* walkDisabledDescendants(node) {
for (const child of node.children_data || []) {
if (!isActiveCategory(child)) yield child;
yield* walkDisabledDescendants(child);
}
}
Confirm each SKU is enabled and visible enough to actually leak
A disabled subcategory's assigned products only cause visible harm if they are themselves enabled and visible. Call GET /rest/V1/products filtered by SKU with conditionType=in, batching every leaked SKU into one search, and read back status and visibility. Status 1 is enabled, and any visibility other than 1, Not Visible Individually, means the product can render in a category listing.
def fetch_product_index(skus):
if not skus:
return {}
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": ",".join(sorted(set(skus))),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": len(set(skus)),
}
result = api_get("/products", params)
return {
item["sku"]: {"status": int(item.get("status", 0)), "visibility": int(item.get("visibility", 0))}
for item in result.get("items", [])
}
async function fetchProductIndex(skus) {
const unique = [...new Set(skus)];
if (unique.length === 0) return {};
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": unique.sort().join(","),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": unique.length,
};
const result = await apiGet("/products", params);
const index = {};
for (const item of result.items || []) {
index[item.sku] = { status: Number(item.status || 0), visibility: Number(item.visibility || 0) };
}
return index;
}
Decide, with one pure function
Keep the decision in its own function so it needs no network and is easy to test. Given the anchor's subtree, the product index, and each disabled category's assignments, walk the tree once, and for every disabled descendant emit a leak record for each of its SKUs that is enabled and visible, deduped by SKU and anchor id. Everything else is left alone by design, the leak is only interesting when it is actually visible on the storefront.
def find_leaked_anchor_products(category_tree, product_index, category_product_assignments):
"""category_tree: {id, isActive, isAnchor, children: [...]} (already fetched, plain data).
product_index: {sku: {status, visibility}}.
category_product_assignments: {category_id: [{sku}, ...]}.
Returns a list of {anchorCategoryId, disabledCategoryId, sku}, deduped by sku+anchorCategoryId.
"""
leaks = []
seen = set()
def walk(node, nearest_anchor_id):
anchor_id = node["id"] if node.get("isAnchor") else nearest_anchor_id
if not node.get("isActive", True) and anchor_id is not None:
for assignment in category_product_assignments.get(node["id"], []):
sku = assignment["sku"]
info = product_index.get(sku)
if not info:
continue
if info.get("status") != 1 or info.get("visibility") == 1:
continue
key = (sku, anchor_id)
if key in seen:
continue
seen.add(key)
leaks.append({"anchorCategoryId": anchor_id, "disabledCategoryId": node["id"], "sku": sku})
for child in node.get("children") or []:
walk(child, anchor_id)
walk(category_tree, category_tree["id"] if category_tree.get("isAnchor") else None)
return leaks
export function findLeakedAnchorProducts(categoryTree, productIndex, categoryProductAssignments) {
const leaks = [];
const seen = new Set();
function walk(node, nearestAnchorId) {
const anchorId = node.isAnchor ? node.id : nearestAnchorId;
if (node.isActive === false && anchorId != null) {
const assignments = categoryProductAssignments.get(node.id) || [];
for (const assignment of assignments) {
const sku = assignment.sku;
const info = productIndex.get(sku);
if (!info) continue;
if (info.status !== 1 || info.visibility === 1) continue;
const key = `${sku}::${anchorId}`;
if (seen.has(key)) continue;
seen.add(key);
leaks.push({ anchorCategoryId: anchorId, disabledCategoryId: node.id, sku });
}
}
for (const child of node.children || []) walk(child, anchorId);
}
walk(categoryTree, categoryTree.isAnchor ? categoryTree.id : null);
return leaks;
}
Report, and optionally unassign the confirmed SKUs
The default and safest path is a DRY_RUN report of every leaked anchor category id, disabled category id, and SKU. There is no core behavior to safely change here, is_anchor and is_active semantics stay exactly as Magento defines them. If you set DRY_RUN=false after a human has reviewed the report and decided a leaked SKU should never sell from that disabled branch, the script can call PUT /rest/V1/categories/{disabledCategoryId} with a productLinks array that omits the leaked SKUs, which unassigns them from that specific category without touching the anchor or the disabled flag at all.
Leave DRY_RUN=true until you have read the report and a human has confirmed each leaked SKU should not be sold from the disabled category. Unassigning a product from a category is a real content change, and it does not fix the underlying aggregation gap, it only removes the specific association the anchor indexer was picking up.
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 only ever reads the category tree and products unless you explicitly opt into unassigning confirmed leaked SKUs.
"""Flag Magento anchor categories that show products from a disabled
subcategory, because the catalog_category_product indexer aggregates the
full category subtree by path and never checks is_active on a child.
Report only by default.
is_anchor only controls whether a category aggregates its subtree's
products at all. It was never wired to also respect a child category's
is_active flag, so disabling a subcategory does not remove its products
from the parent anchor's indexed listing, and reindexing reproduces the
same leak every time. This script cannot change that core aggregation
logic over REST, so it detects and reports the exact leaked SKUs, and
only if you opt in with DRY_RUN=false does it unassign a confirmed SKU
from the disabled category.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("anchor_leak_check")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
ROOT_CATEGORY_ID = os.environ.get("ROOT_CATEGORY_ID", "2")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, payload):
r = requests.put(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, json=payload, timeout=30)
r.raise_for_status()
return r.json()
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_category_tree(root_category_id):
return api_get("/categories", {"rootCategoryId": root_category_id})
def category_products(category_id):
return api_get(f"/categories/{category_id}/products")
def fetch_product_index(skus):
unique = sorted(set(skus))
if not unique:
return {}
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": ",".join(unique),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": len(unique),
}
result = api_get("/products", params)
return {
item["sku"]: {"status": int(item.get("status", 0)), "visibility": int(item.get("visibility", 0))}
for item in result.get("items", [])
}
def to_plain_tree(raw_category):
"""Convert a raw Magento category API node into the plain shape the pure
function expects: {id, isActive, isAnchor, children: [...]}."""
attrs = raw_category.get("custom_attributes")
return {
"id": raw_category["id"],
"isActive": str(custom_attr(attrs, "is_active", "1")) == "1",
"isAnchor": str(custom_attr(attrs, "is_anchor", "0")) == "1",
"children": [to_plain_tree(child) for child in raw_category.get("children_data") or []],
}
def collect_category_ids(node):
ids = [node["id"]]
for child in node.get("children") or []:
ids.extend(collect_category_ids(child))
return ids
def find_leaked_anchor_products(category_tree, product_index, category_product_assignments):
leaks = []
seen = set()
def walk(node, nearest_anchor_id):
anchor_id = node["id"] if node.get("isAnchor") else nearest_anchor_id
if not node.get("isActive", True) and anchor_id is not None:
for assignment in category_product_assignments.get(node["id"], []):
sku = assignment["sku"]
info = product_index.get(sku)
if not info:
continue
if info.get("status") != 1 or info.get("visibility") == 1:
continue
key = (sku, anchor_id)
if key in seen:
continue
seen.add(key)
leaks.append({"anchorCategoryId": anchor_id, "disabledCategoryId": node["id"], "sku": sku})
for child in node.get("children") or []:
walk(child, anchor_id)
walk(category_tree, category_tree["id"] if category_tree.get("isAnchor") else None)
return leaks
def unassign_sku_from_category(category_id, sku):
links = category_products(category_id)
remaining = [link for link in links if link.get("sku") != sku]
api_put(f"/categories/{category_id}", {"category": {"id": category_id, "productLinks": remaining}})
def run():
raw_tree = fetch_category_tree(ROOT_CATEGORY_ID)
tree = to_plain_tree(raw_tree)
assignments = {}
all_skus = []
for category_id in collect_category_ids(tree):
links = category_products(category_id)
assignments[category_id] = links
all_skus.extend(link["sku"] for link in links)
product_index = fetch_product_index(all_skus)
leaks = find_leaked_anchor_products(tree, product_index, assignments)
for leak in leaks:
log.warning(
"Leak: anchor=%s disabled_category=%s sku=%s",
leak["anchorCategoryId"], leak["disabledCategoryId"], leak["sku"],
)
if not DRY_RUN:
unassign_sku_from_category(leak["disabledCategoryId"], leak["sku"])
log.info("Unassigned sku=%s from disabled category=%s", leak["sku"], leak["disabledCategoryId"])
log.info("Done. %d leaked product(s) %s.", len(leaks), "to review" if DRY_RUN else "unassigned")
if __name__ == "__main__":
run()
/**
* Flag Magento anchor categories that show products from a disabled
* subcategory, because the catalog_category_product indexer aggregates the
* full category subtree by path and never checks is_active on a child.
* Report only by default.
*
* Guide: https://www.allanninal.dev/magento/anchor-category-leaks-disabled-subcategory-products/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "dummy-token";
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const ROOT_CATEGORY_ID = process.env.ROOT_CATEGORY_ID || "2";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function findLeakedAnchorProducts(categoryTree, productIndex, categoryProductAssignments) {
const leaks = [];
const seen = new Set();
function walk(node, nearestAnchorId) {
const anchorId = node.isAnchor ? node.id : nearestAnchorId;
if (node.isActive === false && anchorId != null) {
const assignments = categoryProductAssignments.get(node.id) || [];
for (const assignment of assignments) {
const sku = assignment.sku;
const info = productIndex.get(sku);
if (!info) continue;
if (info.status !== 1 || info.visibility === 1) continue;
const key = `${sku}::${anchorId}`;
if (seen.has(key)) continue;
seen.add(key);
leaks.push({ anchorCategoryId: anchorId, disabledCategoryId: node.id, sku });
}
}
for (const child of node.children || []) walk(child, anchorId);
}
walk(categoryTree, categoryTree.isAnchor ? categoryTree.id : null);
return leaks;
}
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function apiPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function fetchCategoryTree(rootCategoryId) {
return apiGet("/categories", { rootCategoryId });
}
async function categoryProducts(categoryId) {
return apiGet(`/categories/${categoryId}/products`);
}
async function fetchProductIndex(skus) {
const unique = [...new Set(skus)];
if (unique.length === 0) return {};
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "sku",
"searchCriteria[filterGroups][0][filters][0][value]": unique.sort().join(","),
"searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
"searchCriteria[pageSize]": unique.length,
};
const result = await apiGet("/products", params);
const index = {};
for (const item of result.items || []) {
index[item.sku] = { status: Number(item.status || 0), visibility: Number(item.visibility || 0) };
}
return index;
}
function toPlainTree(rawCategory) {
const attrs = rawCategory.custom_attributes;
return {
id: rawCategory.id,
isActive: String(customAttr(attrs, "is_active", "1")) === "1",
isAnchor: String(customAttr(attrs, "is_anchor", "0")) === "1",
children: (rawCategory.children_data || []).map(toPlainTree),
};
}
function collectCategoryIds(node) {
const ids = [node.id];
for (const child of node.children || []) ids.push(...collectCategoryIds(child));
return ids;
}
async function unassignSkuFromCategory(categoryId, sku) {
const links = await categoryProducts(categoryId);
const remaining = links.filter((link) => link.sku !== sku);
await apiPut(`/categories/${categoryId}`, { category: { id: categoryId, productLinks: remaining } });
}
export async function run() {
const rawTree = await fetchCategoryTree(ROOT_CATEGORY_ID);
const tree = toPlainTree(rawTree);
const assignments = new Map();
const allSkus = [];
for (const categoryId of collectCategoryIds(tree)) {
const links = await categoryProducts(categoryId);
assignments.set(categoryId, links);
allSkus.push(...links.map((link) => link.sku));
}
const rawIndex = await fetchProductIndex(allSkus);
const productIndex = new Map(Object.entries(rawIndex));
const leaks = findLeakedAnchorProducts(tree, productIndex, assignments);
for (const leak of leaks) {
console.warn(`Leak: anchor=${leak.anchorCategoryId} disabled_category=${leak.disabledCategoryId} sku=${leak.sku}`);
if (!DRY_RUN) {
await unassignSkuFromCategory(leak.disabledCategoryId, leak.sku);
console.log(`Unassigned sku=${leak.sku} from disabled category=${leak.disabledCategoryId}`);
}
}
console.log(`Done. ${leaks.length} leaked product(s) ${DRY_RUN ? "to review" : "unassigned"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides exactly which SKUs get reported as leaking onto a live storefront listing. Because find_leaked_anchor_products is pure, the test needs no network and no Magento store. It just feeds in a plain tree, a plain product index, and plain assignment lists, and checks the answer.
from anchor_leak_check import find_leaked_anchor_products
def build_tree():
return {
"id": 10,
"isActive": True,
"isAnchor": True,
"children": [
{
"id": 11,
"isActive": False,
"isAnchor": False,
"children": [],
},
{
"id": 12,
"isActive": True,
"isAnchor": False,
"children": [],
},
],
}
PRODUCT_INDEX = {
"SKU-LEAK": {"status": 1, "visibility": 4},
"SKU-DISABLED-PRODUCT": {"status": 2, "visibility": 4},
"SKU-HIDDEN": {"status": 1, "visibility": 1},
}
def test_leaks_enabled_visible_sku_from_disabled_child():
assignments = {11: [{"sku": "SKU-LEAK"}]}
leaks = find_leaked_anchor_products(build_tree(), PRODUCT_INDEX, assignments)
assert leaks == [{"anchorCategoryId": 10, "disabledCategoryId": 11, "sku": "SKU-LEAK"}]
def test_skips_products_from_active_child():
assignments = {12: [{"sku": "SKU-LEAK"}]}
leaks = find_leaked_anchor_products(build_tree(), PRODUCT_INDEX, assignments)
assert leaks == []
def test_skips_disabled_product_even_from_disabled_child():
assignments = {11: [{"sku": "SKU-DISABLED-PRODUCT"}]}
leaks = find_leaked_anchor_products(build_tree(), PRODUCT_INDEX, assignments)
assert leaks == []
def test_skips_not_visible_individually_product():
assignments = {11: [{"sku": "SKU-HIDDEN"}]}
leaks = find_leaked_anchor_products(build_tree(), PRODUCT_INDEX, assignments)
assert leaks == []
def test_skips_sku_missing_from_product_index():
assignments = {11: [{"sku": "SKU-UNKNOWN"}]}
leaks = find_leaked_anchor_products(build_tree(), PRODUCT_INDEX, assignments)
assert leaks == []
def test_dedupes_same_sku_and_anchor():
assignments = {11: [{"sku": "SKU-LEAK"}, {"sku": "SKU-LEAK"}]}
leaks = find_leaked_anchor_products(build_tree(), PRODUCT_INDEX, assignments)
assert len(leaks) == 1
def test_no_leak_when_root_is_not_anchor():
tree = build_tree()
tree["isAnchor"] = False
assignments = {11: [{"sku": "SKU-LEAK"}]}
leaks = find_leaked_anchor_products(tree, PRODUCT_INDEX, assignments)
assert leaks == []
def test_nested_disabled_grandchild_attributes_to_nearest_anchor():
tree = {
"id": 1,
"isActive": True,
"isAnchor": True,
"children": [
{
"id": 2,
"isActive": True,
"isAnchor": False,
"children": [
{"id": 3, "isActive": False, "isAnchor": False, "children": []}
],
}
],
}
assignments = {3: [{"sku": "SKU-LEAK"}]}
leaks = find_leaked_anchor_products(tree, PRODUCT_INDEX, assignments)
assert leaks == [{"anchorCategoryId": 1, "disabledCategoryId": 3, "sku": "SKU-LEAK"}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findLeakedAnchorProducts } from "./anchor-leak-check.js";
function buildTree() {
return {
id: 10,
isActive: true,
isAnchor: true,
children: [
{ id: 11, isActive: false, isAnchor: false, children: [] },
{ id: 12, isActive: true, isAnchor: false, children: [] },
],
};
}
const productIndex = new Map([
["SKU-LEAK", { status: 1, visibility: 4 }],
["SKU-DISABLED-PRODUCT", { status: 2, visibility: 4 }],
["SKU-HIDDEN", { status: 1, visibility: 1 }],
]);
test("leaks an enabled, visible sku from a disabled child", () => {
const assignments = new Map([[11, [{ sku: "SKU-LEAK" }]]]);
const leaks = findLeakedAnchorProducts(buildTree(), productIndex, assignments);
assert.deepEqual(leaks, [{ anchorCategoryId: 10, disabledCategoryId: 11, sku: "SKU-LEAK" }]);
});
test("skips products from an active child", () => {
const assignments = new Map([[12, [{ sku: "SKU-LEAK" }]]]);
const leaks = findLeakedAnchorProducts(buildTree(), productIndex, assignments);
assert.deepEqual(leaks, []);
});
test("skips a disabled product even from a disabled child", () => {
const assignments = new Map([[11, [{ sku: "SKU-DISABLED-PRODUCT" }]]]);
const leaks = findLeakedAnchorProducts(buildTree(), productIndex, assignments);
assert.deepEqual(leaks, []);
});
test("skips a not visible individually product", () => {
const assignments = new Map([[11, [{ sku: "SKU-HIDDEN" }]]]);
const leaks = findLeakedAnchorProducts(buildTree(), productIndex, assignments);
assert.deepEqual(leaks, []);
});
test("skips a sku missing from the product index", () => {
const assignments = new Map([[11, [{ sku: "SKU-UNKNOWN" }]]]);
const leaks = findLeakedAnchorProducts(buildTree(), productIndex, assignments);
assert.deepEqual(leaks, []);
});
test("dedupes the same sku and anchor", () => {
const assignments = new Map([[11, [{ sku: "SKU-LEAK" }, { sku: "SKU-LEAK" }]]]);
const leaks = findLeakedAnchorProducts(buildTree(), productIndex, assignments);
assert.equal(leaks.length, 1);
});
test("no leak when the root is not an anchor", () => {
const tree = buildTree();
tree.isAnchor = false;
const assignments = new Map([[11, [{ sku: "SKU-LEAK" }]]]);
const leaks = findLeakedAnchorProducts(tree, productIndex, assignments);
assert.deepEqual(leaks, []);
});
test("a nested disabled grandchild attributes to the nearest anchor", () => {
const tree = {
id: 1,
isActive: true,
isAnchor: true,
children: [
{
id: 2,
isActive: true,
isAnchor: false,
children: [{ id: 3, isActive: false, isAnchor: false, children: [] }],
},
],
};
const assignments = new Map([[3, [{ sku: "SKU-LEAK" }]]]);
const leaks = findLeakedAnchorProducts(tree, productIndex, assignments);
assert.deepEqual(leaks, [{ anchorCategoryId: 1, disabledCategoryId: 3, sku: "SKU-LEAK" }]);
});
Case studies
A discontinued subcategory kept selling on the sale page
A fashion retailer disabled a subcategory for a discontinued shoe line, expecting the products to disappear along with it. The anchor Sale category above it kept showing every one of those shoes, because catalog_category_product never checked whether the child category was active while aggregating the tree. Customers kept ordering a line the merchandising team believed was fully retired, and a full reindex the next night reproduced the exact same listing.
Running the detection script against the anchor's subtree surfaced the disabled category id and the exact leaked SKUs still enabled and visible. The team reviewed the list, confirmed the shoes should never sell again, and unassigned them from the disabled category directly, which finally cleared the anchor listing.
A region locked subcategory leaked into the main storefront
A multi region store disabled a subcategory meant only for a market they no longer served, assuming disabling it would also pull its products out of the top level anchor category most customers browse. The products stayed listed there for weeks because is_active was never part of the anchor aggregation query, and nobody thought to check the anchor's listing directly since the subcategory itself was correctly gone.
A scheduled run of the detection script flagged every leaked SKU with its anchor and disabled category id, which let the team confirm which products were genuinely meant to stop selling everywhere versus which were simply misfiled, before touching any assignment.
After this runs on a schedule, disabling a subcategory no longer means guessing whether its products quietly survived in a parent anchor listing. The script tells you the exact anchor, the exact disabled category, and the exact SKU, so a human can decide once whether that product should stop selling from that branch entirely, instead of discovering the leak from a customer order that should never have been possible.
FAQ
Why does an anchor category show products from a subcategory I disabled?
Magento's anchor category indexer builds the product list for an anchor by walking the entire category subtree using the category path. It never checks whether a child category has is_active set to 0. is_anchor only controls whether child products get aggregated at all, it was not designed to also respect the child's active flag, so a disabled subcategory's assigned products still get pulled into the parent anchor's indexed listing.
Will reindexing fix an anchor category leaking disabled subcategory products?
No. This is not a stale index. Running bin/magento indexer:reindex catalog_category_product rebuilds the same aggregation logic, and that logic still does not filter out inactive branches of the tree, so the same disabled subcategory's products reappear in the anchor's listing right after the reindex finishes. It is a query logic gap in the category product indexer, not a symptom of a stale table.
How do I stop specific leaked products from showing in the anchor category?
There is no single REST call that changes the underlying anchor aggregation behavior. The safe remediation is to review the leaked SKU, anchor category, and disabled subcategory triples a detection script reports, then explicitly unassign the confirmed products from the disabled category through PUT /rest/V1/categories/{id} with the productLinks array, once you have confirmed the merchant does not want those items sold from that branch at all.
Related field notes
Citations
On the problem:
- magento/magento2: Anchor categories are showing products of disabled subcategories. github.com/magento/magento2/issues/9002
- magento/magento2: Products of hidden sub-category are shown in parent anchor category. github.com/magento/magento2/issues/30300
- magento/magento2: is_anchor is not working on store level if the categories are disabled on global level. github.com/magento/magento2/issues/33398
On the solution:
- Adobe Commerce PHP Extensions: Indexing overview and the category product indexer. developer.adobe.com commerce-php indexing
- Adobe Commerce: Index management. experienceleague.adobe.com index-management
- Adobe Commerce: Category product assignments. experienceleague.adobe.com categories-product-assignments
Stuck on a tricky one?
If you have a problem in Magento indexing, catalog structure, MSI stock, or cron 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 explain your leaked anchor listing?
If this saved you from chasing a phantom reassignment or a mysterious order for a discontinued item, 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