Skip to content

Diagnostic Indexing

Magento category product count wrong or zero on large catalogs

A category page in the admin, or the product_count attribute over the REST API, says a category has far fewer products than it actually does, sometimes zero, even though every product is still correctly assigned to it. Nobody removed anything. The catalog is fine. What broke is the index table that Magento reads the count from, usually after a reindex that got interrupted partway through. Here is why it happens, why anchor categories are hit hardest, and a small script that finds every category where the reported count disagrees with the real assignments.

Python and Node.js Magento REST API Detect and report (dry run by default)
Items on display in a store
Photo by Eduardo Soares on Unsplash
The short answer

Magento's category product count is not computed live. It is read from a precomputed index table, catalog_category_product_index, that the catalog_category_product indexer rebuilds by swapping in a temp table rather than updating rows in place. If that swap gets interrupted, the index table is left stale or zeroed while the real product to category assignments on disk are untouched. Run a small Python or Node.js script that pulls the reported product_count from GET /rest/V1/categories/{id} and the real total from GET /rest/V1/products filtered by category_id, then flags any category where the two disagree, with anchor categories called out separately since they are the usual victim. Full code, tests, and sources are below.

The problem in plain words

Magento does not count a category's products by querying catalog_category_product every time a page loads. That table holds the raw assignment between a product and a category, and it can be large. Instead Magento precomputes the number into an index, catalog_category_product_index, plus a per store view copy, and reads from that index whenever a category page, a grid, or the REST API asks for a product count.

Building that index is not a small update. The catalog_category_product indexer rebuilds it by writing into a temp table (or a replica table on newer versions) and then swapping it in for the live table, rather than patching rows one by one. That swap is fast when it works. But it is also the single point where the whole rebuild can fail: a full temp table, a memory limit, a bulk product limit truncation like the one reported in magento/magento2 issue #8018, or a second category save that overlaps the same reindex. When the swap is interrupted, it can partially commit or abort outright, and whatever was in the index table at that moment is what gets reported until the next successful reindex. Meanwhile the actual assignment rows in catalog_category_product never moved.

Product saved assigned to category Indexer rebuilds writes to temp table swap interrupted Index stale or zeroed product_count wrong or 0 catalog_category_product assignment table on disk: still correct
The product to category link on disk never changes. Only the precomputed index the count is read from can go stale, and anchor categories are hit hardest because they aggregate every subcategory in the same pass.

Why it happens

The index is a snapshot, and the way Magento refreshes that snapshot creates a specific failure window:

The result is confusing precisely because it looks like data loss. Merchandisers see a category with fifty products and a product_count of three, or zero, and assume products were unassigned or disabled. Nothing was. The only thing wrong is the cached count, and a full reindex fixes it, but nobody runs that until something tips them off. See the citations at the end for the exact reports.

The key insight

The reported product_count comes from the index. The real membership of a product in a category comes from the live assignment table. Those are two different sources, and the REST API happens to expose both: GET /rest/V1/categories/{id} for the cached count, and GET /rest/V1/products filtered by category_id for the live total. Diffing the two over the API, with no filesystem access, is exactly how you catch this without waiting for someone to notice the storefront looks wrong.

The fix, as a flow

We cannot trigger a real reindex from REST, that is a CLI or cron action. So the script's job is to detect and report every category where the two counts disagree, flag the severity, and, only if you opt in, nudge Magento's own invalidation mechanism so the next scheduled cron reindex actually runs.

For each category list categories via API GET reported count categories/{id} product_count GET actual count products search total_count Counts match? yes, skip no, drift or zeroed Flag in report optional: nudge invalidate
The script only ever reads and reports. Triggering the real fix, indexer:reindex catalog_category_product, stays a CLI or cron action outside its reach.

Build it step by step

1

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.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true"   # start safe, change to false to allow the invalidate nudge
setup (shell)
// 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 DRY_RUN="true"   // start safe, change to false to allow the invalidate nudge
2

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 categories, reading products, and the optional invalidate nudge.

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

Read the reported count and whether the category is an anchor

GET /rest/V1/categories/{id} returns the category with a custom_attributes array. The attribute with code product_count holds the cached count Magento would show in the admin and storefront, and the attribute with code is_anchor tells us whether this category rolls up subcategory products, which is where a partial reindex is most likely to land.

step3.py
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 reported_category_count(category_id):
    cat = api_get(f"/categories/{category_id}")
    attrs = cat.get("custom_attributes")
    reported = int(custom_attr(attrs, "product_count", 0) or 0)
    is_anchor = str(custom_attr(attrs, "is_anchor", "0")) == "1"
    return reported, is_anchor
step3.js
function customAttr(attrs, code, fallback = null) {
  for (const a of attrs || []) {
    if (a.attribute_code === code) return a.value;
  }
  return fallback;
}

async function reportedCategoryCount(categoryId) {
  const cat = await apiGet(`/categories/${categoryId}`);
  const attrs = cat.custom_attributes;
  const reported = Number(customAttr(attrs, "product_count", 0) || 0);
  const isAnchor = String(customAttr(attrs, "is_anchor", "0")) === "1";
  return { reported, isAnchor };
}
4

Read the actual count from the live product assignments

GET /rest/V1/products with a searchCriteria filter on category_id queries the live catalog_category_product assignments, not the index. Setting pageSize=1 means Magento still computes and returns the full total_count, but we do not have to pull every SKU to get it.

step4.py
def actual_category_count(category_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "category_id",
        "searchCriteria[filterGroups][0][filters][0][value]": category_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": 1,
    }
    result = api_get("/products", params)
    return int(result.get("total_count", 0))
step4.js
async function actualCategoryCount(categoryId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "category_id",
    "searchCriteria[filterGroups][0][filters][0][value]": categoryId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": 1,
  };
  const result = await apiGet("/products", params);
  return Number(result.total_count || 0);
}
5

Decide, with one pure function

Keep the decision in its own function so it is easy to read and easy to test with no network calls. The rule treats a reported count of zero with real assignments as the worst case, a full index loss, regardless of tolerance. Anything else that differs by more than the tolerance is drift. isAnchor only changes how the finding is prioritized in the report, never whether it is flagged.

decide.py
def decide_category_count_discrepancy(reported_count, actual_count, is_anchor, tolerance=0):
    delta = actual_count - reported_count
    if actual_count > 0 and reported_count == 0:
        return {"flagged": True, "severity": "zeroed", "delta": delta}
    if abs(delta) > tolerance:
        return {"flagged": True, "severity": "drift", "delta": delta}
    return {"flagged": False, "severity": "none", "delta": delta}
decide.js
export function decideCategoryCountDiscrepancy(reportedCount, actualCount, isAnchor, tolerance = 0) {
  const delta = actualCount - reportedCount;
  if (actualCount > 0 && reportedCount === 0) {
    return { flagged: true, severity: "zeroed", delta };
  }
  if (Math.abs(delta) > tolerance) {
    return { flagged: true, severity: "drift", delta };
  }
  return { flagged: false, severity: "none", delta };
}
6

Report, and optionally nudge the indexer

The default and safest path is a DRY_RUN report of every flagged category id, its severity, and its delta. The real repair, bin/magento indexer:reindex catalog_category_product, needs CLI or cron access this script does not have. If you set MAGENTO_ALLOW_INDEXER_INVALIDATE=true, the script can call PUT /rest/V1/categories/{id} with an unchanged payload, resending the same name, purely to trigger Magento's category save observer and mark the indexer invalid so the next scheduled cron reindex picks it up. That is a soft nudge, not a guaranteed fix, and it is skipped whenever DRY_RUN is true.

Run it safe

Leave DRY_RUN=true until you have read the report and confirmed the flagged categories are real. The invalidate nudge only marks the indexer state dirty, it does not run a reindex itself, so it depends on your cron actually being active on schedule.

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 categories and products unless you explicitly opt into the invalidate nudge.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
category_count_check.py
"""Flag Magento categories whose reported product_count disagrees with the
real product to category assignments, especially anchor categories after a
partial catalog_category_product reindex. Report only by default.

The catalog_category_product indexer rebuilds catalog_category_product_index
(and the per store index) with a temp table swap rather than updating rows in
place. If that swap is interrupted the index table can go stale or zero out
while the live assignment table is untouched. This script cannot trigger a
real reindex over REST, so it detects and reports the gap, and only if you
opt in with MAGENTO_ALLOW_INDEXER_INVALIDATE=true does it resave the category
to nudge Magento's own indexer invalidation for the next scheduled cron run.
"""
import os
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
TOLERANCE = int(os.environ.get("COUNT_TOLERANCE", "0"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ALLOW_INVALIDATE = os.environ.get("MAGENTO_ALLOW_INDEXER_INVALIDATE", "false").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 list_category_ids():
    result = api_get("/categories/list", {
        "searchCriteria[pageSize]": 200,
    })
    return [item["id"] for item in result.get("items", [])]


def reported_category_count(category_id):
    cat = api_get(f"/categories/{category_id}")
    attrs = cat.get("custom_attributes")
    reported = int(custom_attr(attrs, "product_count", 0) or 0)
    is_anchor = str(custom_attr(attrs, "is_anchor", "0")) == "1"
    return reported, is_anchor, cat


def actual_category_count(category_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "category_id",
        "searchCriteria[filterGroups][0][filters][0][value]": category_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": 1,
    }
    result = api_get("/products", params)
    return int(result.get("total_count", 0))


def decide_category_count_discrepancy(reported_count, actual_count, is_anchor, tolerance=0):
    delta = actual_count - reported_count
    if actual_count > 0 and reported_count == 0:
        return {"flagged": True, "severity": "zeroed", "delta": delta}
    if abs(delta) > tolerance:
        return {"flagged": True, "severity": "drift", "delta": delta}
    return {"flagged": False, "severity": "none", "delta": delta}


def nudge_indexer_invalidate(category_id, category):
    name = category.get("name")
    api_put(f"/categories/{category_id}", {"category": {"id": category_id, "name": name}})


def run():
    flagged = 0
    for category_id in list_category_ids():
        reported, is_anchor, category = reported_category_count(category_id)
        actual = actual_category_count(category_id)
        decision = decide_category_count_discrepancy(reported, actual, is_anchor, TOLERANCE)
        if not decision["flagged"]:
            continue
        flagged += 1
        log.warning(
            "Category %s %s: reported=%d actual=%d delta=%+d anchor=%s",
            category_id, decision["severity"], reported, actual, decision["delta"], is_anchor,
        )
        if ALLOW_INVALIDATE and not DRY_RUN:
            nudge_indexer_invalidate(category_id, category)
            log.info("Category %s: resaved to invalidate catalog_category_product indexer", category_id)
    log.info("Done. %d categor%s flagged.", flagged, "y" if flagged == 1 else "ies")


if __name__ == "__main__":
    run()
category-count-check.js
/**
 * Flag Magento categories whose reported product_count disagrees with the
 * real product to category assignments, especially anchor categories after a
 * partial catalog_category_product reindex. Report only by default.
 *
 * Guide: https://www.allanninal.dev/magento/category-product-count-wrong/
 */
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 TOLERANCE = Number(process.env.COUNT_TOLERANCE || 0);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ALLOW_INVALIDATE = (process.env.MAGENTO_ALLOW_INDEXER_INVALIDATE || "false").toLowerCase() === "true";

export function decideCategoryCountDiscrepancy(reportedCount, actualCount, isAnchor, tolerance = 0) {
  const delta = actualCount - reportedCount;
  if (actualCount > 0 && reportedCount === 0) {
    return { flagged: true, severity: "zeroed", delta };
  }
  if (Math.abs(delta) > tolerance) {
    return { flagged: true, severity: "drift", delta };
  }
  return { flagged: false, severity: "none", delta };
}

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 listCategoryIds() {
  const result = await apiGet("/categories/list", { "searchCriteria[pageSize]": 200 });
  return (result.items || []).map((item) => item.id);
}

async function reportedCategoryCount(categoryId) {
  const cat = await apiGet(`/categories/${categoryId}`);
  const attrs = cat.custom_attributes;
  const reported = Number(customAttr(attrs, "product_count", 0) || 0);
  const isAnchor = String(customAttr(attrs, "is_anchor", "0")) === "1";
  return { reported, isAnchor, category: cat };
}

async function actualCategoryCount(categoryId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "category_id",
    "searchCriteria[filterGroups][0][filters][0][value]": categoryId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": 1,
  };
  const result = await apiGet("/products", params);
  return Number(result.total_count || 0);
}

async function nudgeIndexerInvalidate(categoryId, category) {
  await apiPut(`/categories/${categoryId}`, { category: { id: categoryId, name: category.name } });
}

export async function run() {
  let flagged = 0;
  for (const categoryId of await listCategoryIds()) {
    const { reported, isAnchor, category } = await reportedCategoryCount(categoryId);
    const actual = await actualCategoryCount(categoryId);
    const decision = decideCategoryCountDiscrepancy(reported, actual, isAnchor, TOLERANCE);
    if (!decision.flagged) continue;
    flagged++;
    console.warn(
      `Category ${categoryId} ${decision.severity}: reported=${reported} actual=${actual} delta=${decision.delta >= 0 ? "+" : ""}${decision.delta} anchor=${isAnchor}`
    );
    if (ALLOW_INVALIDATE && !DRY_RUN) {
      await nudgeIndexerInvalidate(categoryId, category);
      console.log(`Category ${categoryId}: resaved to invalidate catalog_category_product indexer`);
    }
  }
  console.log(`Done. ${flagged} categor${flagged === 1 ? "y" : "ies"} flagged.`);
}

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 what severity gets reported for a category a merchandiser will look at next. Because decide_category_count_discrepancy is pure, the test needs no network and no Magento store. It just feeds in plain numbers and checks the answer.

test_category_count.py
from category_count_check import decide_category_count_discrepancy


def test_equal_counts_not_flagged():
    result = decide_category_count_discrepancy(42, 42, False)
    assert result == {"flagged": False, "severity": "none", "delta": 0}


def test_off_by_one_is_drift():
    result = decide_category_count_discrepancy(41, 42, False)
    assert result["flagged"] is True
    assert result["severity"] == "drift"
    assert result["delta"] == 1


def test_reported_zero_with_real_assignments_is_zeroed():
    result = decide_category_count_discrepancy(0, 50, True)
    assert result["flagged"] is True
    assert result["severity"] == "zeroed"
    assert result["delta"] == 50


def test_reported_zero_and_actual_zero_not_flagged():
    result = decide_category_count_discrepancy(0, 0, True)
    assert result["flagged"] is False
    assert result["severity"] == "none"


def test_near_miss_within_tolerance_not_flagged():
    result = decide_category_count_discrepancy(100, 102, False, tolerance=5)
    assert result["flagged"] is False


def test_drift_beyond_tolerance_is_flagged():
    result = decide_category_count_discrepancy(100, 108, False, tolerance=5)
    assert result["flagged"] is True
    assert result["severity"] == "drift"


def test_zeroed_ignores_tolerance():
    result = decide_category_count_discrepancy(0, 3, True, tolerance=10)
    assert result["flagged"] is True
    assert result["severity"] == "zeroed"


def test_is_anchor_does_not_change_flag_boundary():
    anchor = decide_category_count_discrepancy(10, 20, True)
    leaf = decide_category_count_discrepancy(10, 20, False)
    assert anchor["flagged"] == leaf["flagged"] == True
    assert anchor["severity"] == leaf["severity"] == "drift"
category-count.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideCategoryCountDiscrepancy } from "./category-count-check.js";

test("equal counts not flagged", () => {
  const result = decideCategoryCountDiscrepancy(42, 42, false);
  assert.deepEqual(result, { flagged: false, severity: "none", delta: 0 });
});

test("off by one is drift", () => {
  const result = decideCategoryCountDiscrepancy(41, 42, false);
  assert.equal(result.flagged, true);
  assert.equal(result.severity, "drift");
  assert.equal(result.delta, 1);
});

test("reported zero with real assignments is zeroed", () => {
  const result = decideCategoryCountDiscrepancy(0, 50, true);
  assert.equal(result.flagged, true);
  assert.equal(result.severity, "zeroed");
  assert.equal(result.delta, 50);
});

test("reported zero and actual zero not flagged", () => {
  const result = decideCategoryCountDiscrepancy(0, 0, true);
  assert.equal(result.flagged, false);
  assert.equal(result.severity, "none");
});

test("near miss within tolerance not flagged", () => {
  const result = decideCategoryCountDiscrepancy(100, 102, false, 5);
  assert.equal(result.flagged, false);
});

test("drift beyond tolerance is flagged", () => {
  const result = decideCategoryCountDiscrepancy(100, 108, false, 5);
  assert.equal(result.flagged, true);
  assert.equal(result.severity, "drift");
});

test("zeroed ignores tolerance", () => {
  const result = decideCategoryCountDiscrepancy(0, 3, true, 10);
  assert.equal(result.flagged, true);
  assert.equal(result.severity, "zeroed");
});

test("isAnchor does not change the flag boundary", () => {
  const anchor = decideCategoryCountDiscrepancy(10, 20, true);
  const leaf = decideCategoryCountDiscrepancy(10, 20, false);
  assert.equal(anchor.flagged, leaf.flagged);
  assert.equal(anchor.severity, leaf.severity);
});

Case studies

Anchor category zeroed

A seasonal sale category showed zero products

A retailer ran a bulk product import overnight that triggered a catalog_category_product reindex on a large anchor category with dozens of subcategories. The reindex hit a limit partway through, the same shape reported in magento/magento2 issue #8018, and the anchor category's product_count dropped to zero the next morning. Merchandisers assumed the import had unassigned every product from the sale category and started manually reassigning them.

Running the diff script against the REST API showed actual_total_count was still the full 340 products, only the reported count had zeroed, matching the pattern described in ACSD-46519. A scheduled indexer:reindex catalog_category_product fixed it in under a minute once the team knew that was the actual problem.

Slow drift

Leaf categories quietly undercounted for weeks

A B2B catalog with frequent small product edits kept triggering partial category reindexes that never fully failed, just left a handful of leaf categories a few products short of their true count. Nobody noticed because the storefront listing itself still rendered the right products, only the summary count next to the category name in the admin grid was off.

A weekly run of the script against every category id flagged the drifted ones with their exact delta, which made it obvious the discrepancies were index staleness rather than a data problem, and a routine reindex kept them in sync going forward.

What good looks like

After this runs on a schedule, nobody has to guess whether a low or zero product count means missing inventory or a stale index. The script tells you exactly which categories disagree, by how much, and whether they are anchor categories most likely to have been hit by a partial reindex, so the team can go straight to running the real fix instead of chasing a phantom data loss.

FAQ

Why does a Magento category show the wrong product count?

The category product count is read from a precomputed index table, catalog_category_product_index, that the catalog_category_product indexer rebuilds using a temp table swap. If that swap is interrupted by a full temp table, a memory limit, a bulk product limit, or an overlapping reindex, the swap can partially commit or abort and leave the index table stale or zeroed, even though the real product to category assignments on disk are untouched.

Why is an anchor category more likely to show zero products?

An anchor category's count is aggregated from every subcategory during the same temp table pass, so it does the most work and touches the most rows. A partial failure during that aggregation is far more likely to hit the anchor category's row than a leaf category, which is why anchor categories are the most common victim of a zeroed or undercounted product_count.

Can a script fix the wrong product count automatically?

Not directly over REST. The real fix is bin/magento indexer:reindex catalog_category_product, a CLI or cron action outside the REST API. A script can detect and report the discrepancy safely, and optionally resave the category with an unchanged payload to mark the indexer invalid so the next scheduled reindex picks it up, but that is a soft nudge, not a guaranteed fix.

Related field notes

Citations

On the problem:

  1. Smile-SA/elasticsuite: category product index is wrong after switching to temp tables for indexing. github.com/Smile-SA/elasticsuite/issues/622
  2. magento/magento2: running indexer:reindex catalog_category_product fails due to limit 500. github.com/magento/magento2/issues/8018
  3. Adobe Commerce Knowledge Base: ACSD-46519, product_count in categoryList GraphQL query returns 0 for anchor categories. experienceleague.adobe.com acsd-46519

On the solution:

  1. Adobe Commerce Operations: Indexing overview, indexers, reindex, and cron. experienceleague.adobe.com commerce-operations indexers
  2. Adobe Commerce Web API: Products endpoint and searchCriteria reference. developer.adobe.com searching-with-rest
  3. Adobe Commerce Web API: Categories endpoint reference. developer.adobe.com rest-endpoints

Stuck on a tricky one?

If you have a problem in Magento indexing, cron, MSI stock, or catalog data 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 explain your category count?

If this saved you a wrong assumption about missing products or a wasted afternoon reassigning things that were never unassigned, 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 Magento field notes