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.
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.
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:
- The Home or root category is removed with a direct SQL delete, a bad module's uninstall routine, or by an admin deleting it from the categories list without using the shop's reassignment flow that PrestaShop expects.
- A category or product import sets
id_parentto an id that was valid in a source system or a different shop, but does not exist, or does not belong to the current shop, in the destination install. Category::checkBeforeAddandAdminImportControllerdo not always confirm that an edited or importedid_parentactually chains back to a live ancestor of the current shop's root, so the write is accepted even when the chain is already broken.- The row is never technically deleted, so nothing in the admin flags it as an error. It just silently stops appearing anywhere a shopper or an admin listing would look.
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 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.
Build it step by step
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.
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
// 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
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.
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")}
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)));
}
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.
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
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,
};
});
}
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.
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}
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 };
}
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.
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)
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);
}
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.
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.
"""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 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.
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"] == []
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
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.
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.
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:
- PrestaShop Forums: Root category deleted by mistake. prestashop.com/forums/topic/564596-root-category-deleted-by-mistake
- PrestaShop GitHub: Multistore, root categories, categories and their parent categories, issue #10942. github.com/PrestaShop/PrestaShop/issues/10942
- 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:
- PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories
- PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products
- 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.
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