Skip to content

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.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
An online store on a laptop
Photo by charlesdeluvio on Unsplash
The short answer

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.

Merchant unchecks the default category box AJAX saves instantly category_product row is deleted right away no re-check happens id_category_default column is untouched still the old value Default points at nothing
The checkbox write and the default value live on two different timelines. Unchecking the box never triggers a check on whether the default is still valid.

Why it happens

This is confirmed core behavior, not a one-off bug in a single store. A few concrete ways it shows up:

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 key insight

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.

List active products GET /api/products?display=full Read default and categories id_category_default, associations find_default_category_drift() pure decision, default vs assigned ids Default in assigned set? no, flag yes, skip Nothing to do Report or fix
The job only ever reports by default. A corrective PUT with a deterministic replacement default is only sent when DRY_RUN is explicitly off and --auto-fix is passed.

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 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.

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 --auto-fix to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, change to false to allow --auto-fix to write
2

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.

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

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.

step3.py
def assigned_category_ids(product):
    categories = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
    return [int(row["id"]) for row in categories]
step3.js
function assignedCategoryIds(product) {
  const categories = product.associations?.categories?.category || [];
  return categories.map((row) => Number(row.id));
}
4

Decide, with one pure function

Keep the decision in its own function that takes only 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.

decide.py
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,
    }
decide.js
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,
  };
}
5

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.

apply.py
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
apply.js
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;
}
6

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.

Run it safe

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.

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.
flag_default_category_drift.py
"""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-default-category-drift.js
/**
 * 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.

test_default_category_drift.py
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({}) == []
default-category-drift.test.js
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

Seasonal recategorization

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.

Catalog import

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.

What good looks like

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:

  1. PrestaShop GitHub: Default category doesn't update properly, issue #28016. github.com/PrestaShop/PrestaShop/issues/28016
  2. PrestaShop GitHub: Default category should always be enforced on a product, even after category is deleted, issue #30219. github.com/PrestaShop/PrestaShop/issues/30219
  3. PrestaShop GitHub: Product importation overwrite the default category, issue #32412. github.com/PrestaShop/PrestaShop/issues/32412

On the solution:

  1. PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products
  2. PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories
  3. PrestaShop Developer Documentation: 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.

Contact me on LinkedIn

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

Back to all PrestaShop field notes