Skip to content

Reconciler

Product left without a valid default category after category deletion

Someone tidies up the catalog and deletes a category that is no longer needed. Everything looks fine in the back office category tree. Then a product that used to live under that category starts throwing errors on the storefront, or its default category shows up blank in the admin. The product still has other, perfectly valid categories. But its id_category_default field is still pointing at the category id you just deleted, and that id does not exist anymore. Here is why PrestaShop leaves that dangling reference behind and a small script that finds and repairs every product it happened to.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A bottle on a store shelf
Photo by Charles Gao on Unsplash
The short answer

When a category is deleted in the PrestaShop back office, the deletion handler only reassigns a product's categories when the product would otherwise be left with zero categories at all. It does not check whether the deleted category happened to be that product's default category while the product still has other valid categories left. So the product row keeps its old id_category_default value, now a dangling reference to a category id that no longer exists in ps_category. This is a confirmed core bug (PrestaShop/PrestaShop issue #30219, and related issues #28016 and #9811). Run a Python or Node.js script that builds the set of valid category ids, compares every product's id_category_default against it, and for each affected product picks a replacement default from the product's own remaining categories, falling back to the shop's root category. Start in dry run, then apply. Full code, tests, and citations are below.

The problem in plain words

Every PrestaShop product carries an id_category_default field. It is the one category PrestaShop uses to build the product's canonical link and its breadcrumb on the storefront, even when the product is also filed under several other categories.

Deleting a category is a normal, everyday piece of catalog housekeeping. A season ends, a supplier line gets discontinued, someone reorganizes the tree. PrestaShop's DeleteCategoryHandler is careful about one thing: it will not leave a product with no categories at all, so if deleting a category would empty out a product's category list entirely, it reassigns that product somewhere else. What it does not check is whether the category being deleted was that product's default one. If the product still has other categories left over, the handler considers its job done and moves on, but the product row still points its id_category_default at the category id that just stopped existing.

Category deleted was id_category_default DeleteCategoryHandler product still has other valid categories, so it stops no default check id_category_default unchanged, now dangling Breadcrumb fatal error
The deletion handler only reassigns categories to keep a product from having zero. It never checks whether the deleted category was the product's default, so the pointer is left dangling.

Why it happens

This is a documented core bug in how category deletion interacts with the product's default category field, not a mistake made by whoever clicked delete. A few things make it easy to run into:

This has been reported and reproduced multiple times against current PrestaShop core versions. See the citations at the end for the exact issue threads and docs.

The key insight

You cannot fix this by watching for it as categories get deleted, because the core does not surface an event for "this deletion left a dangling default." The safe pattern is to periodically reconcile the whole catalog: build the set of category ids that are actually still valid, then check every product's id_category_default against that set. Anything not in the set is broken, whether or not you know when it happened.

The fix, as a flow

We do not touch category deletion itself. We add a job that reads every valid category id, reads every product's default category and its full category list, and runs a pure decision function that picks a safe replacement default from the product's own remaining categories. A corrective write is only sent when explicitly allowed.

List categories GET /api/categories List products id_category_default, associations choose_valid_default_category() pure decision, product vs valid set Default dangling? yes no, valid, skip Nothing to do PUT product DRY_RUN gate
The function only reassigns a default when the current one is proven dangling. It prefers the product's own remaining categories, and only falls back to the root category when the product ended up with none left.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with access to categories and products. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export FALLBACK_ROOT_CATEGORY_ID="2"   # shop root/home category id
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export FALLBACK_ROOT_CATEGORY_ID="2"   // shop root/home category id
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the webservice with HTTP Basic auth

Every call uses HTTP Basic auth where the webservice key is the username and the password is blank, and every request asks for JSON with output_format=JSON. A small helper handles both the GET reads and the PUT writes we need later.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()

def api_put(path, resource_key, body):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params={"output_format": "JSON"}, auth=AUTH,
        json={resource_key: body}, timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function apiPut(path, resourceKey, body) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ [resourceKey]: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
  return res.json();
}
3

Build the set of valid category ids, then read every product

Call GET /api/categories?display=full&output_format=JSON, paginating with limit=, and collect every id into a set. That set is your ground truth for what still exists in ps_category. Then call GET /api/products?display=full&output_format=JSON, also paginated, and read each product's id, id_category_default, and its full category list under associations.categories.category[].id.

step3.py
def all_category_ids(page_size=100):
    ids, offset = set(), 0
    while True:
        data = api_get("categories", params={"display": "full", "limit": f"{offset},{page_size}"})
        rows = data.get("categories") or []
        if not rows:
            return ids
        ids.update(int(row["id"]) for row in rows)
        offset += page_size

def all_products(page_size=100):
    offset = 0
    while True:
        data = api_get("products", params={"display": "full", "limit": f"{offset},{page_size}"})
        rows = data.get("products") or []
        if not rows:
            return
        for row in rows:
            yield row
        offset += page_size

def associated_category_ids(product):
    categories = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
    return [int(row["id"]) for row in categories]
step3.js
async function allCategoryIds(pageSize = 100) {
  const ids = new Set();
  let offset = 0;
  while (true) {
    const data = await apiGet("categories", { display: "full", limit: `${offset},${pageSize}` });
    const rows = data.categories || [];
    if (!rows.length) return ids;
    for (const row of rows) ids.add(Number(row.id));
    offset += pageSize;
  }
}

async function* allProducts(pageSize = 100) {
  let offset = 0;
  while (true) {
    const data = await apiGet("products", { display: "full", limit: `${offset},${pageSize}` });
    const rows = data.products || [];
    if (!rows.length) return;
    for (const row of rows) yield row;
    offset += pageSize;
  }
}

function associatedCategoryIds(product) {
  const categories = product.associations?.categories?.category || [];
  return categories.map((row) => Number(row.id));
}
4

Decide, with one pure function

Keep the decision in its own function that takes only plain values, no I/O. It returns nothing to do when the current default is still valid. Otherwise it looks for a replacement among the product's own remaining valid categories, preferring the highest id as a simple deepest-category heuristic, and falls back to the shop's root category only when the product has no valid categories left at all.

decide.py
def choose_valid_default_category(product_id, current_default_id, associated_category_ids,
                                   valid_category_ids, fallback_root_id=2):
    if current_default_id in valid_category_ids:
        return {"id_product": product_id, "action": "none", "new_default": current_default_id}

    candidates = [
        cid for cid in associated_category_ids
        if cid in valid_category_ids and cid != current_default_id
    ]
    if candidates:
        new_default = max(candidates)
    else:
        new_default = fallback_root_id if fallback_root_id in valid_category_ids else None

    return {
        "id_product": product_id,
        "action": "reassign" if new_default else "flag_manual",
        "old_default": current_default_id,
        "new_default": new_default,
    }
decide.js
export function chooseValidDefaultCategory(productId, currentDefaultId, associatedCategoryIds,
                                            validCategoryIds, fallbackRootId = 2) {
  if (validCategoryIds.has(currentDefaultId)) {
    return { id_product: productId, action: "none", new_default: currentDefaultId };
  }

  const candidates = associatedCategoryIds.filter(
    (cid) => validCategoryIds.has(cid) && cid !== currentDefaultId
  );
  const newDefault = candidates.length
    ? Math.max(...candidates)
    : (validCategoryIds.has(fallbackRootId) ? fallbackRootId : null);

  return {
    id_product: productId,
    action: newDefault ? "reassign" : "flag_manual",
    old_default: currentDefaultId,
    new_default: newDefault,
  };
}
5

Apply the repair with a full PUT

PrestaShop's webservice requires the complete resource body on a write, not a partial patch. So to repair a flagged product, fetch its current full body with GET /api/products/{id}?output_format=JSON, set id_category_default to the chosen valid id, make sure that id is present in associations.categories, and send the whole thing back with PUT /api/products/{id}?output_format=JSON.

apply.py
def repair_product_default_category(product_id, new_default_id):
    data = api_get(f"products/{product_id}")
    product = data["product"]
    product["id_category_default"] = new_default_id

    categories = product.setdefault("associations", {}).setdefault("categories", {})
    rows = categories.setdefault("category", [])
    if not any(int(row["id"]) == new_default_id for row in rows):
        rows.append({"id": new_default_id})

    return api_put(f"products/{product_id}", "product", product)
apply.js
async function repairProductDefaultCategory(productId, newDefaultId) {
  const data = await apiGet(`products/${productId}`);
  const product = data.product;
  product.id_category_default = newDefaultId;

  product.associations = product.associations || {};
  product.associations.categories = product.associations.categories || {};
  const rows = (product.associations.categories.category = product.associations.categories.category || []);
  if (!rows.some((row) => Number(row.id) === newDefaultId)) {
    rows.push({ id: newDefaultId });
  }

  return apiPut(`products/${productId}`, "product", product);
}
6

Wire it together with a dry run guard

The loop ties every piece together: build the valid category set, walk every product, run choose_valid_default_category, and log the proposed change. Under DRY_RUN=true it only logs {id_product, old id_category_default, new id_category_default} and never writes. When DRY_RUN=false, it fetches the product, applies the repair, and sends the full PUT. Products where no valid category exists at all, including the fallback, are flagged for manual review instead of guessed at.

Run it safe

Always start with DRY_RUN=true and read the proposed reassignments before writing anything. The webservice write is a full PUT of the product, so a mistake in the body can affect more than the default category field. Review the dry run log, confirm the picks look right, then flip DRY_RUN off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because a product whose default category is already valid is always left untouched.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
fix_default_category.py
"""Repair PrestaShop products left with a dangling id_category_default after a
category deletion.

DeleteCategoryHandler only reassigns a product's categories when the deletion
would leave it with zero categories at all. It never checks whether the
deleted category was that product's default while the product still has
other valid categories, so id_category_default keeps pointing at a category
id that no longer exists in ps_category (PrestaShop/PrestaShop issue #30219,
and related issues #28016 and #9811).

This script builds the set of valid category ids, walks every product, and
runs a pure decision function that picks a replacement default from the
product's own remaining valid categories, falling back to the shop's root
category. It logs every proposed change. A corrective PUT that resends the
full product body is only sent when DRY_RUN=false.

Run on a schedule, or right after cleaning up the category tree. Safe to run
again and again.
"""
import os
import logging
import requests

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

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
FALLBACK_ROOT_CATEGORY_ID = int(os.environ.get("FALLBACK_ROOT_CATEGORY_ID", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def choose_valid_default_category(product_id, current_default_id, associated_category_ids,
                                   valid_category_ids, fallback_root_id=2):
    """Pure decision function, no I/O.

    product_id: int
    current_default_id: int, the product's current id_category_default
    associated_category_ids: list[int], the product's full category list
    valid_category_ids: set[int], every category id that still exists
    fallback_root_id: int, used only when the product has no valid
        categories of its own left

    Returns a dict describing what to do. action is "none" when the current
    default is already valid, "reassign" when a safe replacement was found,
    or "flag_manual" when no valid category exists to fall back to.
    """
    if current_default_id in valid_category_ids:
        return {"id_product": product_id, "action": "none", "new_default": current_default_id}

    candidates = [
        cid for cid in associated_category_ids
        if cid in valid_category_ids and cid != current_default_id
    ]
    if candidates:
        new_default = max(candidates)
    else:
        new_default = fallback_root_id if fallback_root_id in valid_category_ids else None

    return {
        "id_product": product_id,
        "action": "reassign" if new_default else "flag_manual",
        "old_default": current_default_id,
        "new_default": new_default,
    }


def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def api_put(path, resource_key, body):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params={"output_format": "JSON"}, auth=AUTH,
        json={resource_key: body}, timeout=30,
    )
    r.raise_for_status()
    return r.json()


def all_category_ids(page_size=100):
    ids, offset = set(), 0
    while True:
        data = api_get("categories", params={"display": "full", "limit": f"{offset},{page_size}"})
        rows = data.get("categories") or []
        if not rows:
            return ids
        ids.update(int(row["id"]) for row in rows)
        offset += page_size


def all_products(page_size=100):
    offset = 0
    while True:
        data = api_get("products", params={"display": "full", "limit": f"{offset},{page_size}"})
        rows = data.get("products") or []
        if not rows:
            return
        for row in rows:
            yield row
        offset += page_size


def associated_category_ids(product):
    categories = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
    return [int(row["id"]) for row in categories]


def repair_product_default_category(product_id, new_default_id):
    data = api_get(f"products/{product_id}")
    product = data["product"]
    product["id_category_default"] = new_default_id

    categories = product.setdefault("associations", {}).setdefault("categories", {})
    rows = categories.setdefault("category", [])
    if not any(int(row["id"]) == new_default_id for row in rows):
        rows.append({"id": new_default_id})

    return api_put(f"products/{product_id}", "product", product)


def run():
    valid_category_ids = all_category_ids()
    reassigned = 0
    flagged = 0
    for product in all_products():
        product_id = int(product["id"])
        current_default_id = int(product.get("id_category_default") or 0)
        decision = choose_valid_default_category(
            product_id, current_default_id, associated_category_ids(product),
            valid_category_ids, FALLBACK_ROOT_CATEGORY_ID,
        )
        if decision["action"] == "none":
            continue
        if decision["action"] == "flag_manual":
            flagged += 1
            log.warning("Product id=%s has no valid category to fall back to. Needs manual review.", product_id)
            continue

        log.info(
            "Product id=%s old id_category_default=%s new id_category_default=%s. %s",
            product_id, decision["old_default"], decision["new_default"],
            "would reassign" if DRY_RUN else "reassigning",
        )
        if not DRY_RUN:
            repair_product_default_category(product_id, decision["new_default"])
        reassigned += 1
    log.info("Done. %d product(s) %s, %d flagged for manual review.",
              reassigned, "to reassign" if DRY_RUN else "reassigned", flagged)


if __name__ == "__main__":
    run()
fix-default-category.js
/**
 * Repair PrestaShop products left with a dangling id_category_default after a
 * category deletion.
 *
 * DeleteCategoryHandler only reassigns a product's categories when the deletion
 * would leave it with zero categories at all. It never checks whether the
 * deleted category was that product's default while the product still has
 * other valid categories, so id_category_default keeps pointing at a category
 * id that no longer exists in ps_category (PrestaShop/PrestaShop issue #30219,
 * and related issues #28016 and #9811).
 *
 * This script builds the set of valid category ids, walks every product, and
 * runs a pure decision function that picks a replacement default from the
 * product's own remaining valid categories, falling back to the shop's root
 * category. It logs every proposed change. A corrective PUT that resends the
 * full product body is only sent when DRY_RUN=false.
 *
 * Guide: https://www.allanninal.dev/prestashop/product-missing-default-category-after-deletion/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const FALLBACK_ROOT_CATEGORY_ID = Number(process.env.FALLBACK_ROOT_CATEGORY_ID || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure decision function, no I/O.
 *
 * productId: number
 * currentDefaultId: number, the product's current id_category_default
 * associatedCategoryIds: number[], the product's full category list
 * validCategoryIds: Set, every category id that still exists
 * fallbackRootId: number, used only when the product has no valid
 *   categories of its own left
 *
 * Returns an object describing what to do. action is "none" when the current
 * default is already valid, "reassign" when a safe replacement was found, or
 * "flag_manual" when no valid category exists to fall back to.
 */
export function chooseValidDefaultCategory(productId, currentDefaultId, associatedCategoryIds,
                                            validCategoryIds, fallbackRootId = 2) {
  if (validCategoryIds.has(currentDefaultId)) {
    return { id_product: productId, action: "none", new_default: currentDefaultId };
  }

  const candidates = associatedCategoryIds.filter(
    (cid) => validCategoryIds.has(cid) && cid !== currentDefaultId
  );
  const newDefault = candidates.length
    ? Math.max(...candidates)
    : (validCategoryIds.has(fallbackRootId) ? fallbackRootId : null);

  return {
    id_product: productId,
    action: newDefault ? "reassign" : "flag_manual",
    old_default: currentDefaultId,
    new_default: newDefault,
  };
}

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function apiPut(path, resourceKey, body) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ [resourceKey]: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
  return res.json();
}

async function allCategoryIds(pageSize = 100) {
  const ids = new Set();
  let offset = 0;
  while (true) {
    const data = await apiGet("categories", { display: "full", limit: `${offset},${pageSize}` });
    const rows = data.categories || [];
    if (!rows.length) return ids;
    for (const row of rows) ids.add(Number(row.id));
    offset += pageSize;
  }
}

async function* allProducts(pageSize = 100) {
  let offset = 0;
  while (true) {
    const data = await apiGet("products", { display: "full", limit: `${offset},${pageSize}` });
    const rows = data.products || [];
    if (!rows.length) return;
    for (const row of rows) yield row;
    offset += pageSize;
  }
}

function associatedCategoryIds(product) {
  const categories = product.associations?.categories?.category || [];
  return categories.map((row) => Number(row.id));
}

async function repairProductDefaultCategory(productId, newDefaultId) {
  const data = await apiGet(`products/${productId}`);
  const product = data.product;
  product.id_category_default = newDefaultId;

  product.associations = product.associations || {};
  product.associations.categories = product.associations.categories || {};
  const rows = (product.associations.categories.category = product.associations.categories.category || []);
  if (!rows.some((row) => Number(row.id) === newDefaultId)) {
    rows.push({ id: newDefaultId });
  }

  return apiPut(`products/${productId}`, "product", product);
}

export async function run() {
  const validCategoryIds = await allCategoryIds();
  let reassigned = 0;
  let flagged = 0;
  for await (const product of allProducts()) {
    const productId = Number(product.id);
    const currentDefaultId = Number(product.id_category_default || 0);
    const decision = chooseValidDefaultCategory(
      productId, currentDefaultId, associatedCategoryIds(product),
      validCategoryIds, FALLBACK_ROOT_CATEGORY_ID,
    );
    if (decision.action === "none") continue;
    if (decision.action === "flag_manual") {
      flagged++;
      console.warn(`Product id=${productId} has no valid category to fall back to. Needs manual review.`);
      continue;
    }

    console.log(
      `Product id=${productId} old id_category_default=${decision.old_default} new id_category_default=${decision.new_default}. ${DRY_RUN ? "would reassign" : "reassigning"}`
    );
    if (!DRY_RUN) await repairProductDefaultCategory(productId, decision.new_default);
    reassigned++;
  }
  console.log(`Done. ${reassigned} product(s) ${DRY_RUN ? "to reassign" : "reassigned"}, ${flagged} flagged for manual review.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision function is the part most worth testing, because it decides which category id ends up as a product's default, and it is the only place the logic branches between doing nothing, reassigning, and flagging for manual review. Because we kept choose_valid_default_category pure, the tests need no network and no PrestaShop store. They just feed in plain values and check the answer.

test_default_category.py
from fix_default_category import choose_valid_default_category


def test_no_action_when_default_is_already_valid():
    result = choose_valid_default_category(10, 5, [5, 6], {5, 6, 2})
    assert result == {"id_product": 10, "action": "none", "new_default": 5}


def test_reassigns_to_deepest_remaining_valid_category():
    result = choose_valid_default_category(11, 99, [3, 7], {2, 3, 7})
    assert result["action"] == "reassign"
    assert result["old_default"] == 99
    assert result["new_default"] == 7


def test_falls_back_to_root_when_no_valid_categories_left():
    result = choose_valid_default_category(12, 99, [], {2, 3, 7})
    assert result == {"id_product": 12, "action": "reassign", "old_default": 99, "new_default": 2}


def test_flags_manual_when_even_fallback_root_is_missing():
    result = choose_valid_default_category(13, 99, [], {3, 7}, fallback_root_id=2)
    assert result == {"id_product": 13, "action": "flag_manual", "old_default": 99, "new_default": None}


def test_ignores_associated_categories_that_are_also_invalid():
    result = choose_valid_default_category(14, 99, [98, 97], {2, 3}, fallback_root_id=2)
    assert result["action"] == "reassign"
    assert result["new_default"] == 2


def test_excludes_current_default_from_candidates_even_if_technically_valid():
    # current_default_id already handled by the first branch when it is valid,
    # but associated_category_ids should never resurrect the same broken id
    result = choose_valid_default_category(15, 99, [99, 6], {6, 2})
    assert result["new_default"] == 6
default-category.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { chooseValidDefaultCategory } from "./fix-default-category.js";

test("no action when default is already valid", () => {
  const result = chooseValidDefaultCategory(10, 5, [5, 6], new Set([5, 6, 2]));
  assert.deepEqual(result, { id_product: 10, action: "none", new_default: 5 });
});

test("reassigns to deepest remaining valid category", () => {
  const result = chooseValidDefaultCategory(11, 99, [3, 7], new Set([2, 3, 7]));
  assert.equal(result.action, "reassign");
  assert.equal(result.old_default, 99);
  assert.equal(result.new_default, 7);
});

test("falls back to root when no valid categories left", () => {
  const result = chooseValidDefaultCategory(12, 99, [], new Set([2, 3, 7]));
  assert.deepEqual(result, { id_product: 12, action: "reassign", old_default: 99, new_default: 2 });
});

test("flags manual when even fallback root is missing", () => {
  const result = chooseValidDefaultCategory(13, 99, [], new Set([3, 7]), 2);
  assert.deepEqual(result, { id_product: 13, action: "flag_manual", old_default: 99, new_default: null });
});

test("ignores associated categories that are also invalid", () => {
  const result = chooseValidDefaultCategory(14, 99, [98, 97], new Set([2, 3]), 2);
  assert.equal(result.action, "reassign");
  assert.equal(result.new_default, 2);
});

test("excludes current default from candidates even if technically valid", () => {
  const result = chooseValidDefaultCategory(15, 99, [99, 6], new Set([6, 2]));
  assert.equal(result.new_default, 6);
});

Case studies

Seasonal cleanup

The winter collection that broke summer products

A fashion retailer deleted last year's "Winter 2025" category once the season ended, expecting a routine cleanup. A batch of accessories had been filed under Winter 2025 as their default category, but also carried the general "Accessories" category as a secondary. Those products still had a category, so nothing looked broken in the admin category tree, but every one of their product pages started throwing a fatal error on the storefront.

Running the reconciliation script in dry run turned up the exact list in seconds: every affected product still had the deleted category id sitting in id_category_default. The team reviewed the proposed reassignments, which all correctly picked "Accessories" as the new default, then ran it for real and the storefront errors stopped immediately.

Supplier discontinued

An entire supplier's category, and no fallback anywhere

A hardware store discontinued a supplier and deleted that supplier's entire category branch, assuming every product under it had already been reassigned. A handful of clearance products had that branch as their only category association at all, meaning the deletion handler's zero-category safeguard should have caught them, but a timing issue in the bulk deletion left a few behind with no valid category whatsoever.

The script's flag_manual path caught exactly those products, since neither their own category list nor the fallback root category ID resolved to anything valid at that moment. The team fixed the root category id in their config, reran the script, and the fallback picked up the rest cleanly.

What good looks like

After this runs, every product's id_category_default points at a category that actually still exists, whether that means one of its own remaining categories or the shop's root as a last resort. Product pages stop throwing fatal errors from a dangling default, the back office shows a real category again instead of a blank field, and the handful of products with no valid category left get flagged instead of silently guessed at.

FAQ

Why does my PrestaShop product lose its default category after I delete a category?

PrestaShop's DeleteCategoryHandler only reassigns a product's categories when deleting the category would leave the product with zero categories at all. It never checks whether the deleted category was that product's id_category_default while the product still has other valid categories. The product row keeps the old id_category_default value, which now points at a category id that no longer exists in ps_category.

What breaks on the front office when id_category_default is dangling?

The product page tries to resolve its default category to build the breadcrumb and canonical link. When that category id no longer exists, Category.php cannot load a row for it, and the lookup that expects an array returns false instead. The page then throws a fatal error, commonly reported as Trying to access array offset on value of type bool, so the product becomes unreachable on the storefront.

How do I find every product with a dangling default category through the API?

Build a set of every valid category id from GET /api/categories?display=full&output_format=JSON, then walk every product from GET /api/products?display=full&output_format=JSON and compare each product's id_category_default against that set. A product is affected when id_category_default is missing from the valid set, or when it is 0 or empty while the product still has entries under associations.categories. You can also confirm with a direct GET /api/categories/{id} and treat a 404 as proof.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Default category should always be enforced on a product, even after category is deleted, issue #30219. github.com/PrestaShop/PrestaShop/issues/30219
  2. PrestaShop GitHub: Delete the main category of a product, product should be reassigned another main category, issue #9811. github.com/PrestaShop/PrestaShop/issues/9811
  3. PrestaShop GitHub: Default category does not update properly, issue #28016. github.com/PrestaShop/PrestaShop/issues/28016

On the solution:

  1. PrestaShop Developer Documentation: Products webservice resource, including id_category_default. devdocs.prestashop-project.org/9/webservice/resources/products
  2. PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories
  3. PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/8/webservice/tutorials/create-product-az

Stuck on a tricky one?

If you have a problem in PrestaShop catalog, categories, stock, orders, or the webservice API 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 rescue a broken product page?

If this saved you a fatal error on the storefront or a mystery blank category field, 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 PrestaShop field notes