Reconciler
Product's default category is not among its assigned categories
You open a product in the back office and the breadcrumb, or the storefront canonical link, points at a category the product is not even checked into anymore. Nothing errored when it happened. A merchant unchecked a category box weeks ago, or a category got deleted, or an import ran with a partial category list, and PrestaShop never went back to check whether id_category_default still made sense. Here is why that drift happens and a small script that finds every product it has happened to.
PrestaShop's backend product editor updates category associations in the category_product table instantly, over AJAX, the moment a merchant checks or unchecks a category box, without waiting for the product's Save action. But it never re-validates or re-derives id_category_default at that moment. If the category that used to be the default gets unchecked, or a category is deleted store-wide, the product's id_category_default column keeps pointing at a category the product is no longer linked to. Catalog import can cause the same drift when only partial category data is sent for a row. Run a Python or Node.js script that reads every active product's id_category_default and its associations.categories.category[] list from the webservice, and flags any product where the default is not in that list. Repair is a separate, explicitly confirmed step, since there is no way to recover the merchant's real intent from the data. Full code, tests, and citations are below.
The problem in plain words
Every product in PrestaShop has a set of categories it belongs to, stored as rows in category_product, and one of those categories is marked as the default, stored as the id_category_default column on the product itself. The default is what builds the product's canonical URL and decides which breadcrumb shows on the storefront, so it matters even though it is just one integer.
The back office product editor treats these as two separate things that update on two separate timelines. Ticking or unticking a category checkbox in the Categories tab writes straight to category_product right away, before you ever click Save. The default category dropdown is a different control entirely, and PrestaShop does not sweep back through and ask "is the current default still one of the checked boxes?" when the checked boxes change. Uncheck the box for the category that happens to be the default, and the row disappears from category_product while id_category_default sits there unchanged, now pointing at nothing the product is actually linked to.
Why it happens
This is confirmed core behavior, not a one-off bug in a single store. A few concrete ways it shows up:
- A merchant unchecks the category that is currently set as default in the product's Categories tab. The association is removed instantly over AJAX, but
id_category_defaultis never re-derived, so it keeps the stale value (PrestaShop/PrestaShop issue #28016, "Default category doesn't update properly"). - A category gets deleted store-wide. Every product that had it as their default keeps that now-nonexistent id in
id_category_default, because PrestaShop does not enforce that the default always points at something that still exists (issue #30219, "Default category should always be enforced on a product, even after category is deleted"). - A catalog import sends only partial category data for a row. The importer can overwrite
id_category_defaultfrom the file without validating it against the categories actually submitted in that same row, so the import finishes clean while quietly breaking the default (issue #32412, "Product importation overwrite the default category"). - A product is copied or duplicated in the back office, and the copy is later re-categorized without anyone touching the default explicitly, so it inherits the drift.
None of these throw an error anywhere in the admin or the API. The product looks perfectly normal until something downstream, like a canonical URL, a breadcrumb, or a feed export that reads id_category_default, quietly points somewhere wrong. See the citations at the end for the exact issues and docs.
The webservice will happily hand back an id_category_default that is not in associations.categories.category[] for the same product, and it will not tell you that is wrong. So the safe pattern is not to guess a replacement automatically. It is to compare the two fields you already get back from one product read, flag the mismatch, and let a human, or an explicitly confirmed auto-fix, choose the real default.
The fix, as a flow
We do not touch products automatically. We add a job that reads every active product from the webservice, runs a pure decision function that compares id_category_default against the product's actual associated category ids, and reports the ones where the default is not in that set. A corrective PUT is only sent when it is explicitly authorized with --auto-fix.
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 products and categories. 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 --auto-fix to write
// 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 --auto-fix to write
Read products with their full category associations
Call GET /api/products?display=full&output_format=JSON&filter[active]=1&limit=, paging with limit=<offset>,<count> for large catalogs, using HTTP Basic auth with the webservice key as the username and a blank password. For every product read id, id_category_default, and the nested associations.categories.category[] array, where each item has an id field.
import os, requests
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")
PAGE_SIZE = 50
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 active_products():
offset = 0
while True:
data = api_get("products", params={
"display": "full",
"filter[active]": 1,
"limit": f"{offset},{PAGE_SIZE}",
})
rows = data.get("products") or []
if not rows:
return
for row in rows:
yield row
offset += PAGE_SIZE
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;
const PAGE_SIZE = 50;
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* activeProducts() {
let offset = 0;
while (true) {
const data = await apiGet("products", {
display: "full",
"filter[active]": 1,
limit: `${offset},${PAGE_SIZE}`,
});
const rows = data.products || [];
if (!rows.length) return;
for (const row of rows) yield row;
offset += PAGE_SIZE;
}
}
Pull the assigned category ids out of a product row
The associations array nests each category under an id field. Pull those into a plain set of ints so the decision function never has to deal with the webservice's nesting shape.
def assigned_category_ids(product):
categories = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
return [int(row["id"]) for row in categories]
function assignedCategoryIds(product) {
const categories = product.associations?.categories?.category || [];
return categories.map((row) => Number(row.id));
}
Decide, with one pure function
Keep the decision in its own function that takes only id_category_default and the list of assigned category ids, no I/O at all. It returns None when the default is fine, and a small report dict when it is not, so the caller can log id_category_default and valid_category_ids together for a human to pick a sane replacement.
def find_default_category_drift(id_category_default, associated_category_ids):
valid_ids = sorted({int(x) for x in (associated_category_ids or [])})
if id_category_default is None:
return None
if int(id_category_default) in valid_ids:
return None
return {
"id_category_default": int(id_category_default),
"valid_category_ids": valid_ids,
}
export function findDefaultCategoryDrift(idCategoryDefault, associatedCategoryIds) {
const validIds = [...new Set((associatedCategoryIds || []).map(Number))].sort((a, b) => a - b);
if (idCategoryDefault == null) return null;
if (validIds.includes(Number(idCategoryDefault))) return null;
return {
idCategoryDefault: Number(idCategoryDefault),
validCategoryIds: validIds,
};
}
Report by default, repair only when explicitly confirmed
There is no deterministic correct replacement, since the merchant's real intent for which associated category should be default cannot be recovered from the data. So the default behavior is to log every flagged product's id, its stale id_category_default, and its valid_category_ids, and stop there. Only when DRY_RUN=false and --auto-fix is passed does the script pick the lowest id currently in the associations, falling back to the shop's root category id if associations is empty, and PUT the full product body back with only id_category_default corrected. The associations.categories block itself is never touched, since that reflects merchant intent, and PrestaShop's webservice PUT requires the full resource body or it can wipe other associations.
ROOT_CATEGORY_ID = int(os.environ.get("ROOT_CATEGORY_ID", "2"))
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 repair_default_category(product, drift):
# Always fetch-modify-PUT the complete product body, never hand-construct
# it, and never touch associations.categories, only id_category_default.
replacement = drift["valid_category_ids"][0] if drift["valid_category_ids"] else ROOT_CATEGORY_ID
body = dict(product)
body["id_category_default"] = replacement
api_put(f"products/{product['id']}", "product", body)
return replacement
const ROOT_CATEGORY_ID = Number(process.env.ROOT_CATEGORY_ID || 2);
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 repairDefaultCategory(product, drift) {
// Always fetch-modify-PUT the complete product body, never hand-construct
// it, and never touch associations.categories, only id_category_default.
const replacement = drift.validCategoryIds.length ? drift.validCategoryIds[0] : ROOT_CATEGORY_ID;
const body = { ...product, id_category_default: replacement };
await apiPut(`products/${product.id}`, "product", body);
return replacement;
}
Wire it together with a dry run guard
The loop ties every piece together: page through active products, run each through find_default_category_drift, log the flagged ones with their stale default and their valid category ids, and only call the repair when both DRY_RUN=false and --auto-fix are present for that run. Leave DRY_RUN on and review the report first, since picking the wrong default is a real content change on a live storefront.
Always start with DRY_RUN=true. Flagging is safe and reversible; overwriting id_category_default is a real content change on a live storefront. Only pass --auto-fix once a human has reviewed the flagged list, since the lowest associated id is a deterministic fallback, not a guarantee it matches what the merchant actually wanted as default.
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 corrective PUT to a specific product id when both the dry run flag is off and the auto-fix flag is explicitly passed.
"""Flag PrestaShop products whose default category is not among their assigned categories.
The backend product editor writes category associations to category_product
instantly, over AJAX, the moment a merchant checks or unchecks a box, without
waiting for Save. It never re-validates id_category_default at that moment. If
the category that was the default gets unchecked, or a category is deleted
store-wide, id_category_default keeps pointing at a category the product is no
longer linked to (PrestaShop/PrestaShop issues #28016 and #30219). Catalog
import can cause the same drift when only partial category data is sent for a
row and the importer overwrites id_category_default without validating it
against the submitted categories (issue #32412).
This script pages through active products from the webservice, runs a pure
decision function that flags any product where id_category_default is not in
its associations.categories.category[] ids, and reports by default. A
corrective PUT that resends the full product body with only
id_category_default corrected is only sent when DRY_RUN=false and --auto-fix
is passed, one product id at a time, using the lowest id currently in the
associations as the deterministic replacement.
Run on a schedule, or right after a bulk category edit or import. Safe to run
again and again.
"""
import os
import sys
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_default_category_drift")
PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ROOT_CATEGORY_ID = int(os.environ.get("ROOT_CATEGORY_ID", "2"))
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "50"))
AUTH = (PRESTASHOP_WS_KEY, "")
def find_default_category_drift(id_category_default, associated_category_ids):
"""Pure decision function, no I/O.
id_category_default: int | None, the product's id_category_default value.
associated_category_ids: list[int], the ids from
associations.categories.category[].
Returns None when the default is fine (it is present in the associated
ids, or there is no default to check). Returns a dict with
id_category_default (the stale value) and valid_category_ids (the sorted,
de-duplicated associations list) when the default is not among them, so a
human or an auto-fix step can pick a sane replacement.
"""
valid_ids = sorted({int(x) for x in (associated_category_ids or [])})
if id_category_default is None:
return None
if int(id_category_default) in valid_ids:
return None
return {
"id_category_default": int(id_category_default),
"valid_category_ids": valid_ids,
}
def assigned_category_ids(product):
"""Extract the assigned category ids out of a product's webservice body."""
categories = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
return [int(row["id"]) for row in categories]
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 active_products():
offset = 0
while True:
data = api_get("products", params={
"display": "full",
"filter[active]": 1,
"limit": f"{offset},{PAGE_SIZE}",
})
rows = data.get("products") or []
if not rows:
return
for row in rows:
yield row
offset += PAGE_SIZE
def category_still_exists(category_id):
"""Optional cross-check: a 404 confirms the deleted-category variant (issue #30219)."""
try:
api_get(f"categories/{category_id}")
return True
except requests.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
return False
raise
def repair_default_category(product, drift):
# Always fetch-modify-PUT the complete product body, never hand-construct
# it, and never touch associations.categories, only id_category_default.
replacement = drift["valid_category_ids"][0] if drift["valid_category_ids"] else ROOT_CATEGORY_ID
body = dict(product)
body["id_category_default"] = replacement
api_put(f"products/{product['id']}", "product", body)
return replacement
def run(auto_fix=False):
flagged = 0
repaired = 0
for product in active_products():
drift = find_default_category_drift(
product.get("id_category_default"), assigned_category_ids(product),
)
if drift is None:
continue
flagged += 1
log.warning(
"Product id=%s id_category_default=%s (stale) valid_category_ids=%s",
product.get("id"), drift["id_category_default"], drift["valid_category_ids"],
)
if not DRY_RUN and auto_fix:
replacement = repair_default_category(product, drift)
repaired += 1
log.info(
"Repaired product id=%s: id_category_default %s -> %s.",
product.get("id"), drift["id_category_default"], replacement,
)
log.info("Done. %d product(s) flagged, %d repaired.", flagged, repaired)
if __name__ == "__main__":
run(auto_fix="--auto-fix" in sys.argv)
/**
* Flag PrestaShop products whose default category is not among their assigned categories.
*
* The backend product editor writes category associations to category_product
* instantly, over AJAX, the moment a merchant checks or unchecks a box, without
* waiting for Save. It never re-validates id_category_default at that moment. If
* the category that was the default gets unchecked, or a category is deleted
* store-wide, id_category_default keeps pointing at a category the product is no
* longer linked to (PrestaShop/PrestaShop issues #28016 and #30219). Catalog
* import can cause the same drift when only partial category data is sent for a
* row and the importer overwrites id_category_default without validating it
* against the submitted categories (issue #32412).
*
* This script pages through active products from the webservice, runs a pure
* decision function that flags any product where id_category_default is not in
* its associations.categories.category[] ids, and reports by default. A
* corrective PUT that resends the full product body with only
* id_category_default corrected is only sent when DRY_RUN=false and --auto-fix
* is passed, one product id at a time, using the lowest id currently in the
* associations as the deterministic replacement.
*
* Guide: https://www.allanninal.dev/prestashop/default-category-not-in-assigned-categories/
*/
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";
const ROOT_CATEGORY_ID = Number(process.env.ROOT_CATEGORY_ID || 2);
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 50);
/**
* Pure decision function, no I/O.
*
* idCategoryDefault: number | null | undefined, the product's
* id_category_default value.
* associatedCategoryIds: number[], the ids from
* associations.categories.category[].
*
* Returns null when the default is fine (it is present in the associated
* ids, or there is no default to check). Returns an object with
* idCategoryDefault (the stale value) and validCategoryIds (the sorted,
* de-duplicated associations list) when the default is not among them, so a
* human or an auto-fix step can pick a sane replacement.
*/
export function findDefaultCategoryDrift(idCategoryDefault, associatedCategoryIds) {
const validIds = [...new Set((associatedCategoryIds || []).map(Number))].sort((a, b) => a - b);
if (idCategoryDefault == null) return null;
if (validIds.includes(Number(idCategoryDefault))) return null;
return {
idCategoryDefault: Number(idCategoryDefault),
validCategoryIds: validIds,
};
}
/** Extract the assigned category ids out of a product's webservice body. */
export function assignedCategoryIds(product) {
const categories = product.associations?.categories?.category || [];
return categories.map((row) => Number(row.id));
}
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, 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* activeProducts() {
let offset = 0;
while (true) {
const data = await apiGet("products", {
display: "full",
"filter[active]": 1,
limit: `${offset},${PAGE_SIZE}`,
});
const rows = data.products || [];
if (!rows.length) return;
for (const row of rows) yield row;
offset += PAGE_SIZE;
}
}
/** Optional cross-check: a 404 confirms the deleted-category variant (issue #30219). */
async function categoryStillExists(categoryId) {
try {
await apiGet(`categories/${categoryId}`);
return true;
} catch (err) {
if (String(err.message).includes("404")) return false;
throw err;
}
}
async function repairDefaultCategory(product, drift) {
// Always fetch-modify-PUT the complete product body, never hand-construct
// it, and never touch associations.categories, only id_category_default.
const replacement = drift.validCategoryIds.length ? drift.validCategoryIds[0] : ROOT_CATEGORY_ID;
const body = { ...product, id_category_default: replacement };
await apiPut(`products/${product.id}`, "product", body);
return replacement;
}
export async function run(autoFix = false) {
let flagged = 0;
let repaired = 0;
for await (const product of activeProducts()) {
const drift = findDefaultCategoryDrift(product.id_category_default, assignedCategoryIds(product));
if (drift === null) continue;
flagged++;
console.warn(`Product id=${product.id} id_category_default=${drift.idCategoryDefault} (stale) valid_category_ids=${JSON.stringify(drift.validCategoryIds)}`);
if (!DRY_RUN && autoFix) {
const replacement = await repairDefaultCategory(product, drift);
repaired++;
console.log(`Repaired product id=${product.id}: id_category_default ${drift.idCategoryDefault} -> ${replacement}.`);
}
}
console.log(`Done. ${flagged} product(s) flagged, ${repaired} repaired.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const autoFix = process.argv.includes("--auto-fix");
run(autoFix).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which products get reported, and it gates the only write path in the script. Because we kept find_default_category_drift pure, the tests need no network and no PrestaShop store. They just feed in plain values and check the answer.
from flag_default_category_drift import find_default_category_drift, assigned_category_ids
def test_flags_when_default_not_in_assigned_ids():
drift = find_default_category_drift(9, [1, 2, 3])
assert drift == {"id_category_default": 9, "valid_category_ids": [1, 2, 3]}
def test_no_flag_when_default_is_assigned():
assert find_default_category_drift(2, [1, 2, 3]) is None
def test_no_flag_when_default_is_none():
assert find_default_category_drift(None, [1, 2, 3]) is None
def test_flags_with_empty_valid_category_ids_when_assigned_list_is_empty():
drift = find_default_category_drift(5, [])
assert drift == {"id_category_default": 5, "valid_category_ids": []}
def test_valid_category_ids_are_sorted_and_deduplicated():
drift = find_default_category_drift(9, [3, 1, 2, 1, 3])
assert drift == {"id_category_default": 9, "valid_category_ids": [1, 2, 3]}
def test_accepts_string_ids_from_the_webservice():
assert find_default_category_drift("2", ["1", "2", "3"]) is None
drift = find_default_category_drift("9", ["1", "2", "3"])
assert drift == {"id_category_default": 9, "valid_category_ids": [1, 2, 3]}
def test_assigned_category_ids_reads_the_webservice_shape():
product = {"associations": {"categories": {"category": [{"id": "1"}, {"id": "2"}]}}}
assert assigned_category_ids(product) == [1, 2]
def test_assigned_category_ids_handles_missing_associations():
assert assigned_category_ids({}) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDefaultCategoryDrift, assignedCategoryIds } from "./flag-default-category-drift.js";
test("flags when default is not in assigned ids", () => {
const drift = findDefaultCategoryDrift(9, [1, 2, 3]);
assert.deepEqual(drift, { idCategoryDefault: 9, validCategoryIds: [1, 2, 3] });
});
test("no flag when default is assigned", () => {
assert.equal(findDefaultCategoryDrift(2, [1, 2, 3]), null);
});
test("no flag when default is null or undefined", () => {
assert.equal(findDefaultCategoryDrift(null, [1, 2, 3]), null);
assert.equal(findDefaultCategoryDrift(undefined, [1, 2, 3]), null);
});
test("flags with empty validCategoryIds when assigned list is empty", () => {
const drift = findDefaultCategoryDrift(5, []);
assert.deepEqual(drift, { idCategoryDefault: 5, validCategoryIds: [] });
});
test("validCategoryIds are sorted and deduplicated", () => {
const drift = findDefaultCategoryDrift(9, [3, 1, 2, 1, 3]);
assert.deepEqual(drift, { idCategoryDefault: 9, validCategoryIds: [1, 2, 3] });
});
test("accepts string ids from the webservice", () => {
assert.equal(findDefaultCategoryDrift("2", ["1", "2", "3"]), null);
const drift = findDefaultCategoryDrift("9", ["1", "2", "3"]);
assert.deepEqual(drift, { idCategoryDefault: 9, validCategoryIds: [1, 2, 3] });
});
test("assignedCategoryIds reads the webservice shape", () => {
const product = { associations: { categories: { category: [{ id: "1" }, { id: "2" }] } } };
assert.deepEqual(assignedCategoryIds(product), [1, 2]);
});
test("assignedCategoryIds handles missing associations", () => {
assert.deepEqual(assignedCategoryIds({}), []);
});
Case studies
A whole season's products lost their canonical link
A fashion store retired last season's category at the end of a quarter and unchecked it on every product in bulk, one by one, moving them into the new season's category. Nobody thought to also open the default category dropdown on each product, so dozens of items kept the deleted category as their default long after it stopped existing.
Running the report script found every affected product in minutes, listing the stale id alongside the categories each product actually still belonged to. The merchandising team picked the right default for each from that shortlist instead of guessing from a blank product page.
A partial feed quietly broke defaults on a supplier sync
A distributor's nightly feed only sent a subset of category data for updated SKUs, since most of the file was price and stock changes. PrestaShop's importer used that partial data to update id_category_default anyway, without checking it against the categories the row actually specified, and hundreds of products ended up with a default that was never part of their assigned set.
The team ran the script in dry run right after each nightly import, caught the drift the same day it happened instead of weeks later, and used --auto-fix once they confirmed the deterministic lowest-id replacement matched the store's usual convention for that supplier's SKUs.
After this runs on a schedule, or right after a bulk recategorization or import, every product's default category is checked against what it is actually assigned to, and nothing silently points at a category that no longer applies. The report gives you exactly the stale id and the valid alternatives, so a human can pick with confidence, and --auto-fix is there for when a deterministic fallback is good enough and confirmed in advance.
FAQ
Why does a PrestaShop product's default category point at a category it is not assigned to?
The backend product editor writes category associations to the category_product table instantly through AJAX the moment a merchant checks or unchecks a box, without waiting for Save. It never re-checks id_category_default at that moment, so if the box for the current default category gets unchecked, or that category is deleted store-wide, id_category_default keeps pointing at a category the product is no longer linked to.
Can catalog import cause this same default category drift?
Yes. When an import file sends only partial category data, PrestaShop's importer can overwrite id_category_default with a value that was never validated against the categories actually submitted for that row, so the import finishes without error while leaving the default pointing outside the assigned set.
What is the safe way to fix a product whose default category is not in its assigned categories?
There is no deterministic correct replacement, because the data cannot tell you which associated category the merchant actually wants as default. The safe pattern is to flag and report every affected product by default, and only repair automatically when a human explicitly opts in, using the lowest id in the product's current associations as the deterministic fallback and resending the full product body on the PUT.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Default category doesn't update properly, issue #28016. github.com/PrestaShop/PrestaShop/issues/28016
- PrestaShop GitHub: Default category should always be enforced on a product, even after category is deleted, issue #30219. github.com/PrestaShop/PrestaShop/issues/30219
- PrestaShop GitHub: Product importation overwrite the default category, issue #32412. github.com/PrestaShop/PrestaShop/issues/32412
On the solution:
- PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products
- PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories
- PrestaShop Developer Documentation: The PrestaShop Webservice API. devdocs.prestashop-project.org/8/webservice
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.
Did this untangle your default categories?
If this saved you from a broken canonical URL or a wrong breadcrumb, 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