Skip to content

Reconciler

Products or categories orphaned outside the root category tree

A category still sits in the database, active, with products attached, but it never shows up in navigation. Nothing was deleted on purpose. This usually means its id_parent chain no longer leads back to the shop's root category, either because the Home category was removed directly instead of through the shop's reassignment flow, or an import set id_parent to an id that does not exist or belongs to a different shop. PrestaShop only renders what it can walk from the root, so the row is alive but unreachable. Here is why that happens and a small script that walks the tree and reports every orphaned category and product id.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
Paper documents and file folders
Photo by Wesley Tingey on Unsplash
The short answer

PrestaShop stores categories as a nested set tree, with id_parent plus internal nleft/nright bounds, rooted at each shop's designated root category (shops.id_category, typically Home). If that root is deleted directly, or a category or product import sets id_parent to a non-existent or wrong-shop id, the child category keeps a value that no longer resolves back to the root. The front office and category listings only render nodes reachable from the root, so the row stays active in ps_category and products stay linked through ps_category_product, but neither one shows up anywhere. Run a Python or Node.js script that reads each shop's root category id, pulls every category and product over the webservice, walks the id_parent links with a breadth first search from the root, and reports every category and product id that walk never reaches. Repair is a separate, explicitly confirmed step. Full code, tests, and citations are below.

The problem in plain words

Every PrestaShop shop has one designated root category, recorded in shops.id_category, sitting under a hidden super-root that is never shown on the storefront. Everything a shopper can browse to has to be reachable by following id_parent links, one hop at a time, from that root down to the category or product in question.

That chain can break. If someone deletes the Home category directly, through raw SQL, a bad module, or admin misuse, instead of using the shop's built in category reassignment flow, every category that used to hang off it keeps pointing at an id_parent that no longer exists. If a category or product import writes an id_parent that belongs to a different shop, or an id that was never valid, the same thing happens: the row is untouched, still marked active, but its path back to the root is gone. PrestaShop's Category::checkBeforeAdd and the admin import controller do not always validate that an imported or edited id_parent actually chains back to a live ancestor, so nothing stops the write.

Home root deleted SQL, module, or import id_parent dangles points to a dead or wrong-shop category no path to root Row stays active ps_category, products still linked Invisible on storefront
Nothing looks deleted in the back office list. But without a live path back to the shop's root category, the category and its products never render in navigation.

Why it happens

This is a structural gap in how the nested set tree gets validated on write, not a one-off bug in a single store. A few common ways stores end up here:

This has come up often enough on the PrestaShop forums and issue tracker that store owners describe finding entire branches of categories, and the products under them, simply missing from the storefront with no error anywhere. See the citations at the end for the exact threads and issues.

The key insight

The webservice never exposes nleft/nright, the nested set bounds PrestaShop actually uses internally to decide what is inside the tree. So the only way to detect this over the API is to rebuild the same reachability check yourself: start from each shop's real root id, and walk id_parent links outward. Anything the walk never reaches is orphaned, whether it is a category with no way back to Home, or a product whose every category id fails that same reachability check.

The fix, as a flow

We do not touch the live category tree automatically. We add a job that reads each shop's true root id, pulls back every category and every active product, walks the parent chain from the root to build the reachable set, and runs a pure decision function that flags any category or product the walk never touches. A corrective write is only sent when it is explicitly authorized.

Read root ids GET /api/shops List categories, products id, id_parent, id_category_default find_orphans() pure BFS from root, no I/O Reached by the walk? yes, in tree, skip Nothing to do no, orphaned Report DRY_RUN gate
The job only ever reports by default. A corrective PUT that re-parents an orphaned category to the shop's Home category is only sent when DRY_RUN is explicitly turned off and the target is confirmed.

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 shops, 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 DRY_RUN="true"   # start safe, change to false to allow the repair PUT
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 DRY_RUN="true"   // start safe, change to false to allow the repair PUT
2

Read each shop's true root category id

Call GET /api/shops?output_format=JSON&display=full and read id_category off each shop, that is the real root for that shop. If a shop row is missing it, fall back to GET /api/configurations?filter[name]=PS_HOME_CATEGORY&output_format=JSON. Every reachability walk starts from this set of ids, never from a guess.

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 shop_root_ids():
    data = api_get("shops", params={"display": "full"})
    rows = data.get("shops") or []
    roots = {int(row["id_category"]) for row in rows if row.get("id_category")}
    if roots:
        return roots
    cfg = api_get("configurations", params={"filter[name]": "PS_HOME_CATEGORY"})
    rows = cfg.get("configurations") or []
    return {int(row["value"]) for row in rows if row.get("value")}
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 shopRootIds() {
  const data = await apiGet("shops", { display: "full" });
  const rows = data.shops || [];
  const roots = new Set(rows.filter((r) => r.id_category).map((r) => Number(r.id_category)));
  if (roots.size) return roots;
  const cfg = await apiGet("configurations", { "filter[name]": "PS_HOME_CATEGORY" });
  const cfgRows = cfg.configurations || [];
  return new Set(cfgRows.filter((r) => r.value).map((r) => Number(r.value)));
}
3

Pull back every category and every active product

Call GET /api/categories?output_format=JSON&display=full&limit=0 for the full category list with id, id_parent, and is_root_category. Then call GET /api/products?output_format=JSON&display=full&filter[active]=1&limit=0 and read id_category_default plus every id in associations.categories.category for each product.

step3.py
def all_categories():
    data = api_get("categories", params={"display": "full", "limit": "0"})
    rows = data.get("categories") or []
    return [
        {
            "id": int(row["id"]),
            "id_parent": int(row["id_parent"]) if row.get("id_parent") not in (None, "") else None,
            "is_root_category": str(row.get("is_root_category")) in ("1", "true", "True"),
        }
        for row in rows
    ]

def all_active_products():
    data = api_get("products", params={"display": "full", "filter[active]": "1", "limit": "0"})
    rows = data.get("products") or []
    products = []
    for row in rows:
        cats = ((row.get("associations") or {}).get("categories") or {}).get("category") or []
        category_ids = [int(c["id"]) for c in cats if c.get("id")]
        default = row.get("id_category_default")
        products.append({
            "id": int(row["id"]),
            "id_category_default": int(default) if default not in (None, "") else None,
            "category_ids": category_ids,
        })
    return products
step3.js
async function allCategories() {
  const data = await apiGet("categories", { display: "full", limit: "0" });
  const rows = data.categories || [];
  return rows.map((row) => ({
    id: Number(row.id),
    id_parent: row.id_parent !== undefined && row.id_parent !== null && row.id_parent !== "" ? Number(row.id_parent) : null,
    is_root_category: ["1", "true", true].includes(row.is_root_category),
  }));
}

async function allActiveProducts() {
  const data = await apiGet("products", { display: "full", "filter[active]": "1", limit: "0" });
  const rows = data.products || [];
  return rows.map((row) => {
    const cats = (row.associations && row.associations.categories && row.associations.categories.category) || [];
    const categoryIds = cats.filter((c) => c.id).map((c) => Number(c.id));
    const hasDefault = row.id_category_default !== undefined && row.id_category_default !== null && row.id_category_default !== "";
    return {
      id: Number(row.id),
      id_category_default: hasDefault ? Number(row.id_category_default) : null,
      category_ids: categoryIds,
    };
  });
}
4

Decide, with one pure function

Keep the decision in its own function that takes only plain lists and a set of root ids, no I/O at all. It builds a parent to children adjacency map, walks it with a breadth first search starting from the root ids to compute every reachable category id, then flags any category outside that reachable set and any product whose default category and every associated category are all unreachable.

decide.py
from collections import deque

def find_orphans(categories, root_ids, products):
    children = {}
    for cat in categories:
        parent = cat.get("id_parent")
        if parent is not None:
            children.setdefault(parent, []).append(cat["id"])

    reachable = set(root_ids)
    queue = deque(root_ids)
    while queue:
        current = queue.popleft()
        for child_id in children.get(current, []):
            if child_id not in reachable:
                reachable.add(child_id)
                queue.append(child_id)

    orphaned_categories = [
        cat["id"] for cat in categories
        if cat["id"] not in reachable and cat["id"] not in root_ids
    ]

    orphaned_products = [
        p["id"] for p in products
        if p.get("id_category_default") not in reachable
        and not any(cid in reachable for cid in p.get("category_ids") or [])
    ]

    return {"orphaned_categories": orphaned_categories, "orphaned_products": orphaned_products}
decide.js
export function findOrphans(categories, rootIds, products) {
  const rootSet = rootIds instanceof Set ? rootIds : new Set(rootIds);
  const children = new Map();
  for (const cat of categories) {
    const parent = cat.id_parent;
    if (parent !== null && parent !== undefined) {
      if (!children.has(parent)) children.set(parent, []);
      children.get(parent).push(cat.id);
    }
  }

  const reachable = new Set(rootSet);
  const queue = [...rootSet];
  while (queue.length) {
    const current = queue.shift();
    for (const childId of children.get(current) || []) {
      if (!reachable.has(childId)) {
        reachable.add(childId);
        queue.push(childId);
      }
    }
  }

  const orphanedCategories = categories
    .filter((cat) => !reachable.has(cat.id) && !rootSet.has(cat.id))
    .map((cat) => cat.id);

  const orphanedProducts = products
    .filter((p) => {
      const defaultReachable = reachable.has(p.id_category_default);
      const anyReachable = (p.category_ids || []).some((cid) => reachable.has(cid));
      return !defaultReachable && !anyReachable;
    })
    .map((p) => p.id);

  return { orphaned_categories: orphanedCategories, orphaned_products: orphanedProducts };
}
5

Report by default, repair only when explicitly confirmed

Re-parenting can silently move a category that was deliberately structured into the wrong branch, so the default behavior is to log every orphaned category id and product id with their current id_parent or id_category_default, and stop there. Only when DRY_RUN=false does the script send the corrective write: a PUT /api/categories/{id} that sets id_parent to the shop's Home category id.

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

def reparent_category_to_home(category, home_category_id):
    # Only used when DRY_RUN=false and a safe target has been confirmed.
    # PrestaShop recomputes nleft/nright for the moved subtree on save.
    body = dict(category)
    body["id_parent"] = home_category_id
    return api_put(f"categories/{category['id']}", "category", body)
apply.js
async function apiPut(path, resourceKey, body, 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, {
    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 reparentCategoryToHome(category, homeCategoryId) {
  // Only used when DRY_RUN=false and a safe target has been confirmed.
  // PrestaShop recomputes nleft/nright for the moved subtree on save.
  const body = { ...category, id_parent: homeCategoryId };
  return apiPut(`categories/${category.id}`, "category", body);
}
6

Wire it together with a dry run guard

The loop ties every piece together: read the shop root ids, list categories and active products, run find_orphans, log every orphaned category and product id, and only send the repair PUT for orphaned category roots when DRY_RUN=false. Leave DRY_RUN on and review the report first, because re-parenting the wrong branch into Home is not something the webservice can undo on its own.

Run it safe

Always start with DRY_RUN=true. Reporting orphaned ids is safe and reversible; re-parenting a category to Home is not something the webservice can undo automatically. Only turn off dry run once a human has reviewed the flagged list and agreed where each orphaned root belongs.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, reports by default, and only sends the repair PUT to a confirmed orphaned category root when the dry run flag is explicitly turned off.

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.
find_orphaned_categories.py
"""Find PrestaShop categories and products orphaned outside the root tree.

PrestaShop stores categories as a nested set tree (id_parent plus internal
nleft/nright bounds) rooted at each shop's designated root category
(shops.id_category, typically Home under a hidden super-root). If the root is
deleted directly instead of through the shop's reassignment flow, or a
category or product import sets id_parent to a non-existent or wrong-shop id,
child categories keep an id_parent that no longer resolves back to the root.
The front office only renders nodes reachable from the root, so the row stays
active in ps_category, and products stay linked via ps_category_product, but
neither is visible anywhere.

This script reads each shop's true root id, pulls every category and active
product over the webservice, walks id_parent links with a breadth first
search from the root, and runs a pure decision function that flags any
category or product the walk never reaches. It reports by default. A
corrective PUT that re-parents an orphaned category root to the shop's Home
category is only sent when DRY_RUN=false and the target has been confirmed.

Run on a schedule, or right after a suspicious import. Safe to run again and again.
"""
import os
import logging
from collections import deque
import requests

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

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


def find_orphans(categories, root_ids, products):
    """Pure decision function, no I/O.

    categories: list[dict] each with id, id_parent, is_root_category.
    root_ids: set[int] of valid shop root category ids (shops.id_category).
    products: list[dict] each with id, id_category_default, category_ids (list[int]).

    Builds a parent to children adjacency map, walks it with a breadth first
    search from root_ids to compute reachable_category_ids, then returns the
    category ids and product ids that walk never reaches.
    """
    children = {}
    for cat in categories:
        parent = cat.get("id_parent")
        if parent is not None:
            children.setdefault(parent, []).append(cat["id"])

    reachable = set(root_ids)
    queue = deque(root_ids)
    while queue:
        current = queue.popleft()
        for child_id in children.get(current, []):
            if child_id not in reachable:
                reachable.add(child_id)
                queue.append(child_id)

    orphaned_categories = [
        cat["id"] for cat in categories
        if cat["id"] not in reachable and cat["id"] not in root_ids
    ]

    orphaned_products = [
        p["id"] for p in products
        if p.get("id_category_default") not in reachable
        and not any(cid in reachable for cid in p.get("category_ids") or [])
    ]

    return {"orphaned_categories": orphaned_categories, "orphaned_products": orphaned_products}


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, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params=params, auth=AUTH,
        json={resource_key: body}, timeout=30,
    )
    r.raise_for_status()
    return r.json()


def shop_root_ids():
    data = api_get("shops", params={"display": "full"})
    rows = data.get("shops") or []
    roots = {int(row["id_category"]) for row in rows if row.get("id_category")}
    if roots:
        return roots
    cfg = api_get("configurations", params={"filter[name]": "PS_HOME_CATEGORY"})
    rows = cfg.get("configurations") or []
    return {int(row["value"]) for row in rows if row.get("value")}


def all_categories():
    data = api_get("categories", params={"display": "full", "limit": "0"})
    rows = data.get("categories") or []
    return [
        {
            "id": int(row["id"]),
            "id_parent": int(row["id_parent"]) if row.get("id_parent") not in (None, "") else None,
            "is_root_category": str(row.get("is_root_category")) in ("1", "true", "True"),
        }
        for row in rows
    ]


def all_active_products():
    data = api_get("products", params={"display": "full", "filter[active]": "1", "limit": "0"})
    rows = data.get("products") or []
    products = []
    for row in rows:
        cats = ((row.get("associations") or {}).get("categories") or {}).get("category") or []
        category_ids = [int(c["id"]) for c in cats if c.get("id")]
        default = row.get("id_category_default")
        products.append({
            "id": int(row["id"]),
            "id_category_default": int(default) if default not in (None, "") else None,
            "category_ids": category_ids,
        })
    return products


def reparent_category_to_home(category, home_category_id):
    # Only used when DRY_RUN=false and a safe target has been confirmed.
    # PrestaShop recomputes nleft/nright for the moved subtree on save.
    body = dict(category)
    body["id_parent"] = home_category_id
    return api_put(f"categories/{category['id']}", "category", body)


def run():
    root_ids = shop_root_ids()
    categories = all_categories()
    products = all_active_products()
    result = find_orphans(categories, root_ids, products)

    orphaned_categories = result["orphaned_categories"]
    orphaned_products = result["orphaned_products"]
    by_id = {cat["id"]: cat for cat in categories}

    for cat_id in orphaned_categories:
        cat = by_id.get(cat_id, {})
        log.warning("Orphaned category id=%s id_parent=%s", cat_id, cat.get("id_parent"))

    for prod_id in orphaned_products:
        log.warning("Orphaned product id=%s", prod_id)

    if not DRY_RUN and orphaned_categories and root_ids:
        home_id = next(iter(root_ids))
        for cat_id in orphaned_categories:
            reparent_category_to_home(by_id[cat_id], home_id)
            log.info("Re-parented category id=%s to Home id_parent=%s.", cat_id, home_id)

    log.info(
        "Done. %d orphaned categorie(s), %d orphaned product(s) %s.",
        len(orphaned_categories), len(orphaned_products),
        "reported" if DRY_RUN else "reported and categories repaired",
    )


if __name__ == "__main__":
    run()
find-orphaned-categories.js
/**
 * Find PrestaShop categories and products orphaned outside the root tree.
 *
 * PrestaShop stores categories as a nested set tree (id_parent plus internal
 * nleft/nright bounds) rooted at each shop's designated root category
 * (shops.id_category, typically Home under a hidden super-root). If the root is
 * deleted directly instead of through the shop's reassignment flow, or a
 * category or product import sets id_parent to a non-existent or wrong-shop id,
 * child categories keep an id_parent that no longer resolves back to the root.
 * The front office only renders nodes reachable from the root, so the row stays
 * active in ps_category, and products stay linked via ps_category_product, but
 * neither is visible anywhere.
 *
 * This script reads each shop's true root id, pulls every category and active
 * product over the webservice, walks id_parent links with a breadth first
 * search from the root, and runs a pure decision function that flags any
 * category or product the walk never reaches. It reports by default. A
 * corrective PUT that re-parents an orphaned category root to the shop's Home
 * category is only sent when DRY_RUN=false and the target has been confirmed.
 *
 * Guide: https://www.allanninal.dev/prestashop/orphaned-categories-outside-root-tree/
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

/**
 * Pure decision function, no I/O.
 *
 * categories: array of plain objects, each with id, id_parent, is_root_category.
 * rootIds: Set or array of valid shop root category ids (shops.id_category).
 * products: array of plain objects, each with id, id_category_default, category_ids (array).
 *
 * Builds a parent to children adjacency map, walks it with a breadth first
 * search from rootIds to compute the reachable category ids, then returns the
 * category ids and product ids that walk never reaches.
 */
export function findOrphans(categories, rootIds, products) {
  const rootSet = rootIds instanceof Set ? rootIds : new Set(rootIds);
  const children = new Map();
  for (const cat of categories) {
    const parent = cat.id_parent;
    if (parent !== null && parent !== undefined) {
      if (!children.has(parent)) children.set(parent, []);
      children.get(parent).push(cat.id);
    }
  }

  const reachable = new Set(rootSet);
  const queue = [...rootSet];
  while (queue.length) {
    const current = queue.shift();
    for (const childId of children.get(current) || []) {
      if (!reachable.has(childId)) {
        reachable.add(childId);
        queue.push(childId);
      }
    }
  }

  const orphanedCategories = categories
    .filter((cat) => !reachable.has(cat.id) && !rootSet.has(cat.id))
    .map((cat) => cat.id);

  const orphanedProducts = products
    .filter((p) => {
      const defaultReachable = reachable.has(p.id_category_default);
      const anyReachable = (p.category_ids || []).some((cid) => reachable.has(cid));
      return !defaultReachable && !anyReachable;
    })
    .map((p) => p.id);

  return { orphaned_categories: orphanedCategories, orphaned_products: orphanedProducts };
}

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, 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, {
    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 shopRootIds() {
  const data = await apiGet("shops", { display: "full" });
  const rows = data.shops || [];
  const roots = new Set(rows.filter((r) => r.id_category).map((r) => Number(r.id_category)));
  if (roots.size) return roots;
  const cfg = await apiGet("configurations", { "filter[name]": "PS_HOME_CATEGORY" });
  const cfgRows = cfg.configurations || [];
  return new Set(cfgRows.filter((r) => r.value).map((r) => Number(r.value)));
}

async function allCategories() {
  const data = await apiGet("categories", { display: "full", limit: "0" });
  const rows = data.categories || [];
  return rows.map((row) => ({
    id: Number(row.id),
    id_parent: row.id_parent !== undefined && row.id_parent !== null && row.id_parent !== "" ? Number(row.id_parent) : null,
    is_root_category: ["1", "true", true].includes(row.is_root_category),
  }));
}

async function allActiveProducts() {
  const data = await apiGet("products", { display: "full", "filter[active]": "1", limit: "0" });
  const rows = data.products || [];
  return rows.map((row) => {
    const cats = (row.associations && row.associations.categories && row.associations.categories.category) || [];
    const categoryIds = cats.filter((c) => c.id).map((c) => Number(c.id));
    const hasDefault = row.id_category_default !== undefined && row.id_category_default !== null && row.id_category_default !== "";
    return {
      id: Number(row.id),
      id_category_default: hasDefault ? Number(row.id_category_default) : null,
      category_ids: categoryIds,
    };
  });
}

async function reparentCategoryToHome(category, homeCategoryId) {
  // Only used when DRY_RUN=false and a safe target has been confirmed.
  // PrestaShop recomputes nleft/nright for the moved subtree on save.
  const body = { ...category, id_parent: homeCategoryId };
  return apiPut(`categories/${category.id}`, "category", body);
}

export async function run() {
  const rootIds = await shopRootIds();
  const categories = await allCategories();
  const products = await allActiveProducts();
  const result = findOrphans(categories, rootIds, products);

  const { orphaned_categories: orphanedCategories, orphaned_products: orphanedProducts } = result;
  const byId = new Map(categories.map((cat) => [cat.id, cat]));

  for (const catId of orphanedCategories) {
    const cat = byId.get(catId) || {};
    console.warn(`Orphaned category id=${catId} id_parent=${cat.id_parent}`);
  }

  for (const prodId of orphanedProducts) {
    console.warn(`Orphaned product id=${prodId}`);
  }

  if (!DRY_RUN && orphanedCategories.length && rootIds.size) {
    const homeId = [...rootIds][0];
    for (const catId of orphanedCategories) {
      await reparentCategoryToHome(byId.get(catId), homeId);
      console.log(`Re-parented category id=${catId} to Home id_parent=${homeId}.`);
    }
  }

  console.log(
    `Done. ${orphanedCategories.length} orphaned categorie(s), ${orphanedProducts.length} orphaned product(s) ${DRY_RUN ? "reported" : "reported and categories repaired"}.`
  );
}

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 and product ids get reported, and it gates the only write path in the script. Because we kept find_orphans pure, the tests need no network and no PrestaShop store. They just build small synthetic trees and check the answer.

test_orphaned_categories.py
from find_orphaned_categories import find_orphans


def cat(id, id_parent):
    return {"id": id, "id_parent": id_parent, "is_root_category": False}


def test_reachable_tree_has_no_orphans():
    categories = [cat(2, 1), cat(3, 1), cat(4, 2)]
    result = find_orphans(categories, {1}, [])
    assert result["orphaned_categories"] == []


def test_category_pointing_at_deleted_parent_is_orphaned():
    # id_parent 99 does not exist anywhere in the categories list
    categories = [cat(2, 1), cat(3, 99)]
    result = find_orphans(categories, {1}, [])
    assert result["orphaned_categories"] == [3]


def test_whole_orphaned_branch_is_flagged():
    categories = [cat(2, 1), cat(3, 99), cat(4, 3)]
    result = find_orphans(categories, {1}, [])
    assert result["orphaned_categories"] == [3, 4]


def test_root_ids_are_never_flagged():
    categories = [cat(2, 1)]
    result = find_orphans(categories, {1, 2}, [])
    assert result["orphaned_categories"] == []


def test_cycle_outside_root_is_orphaned():
    # 3 and 4 point at each other, neither one chains back to root 1
    categories = [cat(2, 1), cat(3, 4), cat(4, 3)]
    result = find_orphans(categories, {1}, [])
    assert sorted(result["orphaned_categories"]) == [3, 4]


def test_product_with_only_orphaned_category_is_flagged():
    categories = [cat(2, 1), cat(3, 99)]
    products = [{"id": 501, "id_category_default": 3, "category_ids": [3]}]
    result = find_orphans(categories, {1}, products)
    assert result["orphaned_products"] == [501]


def test_product_reachable_through_any_category_is_not_flagged():
    categories = [cat(2, 1), cat(3, 99)]
    products = [{"id": 502, "id_category_default": 3, "category_ids": [3, 2]}]
    result = find_orphans(categories, {1}, products)
    assert result["orphaned_products"] == []
orphaned.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphans } from "./find-orphaned-categories.js";

const cat = (id, idParent) => ({ id, id_parent: idParent, is_root_category: false });

test("reachable tree has no orphans", () => {
  const categories = [cat(2, 1), cat(3, 1), cat(4, 2)];
  const result = findOrphans(categories, new Set([1]), []);
  assert.deepEqual(result.orphaned_categories, []);
});

test("category pointing at deleted parent is orphaned", () => {
  const categories = [cat(2, 1), cat(3, 99)];
  const result = findOrphans(categories, new Set([1]), []);
  assert.deepEqual(result.orphaned_categories, [3]);
});

test("whole orphaned branch is flagged", () => {
  const categories = [cat(2, 1), cat(3, 99), cat(4, 3)];
  const result = findOrphans(categories, new Set([1]), []);
  assert.deepEqual(result.orphaned_categories, [3, 4]);
});

test("root ids are never flagged", () => {
  const categories = [cat(2, 1)];
  const result = findOrphans(categories, new Set([1, 2]), []);
  assert.deepEqual(result.orphaned_categories, []);
});

test("cycle outside root is orphaned", () => {
  const categories = [cat(2, 1), cat(3, 4), cat(4, 3)];
  const result = findOrphans(categories, new Set([1]), []);
  assert.deepEqual([...result.orphaned_categories].sort(), [3, 4]);
});

test("product with only orphaned category is flagged", () => {
  const categories = [cat(2, 1), cat(3, 99)];
  const products = [{ id: 501, id_category_default: 3, category_ids: [3] }];
  const result = findOrphans(categories, new Set([1]), products);
  assert.deepEqual(result.orphaned_products, [501]);
});

test("product reachable through any category is not flagged", () => {
  const categories = [cat(2, 1), cat(3, 99)];
  const products = [{ id: 502, id_category_default: 3, category_ids: [3, 2] }];
  const result = findOrphans(categories, new Set([1]), products);
  assert.deepEqual(result.orphaned_products, []);
});

Case studies

Bad module uninstall

The seasonal module that deleted Home on its way out

A merchant installed a seasonal landing page module that created its own root-level category, then removed it a few months later. The module's uninstall routine deleted more than it should have, taking the shop's Home category with it. Nobody noticed immediately, because the categories under Home were still sitting in the back office list, just not showing up anywhere on the storefront.

Running the report script against the shop showed dozens of category ids whose id_parent pointed at an id that no longer existed anywhere in the categories list. Once the team confirmed which branch used to sit under Home, they ran the script again with DRY_RUN=false to re-parent just those root orphans back under the real Home category, and the storefront navigation came back within minutes of the next cache rebuild.

Cross-shop import

Categories imported from the wrong shop id

A multistore install migrated a supplier's catalog with a script that copied category ids from a staging shop straight into production without remapping them. Every imported category's id_parent referenced an id that existed in staging but belonged to a different shop's tree in production, so the categories were active but never reachable from the production shop's root.

The orphan report flagged every one of those categories in a single run, along with the handful of products whose only associated category was one of the orphaned ones. The team fixed the import script to remap parent ids per shop going forward, then used the report to re-parent the existing orphaned roots by hand, one confirmed branch at a time.

What good looks like

After this runs, every category and product in the catalog has a confirmed, live path back to its shop's root, and nothing sits active but invisible because of a broken id_parent chain. The report tells you exactly which category ids and product ids are affected and what they currently point at, so a human can confirm the right home before anything is re-parented, and going forward the fix is simple: always use the shop's reassignment flow before deleting a category, and validate id_parent against the destination shop on every import.

FAQ

Why is a category active but invisible on my PrestaShop storefront?

PrestaShop only renders categories that are reachable by following id_parent links back to the shop's root category (shops.id_category). If the root itself was deleted directly, or an import set id_parent to a category id that no longer exists or belongs to a different shop, the row stays active in ps_category but has no live path back to the root, so navigation and category listings never show it even though it is not deleted.

Can I find orphaned categories through the PrestaShop webservice without direct database access?

Yes. Read each shop's root id from GET /api/shops, then pull every category with GET /api/categories?display=full&limit=0 and walk the id_parent links from that root with a breadth first search. Any category id never reached by that walk is orphaned. The nleft and nright nested set bounds PrestaShop uses internally are not exposed by the webservice, so the id_parent walk is the only way to do this over the API.

Is it safe to automatically re-parent orphaned categories back into the tree?

Not by default. Re-parenting can silently move a subtree that was deliberately structured into the wrong branch, so the safe default is to report the orphaned category and product ids for a human to review. A corrective PUT that sets id_parent to the shop's Home category is only sent when DRY_RUN is explicitly turned off and the target has been confirmed, because PrestaShop recomputes nleft and nright automatically on save but cannot guess where a genuinely deleted ancestor belonged.

Related field notes

Citations

On the problem:

  1. PrestaShop Forums: Root category deleted by mistake. prestashop.com/forums/topic/564596-root-category-deleted-by-mistake
  2. PrestaShop GitHub: Multistore, root categories, categories and their parent categories, issue #10942. github.com/PrestaShop/PrestaShop/issues/10942
  3. Sitolog: Products without a PrestaShop category, how to attach orphan products. sitolog.com/en/content/41-how-to-link-an-orphan-product-to-a-prestashop-category

On the solution:

  1. PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories
  2. PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products
  3. PrestaShop Developer Documentation: Manage Multishop. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/manage-multishop

Stuck on a tricky one?

If you have a problem in PrestaShop categories, multistore, 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 find your missing categories?

If this saved you a branch of categories that vanished from the storefront, 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