Skip to content

Reconciler

Product default category silently changed or lost after a catalog import

You run a CSV import to update prices or stock, nothing errors, and the job finishes green. Days later a merchant notices a product's breadcrumb points at Home instead of the deep category it always lived in, or the storefront canonical URL looks wrong. Nobody edited that product by hand. The import did it, quietly, because PrestaShop's importer builds every row from scratch and does not know what the default category used to be. Here is why that happens and a small reconciler that catches it before a customer does.

Python and Node.js PrestaShop Webservice API Safe by default (snapshot and compare)
A stack of papers
Photo by Kelly Sikkema on Unsplash
The short answer

PrestaShop's product CSV importer (AdminImportController) builds each row independently from the Category column. When multiple category ids or names are comma separated, it has historically picked the first one in the list, or in older force ID flows silently kept or reset id_category_default to whatever the file's ordering implies, rather than preserving the product's previously configured default. Because id_category_default and the associations.categories list are loosely coupled in the importer, a re-export and re-import round trip, or a partial update file that omits the category column entirely, can shift the default to category id 2 (Home) or to some other unintended category. Multistore compounds this since the default is shop specific. Snapshot every product's id_category_default before the import, run the same read after, and flag any product whose default changed without an explicit category edit in the file, or whose new default is the root category, or whose default is no longer even in its own associations list. Only repair once an operator has confirmed the snapshot is authoritative. Full code, tests, and citations are below.

The problem in plain words

Every product row in a PrestaShop import file carries a Category column, usually a list of category ids or full category paths separated by commas. The importer reads that column, resolves each entry to a category, links the product to all of them, and picks one to be the default, stored on the product as the id_category_default column.

The trouble is that the importer treats this as a fresh write, not a merge. It does not first ask the product what its default already was and try to keep that choice if it is still one of the listed categories. It just looks at the Category column for that row, in isolation, and decides the default from what it sees there, which historically has meant taking the first id in a comma separated list. If your export tool reordered that list, if the file only carries a partial category update, or if the column is empty or malformed for a row, the importer can end up writing a default that has nothing to do with what the merchant had carefully set before. Nothing in the import summary tells you this happened. The row imports clean.

CSV row imports Category column read for this row only Prior default ignored no lookup of what the product had before no merge happens First id in list, or Home (id 2), becomes the new default Breadcrumb and URL break
The importer decides the default from the file's Category column alone. It never checks what the product's default already was, so a re-export, a reorder, or a partial file can quietly overwrite it.

Why it happens

This is a long-standing, confirmed pattern in the importer's design, not a one-off bug in a single store. A few concrete ways it shows up:

None of this raises an error in the import summary. The batch reports success, because from the importer's point of view every row wrote a valid category assignment. It just was not the assignment the merchant had before. See the citations at the end for the exact issues and docs.

The key insight

Marking a category as default and having a category actually reset to Home look completely different from the merchant's chair, but they are both just a changed id_category_default from the API's point of view. You cannot tell corruption from an intentional re-categorization by looking at the after state alone. So the safe pattern is not to guess. It is to snapshot the before state, compare it to the after state, and use the shape of the change, especially a reset to the root category, or a default that fell out of the product's own associations, as the signal that something needs a human's attention.

The fix, as a flow

We do not touch the import itself. We wrap it with a reconciler: snapshot every affected product's id_category_default before the batch runs, let the import do its work, then re-read the same products and run a pure decision function that classifies each change as unchanged, a flag for manual review, or a safe repair candidate. Repair only ever restores the snapshotted value, and only when explicitly confirmed.

Snapshot before import GET id_category_default Import runs CSV batch as usual Re-read after import same fields, same products decide_category_repair() pure, before vs after vs root none no write flag human review repair confirmed only
The reconciler never guesses. It restores only what the pre-import snapshot proved was there, and only after DRY_RUN is explicitly off and an operator has confirmed the snapshot.

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 shops. 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 only after an operator confirms
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 only after an operator confirms
2

Snapshot id_category_default before the import runs

Before the CSV batch starts, read every product you are about to touch and record its current default category. In multistore, loop the same read per shop id, since the default is shop scoped. Store this snapshot somewhere the reconciler can read it back after the import, keyed by product id and, in multistore, by shop id too.

step2.py
import os, requests

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

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

def read_product_default(product_id, id_shop=None):
    params = {
        "filter[id]": product_id,
        "display": "[id,id_category_default,associations.categories]",
    }
    if id_shop is not None:
        params["id_shop"] = id_shop
    rows = api_get("products", params=params).get("products") or []
    return rows[0] if rows else None

def snapshot_products(product_ids, shop_ids=None):
    snapshot = {}
    for pid in product_ids:
        for sid in (shop_ids or [None]):
            row = read_product_default(pid, id_shop=sid)
            if row is not None:
                snapshot[(pid, sid)] = int(row["id_category_default"])
    return snapshot
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

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

async function readProductDefault(productId, idShop) {
  const params = {
    "filter[id]": productId,
    display: "[id,id_category_default,associations.categories]",
  };
  if (idShop != null) params.id_shop = idShop;
  const data = await apiGet("products", params);
  const rows = data.products || [];
  return rows[0] || null;
}

async function snapshotProducts(productIds, shopIds = [null]) {
  const snapshot = new Map();
  for (const pid of productIds) {
    for (const sid of shopIds) {
      const row = await readProductDefault(pid, sid);
      if (row) snapshot.set(`${pid}:${sid}`, Number(row.id_category_default));
    }
  }
  return snapshot;
}
3

Re-read the same products after the import

Run the import as usual. Afterward, call the exact same read for the exact same product and shop ids. Also pull associations.categories.category[].id for each product, since a default category id that is not even in that list is itself a corruption signal, worse than a default that simply changed.

step3.py
def category_ids_of(row):
    categories = ((row.get("associations") or {}).get("categories") or {}).get("category") or []
    return [int(c["id"]) for c in categories]

def read_post_import_state(product_ids, shop_ids=None):
    state = {}
    for pid in product_ids:
        for sid in (shop_ids or [None]):
            row = read_product_default(pid, id_shop=sid)
            if row is not None:
                state[(pid, sid)] = {
                    "id_category_default": int(row["id_category_default"]),
                    "category_ids": category_ids_of(row),
                }
    return state
step3.js
function categoryIdsOf(row) {
  const categories = row.associations?.categories?.category || [];
  return categories.map((c) => Number(c.id));
}

async function readPostImportState(productIds, shopIds = [null]) {
  const state = new Map();
  for (const pid of productIds) {
    for (const sid of shopIds) {
      const row = await readProductDefault(pid, sid);
      if (row) {
        state.set(`${pid}:${sid}`, {
          idCategoryDefault: Number(row.id_category_default),
          categoryIds: categoryIdsOf(row),
        });
      }
    }
  }
  return state;
}
4

Decide, with one pure function

Keep the whole heuristic in a function that takes plain ids and lists, no I/O at all. It compares the pre-import default to the post-import default and to the post-import associations list, and returns one of three actions: none when nothing changed, flag when a human needs to look, or repair only for the classic "reset to Home" signature, where the prior default is gone and the new default is the root category.

decide.py
def decide_category_repair(product_id, id_shop, pre_import_default, post_import_default,
                            post_import_category_ids, root_category_id=2):
    post_ids = [int(x) for x in (post_import_category_ids or [])]
    pre_default = int(pre_import_default)
    post_default = int(post_import_default)

    if post_default == pre_default:
        return {"product_id": product_id, "id_shop": id_shop, "action": "none",
                "reason": "default unchanged", "restore_to": None}

    if pre_default not in post_ids:
        return {"product_id": product_id, "id_shop": id_shop, "action": "flag",
                "reason": "prior default is no longer in associations, needs manual review",
                "restore_to": None}

    if post_default == int(root_category_id) and pre_default != int(root_category_id):
        return {"product_id": product_id, "id_shop": id_shop, "action": "repair",
                "reason": "reset to Home/root category, classic import corruption",
                "restore_to": pre_default}

    return {"product_id": product_id, "id_shop": id_shop, "action": "flag",
            "reason": "ambiguous change, surface for human confirmation",
            "restore_to": pre_default}
decide.js
export function decideCategoryRepair(productId, idShop, preImportDefault, postImportDefault,
                                      postImportCategoryIds, rootCategoryId = 2) {
  const postIds = (postImportCategoryIds || []).map(Number);
  const preDefault = Number(preImportDefault);
  const postDefault = Number(postImportDefault);
  const root = Number(rootCategoryId);

  if (postDefault === preDefault) {
    return { productId, idShop, action: "none", reason: "default unchanged", restoreTo: null };
  }
  if (!postIds.includes(preDefault)) {
    return {
      productId, idShop, action: "flag",
      reason: "prior default is no longer in associations, needs manual review",
      restoreTo: null,
    };
  }
  if (postDefault === root && preDefault !== root) {
    return {
      productId, idShop, action: "repair",
      reason: "reset to Home/root category, classic import corruption",
      restoreTo: preDefault,
    };
  }
  return {
    productId, idShop, action: "flag",
    reason: "ambiguous change, surface for human confirmation",
    restoreTo: preDefault,
  };
}
5

Repair only the confirmed cases, scoped to the right shop

When an operator has confirmed the snapshot is authoritative and DRY_RUN=false, PUT the affected product with only id_category_default reset to the snapshotted value, and make sure the category id is still present in associations.categories, adding it back first if it was dropped. In multistore, scope the PUT with ?id_shop={id_shop} for each affected shop, and never repair in the "all shops" context when the corruption was shop specific.

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

def restore_default_category(product_id, id_shop, restore_to):
    params = {"id_shop": id_shop} if id_shop is not None else None
    current = api_get(f"products/{product_id}", params=params)["product"]
    body = dict(current)
    body["id_category_default"] = restore_to
    categories = ((body.get("associations") or {}).get("categories") or {}).get("category") or []
    ids = {int(c["id"]) for c in categories}
    if restore_to not in ids:
        categories.append({"id": restore_to})
        body.setdefault("associations", {}).setdefault("categories", {})["category"] = categories
    return api_put(f"products/{product_id}", "product", body, params=params)
apply.js
async function apiPut(path, resourceKey, body, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ [resourceKey]: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
  return res.json();
}

async function restoreDefaultCategory(productId, idShop, restoreTo) {
  const params = idShop != null ? { id_shop: idShop } : {};
  const current = (await apiGet(`products/${productId}`, params)).product;
  const body = { ...current, id_category_default: restoreTo };
  const categories = body.associations?.categories?.category || [];
  const ids = new Set(categories.map((c) => Number(c.id)));
  if (!ids.has(restoreTo)) {
    categories.push({ id: restoreTo });
    body.associations = { ...(body.associations || {}), categories: { category: categories } };
  }
  return apiPut(`products/${productId}`, "product", body, params);
}
6

Wire it together with a dry run guard

The loop runs decide_category_repair for every snapshotted product and shop pair, logs the flagged and repaired ones with the diff of old versus new default, and only calls the restoring PUT when both DRY_RUN=false and the action is repair. Products flagged as ambiguous or as a dropped association are never auto-written, only reported. Skip any product where the snapshot itself is missing.

Run it safe

Always start with DRY_RUN=true. Only flip it off once an operator has confirmed the pre-import snapshot is authoritative, since the reconciler restores exactly what the snapshot said and nothing else. If the snapshot is missing or ambiguous for a product, skip it rather than guess.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, snapshots before an import, compares after, reports by default, and only restores the classic reset-to-root case when DRY_RUN is explicitly off.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
reconcile_import_default_category.py
"""Catch and safely repair a PrestaShop product default category overwritten by import.

PrestaShop's product CSV importer (AdminImportController) builds each row
independently from the Category column. When multiple category ids or names
are comma separated it has historically picked the first one in the list, or
in older "Force ID" flows silently reset id_category_default to whatever the
file's ordering implies, rather than preserving the product's prior default
(PrestaShop/PrestaShop issues #27938 and #10871). A partial update file that
omits the category column can cause the same overwrite (issue #32412). In
multistore, the default category is scoped per shop, so an import run without
shop scoping can overwrite the wrong shop's default.

This script snapshots every affected product's id_category_default before an
import, re-reads the same products after, and runs a pure decision function
that classifies each product as unchanged, needing manual review (flag), or a
safe automatic repair candidate (the classic "reset to Home" signature). A
restoring PUT is only sent when DRY_RUN=false, scoped per shop, and only for
the repair action. Ambiguous changes and dropped associations are always
flagged, never auto-written.

Run right before and right after a catalog import. Safe to run again and again.
"""
import os
import logging
import requests

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

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"))
AUTH = (PRESTASHOP_WS_KEY, "")


def decide_category_repair(product_id, id_shop, pre_import_default, post_import_default,
                            post_import_category_ids, root_category_id=2):
    """Pure decision function, no I/O.

    product_id: int, the product being checked.
    id_shop: int | None, the shop context, or None for a single-shop install.
    pre_import_default: int, id_category_default read before the import.
    post_import_default: int, id_category_default read after the import.
    post_import_category_ids: list[int], associations.categories.category[].id
        read after the import.
    root_category_id: int, the store's root/Home category id, default 2.

    Returns a dict {product_id, id_shop, action, reason, restore_to}. action is
    one of "none", "flag", "repair". restore_to is the pre_import_default when
    a repair or a flagged-for-confirmation change is proposed, otherwise None.
    """
    post_ids = [int(x) for x in (post_import_category_ids or [])]
    pre_default = int(pre_import_default)
    post_default = int(post_import_default)
    root = int(root_category_id)

    if post_default == pre_default:
        return {
            "product_id": product_id, "id_shop": id_shop, "action": "none",
            "reason": "default unchanged", "restore_to": None,
        }

    if pre_default not in post_ids:
        return {
            "product_id": product_id, "id_shop": id_shop, "action": "flag",
            "reason": "prior default is no longer in associations, needs manual review",
            "restore_to": None,
        }

    if post_default == root and pre_default != root:
        return {
            "product_id": product_id, "id_shop": id_shop, "action": "repair",
            "reason": "reset to Home/root category, classic import corruption",
            "restore_to": pre_default,
        }

    return {
        "product_id": product_id, "id_shop": id_shop, "action": "flag",
        "reason": "ambiguous change, surface for human confirmation",
        "restore_to": pre_default,
    }


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


def api_put(path, resource_key, body, 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 category_ids_of(row):
    categories = ((row.get("associations") or {}).get("categories") or {}).get("category") or []
    return [int(c["id"]) for c in categories]


def read_product_state(product_id, id_shop=None):
    params = {
        "filter[id]": product_id,
        "display": "[id,id_category_default,associations.categories]",
    }
    if id_shop is not None:
        params["id_shop"] = id_shop
    rows = api_get("products", params=params).get("products") or []
    if not rows:
        return None
    row = rows[0]
    return {
        "id_category_default": int(row["id_category_default"]),
        "category_ids": category_ids_of(row),
    }


def snapshot(product_ids, shop_ids=None):
    result = {}
    for pid in product_ids:
        for sid in (shop_ids or [None]):
            state = read_product_state(pid, id_shop=sid)
            if state is not None:
                result[(pid, sid)] = state["id_category_default"]
    return result


def restore_default_category(product_id, id_shop, restore_to):
    params = {"id_shop": id_shop} if id_shop is not None else None
    current = api_get(f"products/{product_id}", params=params)["product"]
    body = dict(current)
    body["id_category_default"] = restore_to
    categories = ((body.get("associations") or {}).get("categories") or {}).get("category") or []
    ids = {int(c["id"]) for c in categories}
    if restore_to not in ids:
        categories.append({"id": restore_to})
        body.setdefault("associations", {}).setdefault("categories", {})["category"] = categories
    return api_put(f"products/{product_id}", "product", body, params=params)


def reconcile(pre_snapshot, product_ids, shop_ids=None):
    flagged = 0
    repaired = 0
    for pid in product_ids:
        for sid in (shop_ids or [None]):
            pre_default = pre_snapshot.get((pid, sid))
            if pre_default is None:
                log.info("Skipping product %s (shop %s): no pre-import snapshot.", pid, sid)
                continue
            post_state = read_product_state(pid, id_shop=sid)
            if post_state is None:
                log.warning("Skipping product %s (shop %s): not found after import.", pid, sid)
                continue
            decision = decide_category_repair(
                pid, sid, pre_default, post_state["id_category_default"],
                post_state["category_ids"], ROOT_CATEGORY_ID,
            )
            if decision["action"] == "none":
                continue
            if decision["action"] == "flag":
                flagged += 1
                log.warning(
                    "FLAG product=%s shop=%s: %s (pre=%s post=%s)",
                    pid, sid, decision["reason"], pre_default, post_state["id_category_default"],
                )
                continue
            # action == "repair"
            log.warning(
                "REPAIR candidate product=%s shop=%s: %s (pre=%s post=%s)",
                pid, sid, decision["reason"], pre_default, post_state["id_category_default"],
            )
            if not DRY_RUN:
                restore_default_category(pid, sid, decision["restore_to"])
                repaired += 1
                log.info("Repaired product=%s shop=%s: restored id_category_default=%s.",
                          pid, sid, decision["restore_to"])
    log.info("Done. %d flagged for review, %d repaired.", flagged, repaired)


def run(product_ids, shop_ids=None):
    pre_snapshot = snapshot(product_ids, shop_ids=shop_ids)
    log.info("Snapshotted %d product/shop pair(s) before import.", len(pre_snapshot))
    return pre_snapshot


if __name__ == "__main__":
    # Typical usage: call run() before the import to get and persist the
    # snapshot, run your import, then call reconcile(saved_snapshot, ids) after.
    ids = [int(x) for x in os.environ.get("PRODUCT_IDS", "").split(",") if x.strip()]
    if not ids:
        log.info("Set PRODUCT_IDS to a comma separated list of product ids to check.")
    else:
        snap = run(ids)
        reconcile(snap, ids)
reconcile-import-default-category.js
/**
 * Catch and safely repair a PrestaShop product default category overwritten by import.
 *
 * PrestaShop's product CSV importer (AdminImportController) builds each row
 * independently from the Category column. When multiple category ids or names
 * are comma separated it has historically picked the first one in the list, or
 * in older "Force ID" flows silently reset id_category_default to whatever the
 * file's ordering implies, rather than preserving the product's prior default
 * (PrestaShop/PrestaShop issues #27938 and #10871). A partial update file that
 * omits the category column can cause the same overwrite (issue #32412). In
 * multistore, the default category is scoped per shop, so an import run
 * without shop scoping can overwrite the wrong shop's default.
 *
 * This script snapshots every affected product's id_category_default before an
 * import, re-reads the same products after, and runs a pure decision function
 * that classifies each product as unchanged, needing manual review (flag), or
 * a safe automatic repair candidate (the classic "reset to Home" signature). A
 * restoring PUT is only sent when DRY_RUN=false, scoped per shop, and only for
 * the repair action. Ambiguous changes and dropped associations are always
 * flagged, never auto-written.
 *
 * Guide: https://www.allanninal.dev/prestashop/default-category-overwritten-by-import/
 */
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);

/**
 * Pure decision function, no I/O.
 *
 * productId: int, the product being checked.
 * idShop: number | null, the shop context, or null for a single-shop install.
 * preImportDefault: number, id_category_default read before the import.
 * postImportDefault: number, id_category_default read after the import.
 * postImportCategoryIds: number[], associations.categories.category[].id read
 *   after the import.
 * rootCategoryId: number, the store's root/Home category id, default 2.
 *
 * Returns { productId, idShop, action, reason, restoreTo }. action is one of
 * "none", "flag", "repair". restoreTo is preImportDefault when a repair or a
 * flagged-for-confirmation change is proposed, otherwise null.
 */
export function decideCategoryRepair(productId, idShop, preImportDefault, postImportDefault,
                                      postImportCategoryIds, rootCategoryId = 2) {
  const postIds = (postImportCategoryIds || []).map(Number);
  const preDefault = Number(preImportDefault);
  const postDefault = Number(postImportDefault);
  const root = Number(rootCategoryId);

  if (postDefault === preDefault) {
    return { productId, idShop, action: "none", reason: "default unchanged", restoreTo: null };
  }
  if (!postIds.includes(preDefault)) {
    return {
      productId, idShop, action: "flag",
      reason: "prior default is no longer in associations, needs manual review",
      restoreTo: null,
    };
  }
  if (postDefault === root && preDefault !== root) {
    return {
      productId, idShop, action: "repair",
      reason: "reset to Home/root category, classic import corruption",
      restoreTo: preDefault,
    };
  }
  return {
    productId, idShop, action: "flag",
    reason: "ambiguous change, surface for human confirmation",
    restoreTo: preDefault,
  };
}

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();
}

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

async function readProductState(productId, idShop) {
  const params = {
    "filter[id]": productId,
    display: "[id,id_category_default,associations.categories]",
  };
  if (idShop != null) params.id_shop = idShop;
  const data = await apiGet("products", params);
  const rows = data.products || [];
  if (!rows.length) return null;
  const row = rows[0];
  return {
    idCategoryDefault: Number(row.id_category_default),
    categoryIds: categoryIdsOf(row),
  };
}

export async function snapshot(productIds, shopIds = [null]) {
  const result = new Map();
  for (const pid of productIds) {
    for (const sid of shopIds) {
      const state = await readProductState(pid, sid);
      if (state) result.set(`${pid}:${sid}`, state.idCategoryDefault);
    }
  }
  return result;
}

async function restoreDefaultCategory(productId, idShop, restoreTo) {
  const params = idShop != null ? { id_shop: idShop } : {};
  const current = (await apiGet(`products/${productId}`, params)).product;
  const body = { ...current, id_category_default: restoreTo };
  const categories = body.associations?.categories?.category || [];
  const ids = new Set(categories.map((c) => Number(c.id)));
  if (!ids.has(restoreTo)) {
    categories.push({ id: restoreTo });
    body.associations = { ...(body.associations || {}), categories: { category: categories } };
  }
  return apiPut(`products/${productId}`, "product", body, params);
}

export async function reconcile(preSnapshot, productIds, shopIds = [null]) {
  let flagged = 0;
  let repaired = 0;
  for (const pid of productIds) {
    for (const sid of shopIds) {
      const key = `${pid}:${sid}`;
      const preDefault = preSnapshot.get(key);
      if (preDefault == null) {
        console.log(`Skipping product ${pid} (shop ${sid}): no pre-import snapshot.`);
        continue;
      }
      const postState = await readProductState(pid, sid);
      if (!postState) {
        console.warn(`Skipping product ${pid} (shop ${sid}): not found after import.`);
        continue;
      }
      const decision = decideCategoryRepair(
        pid, sid, preDefault, postState.idCategoryDefault, postState.categoryIds, ROOT_CATEGORY_ID,
      );
      if (decision.action === "none") continue;
      if (decision.action === "flag") {
        flagged++;
        console.warn(`FLAG product=${pid} shop=${sid}: ${decision.reason} (pre=${preDefault} post=${postState.idCategoryDefault})`);
        continue;
      }
      console.warn(`REPAIR candidate product=${pid} shop=${sid}: ${decision.reason} (pre=${preDefault} post=${postState.idCategoryDefault})`);
      if (!DRY_RUN) {
        await restoreDefaultCategory(pid, sid, decision.restoreTo);
        repaired++;
        console.log(`Repaired product=${pid} shop=${sid}: restored id_category_default=${decision.restoreTo}.`);
      }
    }
  }
  console.log(`Done. ${flagged} flagged for review, ${repaired} repaired.`);
}

export async function run(productIds, shopIds = [null]) {
  const preSnapshot = await snapshot(productIds, shopIds);
  console.log(`Snapshotted ${preSnapshot.size} product/shop pair(s) before import.`);
  return preSnapshot;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const ids = (process.env.PRODUCT_IDS || "")
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean)
    .map(Number);
  if (!ids.length) {
    console.log("Set PRODUCT_IDS to a comma separated list of product ids to check.");
  } else {
    run(ids)
      .then((snap) => reconcile(snap, ids))
      .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 auto-repaired and which get flagged for a human, and it is the only gate on the write path. Because we kept decide_category_repair pure, the tests need no network and no PrestaShop store. They just feed in plain ids and lists and check the classification.

test_default_category_import.py
from reconcile_import_default_category import decide_category_repair


def test_unchanged_default_is_none():
    result = decide_category_repair(1, None, 5, 5, [1, 5, 9])
    assert result["action"] == "none"
    assert result["restore_to"] is None


def test_reset_to_home_is_repair():
    result = decide_category_repair(1, None, 9, 2, [1, 2, 9])
    assert result["action"] == "repair"
    assert result["restore_to"] == 9


def test_dropped_association_is_flag_not_repair():
    result = decide_category_repair(1, None, 9, 2, [1, 2])
    assert result["action"] == "flag"
    assert result["restore_to"] is None


def test_ambiguous_shift_is_flag_with_restore_hint():
    result = decide_category_repair(1, None, 9, 12, [1, 9, 12])
    assert result["action"] == "flag"
    assert result["restore_to"] == 9


def test_already_home_moving_to_another_category_is_flag():
    # pre_import_default == root_category_id, so the "reset to Home" branch
    # cannot apply even though post_default changed.
    result = decide_category_repair(1, None, 2, 12, [1, 2, 12])
    assert result["action"] == "flag"
    assert result["restore_to"] == 2


def test_multistore_pair_is_carried_through_untouched():
    result = decide_category_repair(7, 3, 9, 2, [1, 2, 9])
    assert result["product_id"] == 7
    assert result["id_shop"] == 3
    assert result["action"] == "repair"


def test_custom_root_category_id_is_respected():
    result = decide_category_repair(1, None, 9, 20, [1, 9, 20], root_category_id=20)
    assert result["action"] == "repair"
    assert result["restore_to"] == 9
default-category-import.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideCategoryRepair } from "./reconcile-import-default-category.js";

test("unchanged default is none", () => {
  const result = decideCategoryRepair(1, null, 5, 5, [1, 5, 9]);
  assert.equal(result.action, "none");
  assert.equal(result.restoreTo, null);
});

test("reset to Home is repair", () => {
  const result = decideCategoryRepair(1, null, 9, 2, [1, 2, 9]);
  assert.equal(result.action, "repair");
  assert.equal(result.restoreTo, 9);
});

test("dropped association is flag not repair", () => {
  const result = decideCategoryRepair(1, null, 9, 2, [1, 2]);
  assert.equal(result.action, "flag");
  assert.equal(result.restoreTo, null);
});

test("ambiguous shift is flag with restore hint", () => {
  const result = decideCategoryRepair(1, null, 9, 12, [1, 9, 12]);
  assert.equal(result.action, "flag");
  assert.equal(result.restoreTo, 9);
});

test("already home moving to another category is flag", () => {
  const result = decideCategoryRepair(1, null, 2, 12, [1, 2, 12]);
  assert.equal(result.action, "flag");
  assert.equal(result.restoreTo, 2);
});

test("multistore pair is carried through untouched", () => {
  const result = decideCategoryRepair(7, 3, 9, 2, [1, 2, 9]);
  assert.equal(result.productId, 7);
  assert.equal(result.idShop, 3);
  assert.equal(result.action, "repair");
});

test("custom root category id is respected", () => {
  const result = decideCategoryRepair(1, null, 9, 20, [1, 9, 20], 20);
  assert.equal(result.action, "repair");
  assert.equal(result.restoreTo, 9);
});

Case studies

Re-export and re-import

A pricing update quietly reset a thousand defaults

A homeware retailer exported the full catalog to update seasonal pricing in a spreadsheet, then re-imported the same file. The export tool had reordered the comma separated Category column for every row, alphabetically instead of by the original association order. The importer picked the first id in each row's new order as the default, and about a thousand products ended up with a different, often shallower, default category than before.

Nothing failed. The import summary said every row succeeded. The team only noticed when category page counts on the storefront looked off. Running the reconciler with a snapshot taken right before that import would have caught every one of them in the same report, with the exact prior value to restore.

Multistore

A price-only import broke navigation on one shop only

A multistore install ran a nightly supplier feed that only updated stock and price columns, with the Category column left blank for unchanged products. The import ran in an "all shops" context rather than scoped to the one shop the feed was meant for, and a batch of products had their default category reset for a shop nobody intended to touch, while the other shops in the install were unaffected.

Because the reconciler snapshots and re-reads per shop id, it caught the mismatch immediately: the default had changed for one id_shop and not the others, on products where the Category column was not even present in that day's feed. The team scoped the next feed run to the correct shop and used the confirmed snapshot to restore just that shop's defaults.

What good looks like

After wrapping every catalog import with a snapshot and a post-import comparison, a re-export, a reordered category list, or a partial feed can no longer silently move a product's default category. The reconciler tells you exactly which products changed, whether the change looks like classic Home-reset corruption or something a human should confirm, and it only ever restores what the snapshot proved was there before, scoped to the right shop.

FAQ

Why does a catalog import change a product's default category in PrestaShop?

PrestaShop's product CSV importer builds each row independently from the Category column. When multiple category ids or names are comma separated it has historically picked the first one in the list, or in older force ID flows kept whatever the file implies, instead of preserving the product's previously configured id_category_default. A re-export and re-import round trip, or a partial file that omits the category column, can shift the default to the wrong category without any error.

Why does the default sometimes get reset to Home (category id 2) specifically?

Category id 2 is the store root or Home category in a standard PrestaShop install. When the importer cannot cleanly resolve which category in a comma separated list should be the default, or the category column is missing or malformed for that row, it can fall back to the root category rather than leaving the prior default untouched, which is why Home is the most common wrong value seen after an import.

Is it safe to auto-fix a default category that changed after an import?

No, not blindly. A legitimate re-categorization can look identical to corruption from the outside, since both are just a changed id_category_default. The safe pattern is to snapshot every product's default category before the import, compare it after, and only repair automatically once an operator has confirmed the pre-import snapshot is authoritative, restoring the value with a scoped PUT rather than guessing.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Importing products changes the default category, issue #27938. github.com/PrestaShop/PrestaShop/issues/27938
  2. PrestaShop GitHub: Product importation overwrite the default category, issue #32412. github.com/PrestaShop/PrestaShop/issues/32412
  3. PrestaShop GitHub: Allow product import to change previous id_category_default when you force ID, issue #10871. github.com/PrestaShop/PrestaShop/issues/10871

On the solution:

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

Stuck on a tricky one?

If you have a problem in PrestaShop catalog, categories, imports, 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 catch a broken import for you?

If this saved you from a silently broken breadcrumb or canonical URL, 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