Diagnostic Catalog & Products
Duplicate key error creating a default combination per shop in multistore
A merchant with two or more shops tries to set a default combination on the second shop, and PrestaShop hands back a SQL error: a duplicate entry for key product_default. The write can leave the wrong shop holding the default, or leave a shop with no default at all. Here is why the per shop scoping breaks down and a diagnostic script that lists every product and shop where the default combination state is actually broken, before you touch anything.
PrestaShop's default combination flag is supposed to be scoped per shop through product_attribute_shop, but the unique key behind product_default was not always built with id_shop in mind. Creating or converting a default combination on a second shop can then collide with the default already set for the first shop, and the failed write can leave a shop with two combinations flagged default or with none. Run a small Python or Node.js script that reads every product's combinations once per shop and flags duplicates, missing defaults, and pointer mismatches. It only reports by default. Full code, tests, and a dry run guarded repair are below.
The problem in plain words
A PrestaShop product with combinations always needs one, and only one, combination marked as the default for each shop it is sold in. Single shop stores never notice how fragile this is, because there is only ever one shop to keep in sync. Multistore installs are different: the same product can be associated with several shops, and each shop is allowed to have its own default combination, its own price, and its own stock, all layered on top of one shared product row.
The trouble starts because the flag that marks a combination as default lives in two places that do not always agree across shops. ps_product_attribute_shop is meant to hold a default_on value per shop, but the unique index that historically enforced "only one default" on ps_product_attribute was not always scoped by id_shop in older 1.6 style code paths. So when a merchant creates a new combination and marks it default on shop two, or converts a simple product into a combination product on shop two, the write can try to claim the same default slot the first shop is still using, and the database rejects it with a duplicate entry error instead of quietly keeping two independent defaults.
Why it happens
The root cause is that the "one default per product" rule predates full multistore awareness, and a few ordinary actions can still trigger it or leave its aftermath behind:
- Creating a new combination and marking it default on a second shop while the first shop's combination still holds the default flag from before multistore was configured.
- Converting a simple product into a product with combinations on shop two, which needs a fresh default combination row, while shop one already has its own default in place.
- The failed transaction leaving
default_onset in the wrong table or shop scope, so some shops end up with two combinations flaggeddefault_on=1and others end up with zero, because the write partially applied before it errored. - The same race reachable through the Webservice
combinationsresource when a client setsdefault_on=1without first clearing the previous default in that shop'sproduct_attribute_shopscope, exactly the pattern reported against the webservice in issue #21543. - A default combination deleted on one shop with nothing reassigned to take its place, leaving that shop with an orphaned, missing default, as tracked in issue #12244.
This is a known rough edge in multistore, not a one-off bug in a single store's data. PrestaShop's own tracker has open reports of the exact duplicate entry message when changing a product with combinations into a standard product and back on a second shop, and separate reports of shops silently losing their default entirely. See the citations at the end for the exact threads.
Because the constraint bug lives at the database schema level, this is not something a script should try to patch with a blind write. The safe move is to separate finding the problem from fixing it. A diagnostic reads the combinations for every shop a product is associated with, and only flags a shop as inconsistent when the combinations list itself proves it: more than one default, zero defaults on an active shop, or a mismatch between the product's id_default_combination pointer and the row that is actually flagged default in that shop.
The fix, as a flow
We do not touch the core webservice code or the database schema. A script lists every shop from the shops resource, then for each product pulls its combinations filtered to that shop and classifies the state with one pure function. Anything that is not OK gets reported. Only when a merchant explicitly turns off dry run does the script attempt the two step repair, and even then it applies one write per row, never a bulk toggle.
Build it step by step
Get a Webservice key
In the PrestaShop back office, go to Advanced Parameters, Webservice, and create a key with read access to the products, combinations, and shops resources. 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 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 write
Talk to the Webservice API
Every call is plain HTTP with the key as the Basic auth username. Ask for JSON with output_format=JSON, since the default is XML. A small helper sends the request and raises if PrestaShop returns an error status.
import os, requests
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["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=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
List the shops, then the combinations per shop
Pull every shop from the shops resource, then for each product pull its combinations filtered to id_product and to id_shop with display=full, so each row includes the default_on flag as seen in that shop's context. Also read id_default_combination off the product record so we can catch a pointer mismatch.
def all_shops():
data = api_get("shops", {"display": "full"})
return data.get("shops") or []
def combinations_for_product_shop(id_product, id_shop):
data = api_get("combinations", {
"filter[id_product]": id_product,
"id_shop": id_shop,
"display": "full",
})
return data.get("combinations") or []
def product_default_combination(id_product):
data = api_get(f"products/{id_product}", {"display": "full"})
return int((data.get("product") or {}).get("id_default_combination") or 0) or None
async function allShops() {
const data = await apiGet("shops", { display: "full" });
return data.shops || [];
}
async function combinationsForProductShop(idProduct, idShop) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
id_shop: idShop,
display: "full",
});
return data.combinations || [];
}
async function productDefaultCombination(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
const raw = data.product?.id_default_combination;
return raw ? Number(raw) : null;
}
Decide, with one pure function
The classification that matters is a single verdict per product and shop: OK, DUPLICATE_DEFAULT, MISSING_DEFAULT, POINTER_MISMATCH, or NOT_APPLICABLE. Keeping this pure and free of any HTTP call means we can test every case with plain lists, no PrestaShop store required, and it is the only place the diagnostic makes a decision.
def classify_default_combination_state(combinations, id_default_combination, shop_active):
"""
combinations: list of {"id": int, "id_product_attribute": int, "default_on": "0"|"1"|None}
for ONE id_shop context.
id_default_combination: the product resource's pointer for that shop context, or None.
shop_active: whether the product is active/associated to this shop.
Returns one of: OK, DUPLICATE_DEFAULT, MISSING_DEFAULT, POINTER_MISMATCH, NOT_APPLICABLE.
"""
if not combinations:
return "NOT_APPLICABLE"
default_flags = [c for c in combinations if str(c.get("default_on")) == "1"]
if len(default_flags) > 1:
return "DUPLICATE_DEFAULT"
if len(default_flags) == 0:
return "MISSING_DEFAULT" if shop_active else "NOT_APPLICABLE"
only_default = default_flags[0]
if id_default_combination is not None and only_default["id_product_attribute"] != id_default_combination:
return "POINTER_MISMATCH"
return "OK"
export function classifyDefaultCombinationState(combinations, idDefaultCombination, shopActive) {
if (!combinations || combinations.length === 0) return "NOT_APPLICABLE";
const defaultFlags = combinations.filter((c) => String(c.default_on) === "1");
if (defaultFlags.length > 1) return "DUPLICATE_DEFAULT";
if (defaultFlags.length === 0) return shopActive ? "MISSING_DEFAULT" : "NOT_APPLICABLE";
const onlyDefault = defaultFlags[0];
if (idDefaultCombination !== null && idDefaultCombination !== undefined
&& onlyDefault.id_product_attribute !== idDefaultCombination) {
return "POINTER_MISMATCH";
}
return "OK";
}
Walk every product across every shop, and report
For each id_shop in the shops list, and each product in range, pull that shop's combinations, classify the state, and log anything that is not OK. This is the default and safe mode: a report only, no write of any kind.
def scan_product(id_product, shops):
findings = []
id_default_combination = product_default_combination(id_product)
for shop in shops:
id_shop = int(shop["id"])
shop_active = str(shop.get("active", "1")) == "1"
combos = combinations_for_product_shop(id_product, id_shop)
state = classify_default_combination_state(combos, id_default_combination, shop_active)
if state != "OK" and state != "NOT_APPLICABLE":
findings.append({"id_product": id_product, "id_shop": id_shop, "state": state})
return findings
async function scanProduct(idProduct, shops) {
const findings = [];
const idDefaultCombination = await productDefaultCombination(idProduct);
for (const shop of shops) {
const idShop = Number(shop.id);
const shopActive = String(shop.active ?? "1") === "1";
const combos = await combinationsForProductShop(idProduct, idShop);
const state = classifyDefaultCombinationState(combos, idDefaultCombination, shopActive);
if (state !== "OK" && state !== "NOT_APPLICABLE") {
findings.push({ id_product: idProduct, id_shop: idShop, state });
}
}
return findings;
}
Wire it together with a dry run guarded repair
The run loop scans a range of products and reports every finding. Only when DRY_RUN=false does it also repair: clear default_on on every extra default row in that shop first, one PUT per id_product_attribute, then PUT the product's id_default_combination to the surviving row. If a shop is MISSING_DEFAULT, it picks the lowest id_product_attribute in that shop as the new default and applies the same two step write. Doing the combination writes before the product write avoids a transient window with two shop scoped defaults, which is exactly what can retrigger the legacy unique key error on some 1.6.x builds.
Always start with DRY_RUN=true and read the report before changing anything. Never write a bulk toggle across every combination at once, and always clear the extra default rows in a shop before pointing id_default_combination at the survivor, since that ordering is what keeps the write from tripping the same unique key it is meant to fix.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, walks every product across every shop, classifies each product and shop pair with the pure function above, logs every finding, and only writes when DRY_RUN is explicitly turned off, one row at a time, in the order that never leaves two shop scoped defaults in play at once.
"""Diagnose duplicate or missing default combinations across PrestaShop shops.
In multistore, the default combination flag is meant to be scoped per shop
through product_attribute_shop, but the unique index behind product_default
was not always shop aware in older 1.6 style code paths. Creating or
converting a default combination on a second shop can then collide with the
default already set on the first shop, and the failed write can leave a shop
with two combinations flagged default_on=1, or with none at all.
This script reads every shop, then for each product in a given id range
pulls that product's combinations filtered to each id_shop and classifies
the state with a pure function. It only reports by default. Set
DRY_RUN=false to also apply a two step repair per flagged product and shop:
clear every extra default row in that shop first, one PUT per
id_product_attribute, then PUT the product's id_default_combination to the
surviving row.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diagnose_multistore_default_combination")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ID_PRODUCT_START = int(os.environ.get("ID_PRODUCT_START", "1"))
ID_PRODUCT_END = int(os.environ.get("ID_PRODUCT_END", "1"))
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=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
def api_put(path, body):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"},
json=body,
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return r.json()
def classify_default_combination_state(combinations, id_default_combination, shop_active):
"""
combinations: list of {"id": int, "id_product_attribute": int, "default_on": "0"|"1"|None}
for ONE id_shop context.
id_default_combination: the product resource's pointer for that shop context, or None.
shop_active: whether the product is active/associated to this shop.
Returns one of: OK, DUPLICATE_DEFAULT, MISSING_DEFAULT, POINTER_MISMATCH, NOT_APPLICABLE.
Pure decision logic, no I/O.
"""
if not combinations:
return "NOT_APPLICABLE"
default_flags = [c for c in combinations if str(c.get("default_on")) == "1"]
if len(default_flags) > 1:
return "DUPLICATE_DEFAULT"
if len(default_flags) == 0:
return "MISSING_DEFAULT" if shop_active else "NOT_APPLICABLE"
only_default = default_flags[0]
if id_default_combination is not None and only_default["id_product_attribute"] != id_default_combination:
return "POINTER_MISMATCH"
return "OK"
def all_shops():
data = api_get("shops", {"display": "full"})
return data.get("shops") or []
def combinations_for_product_shop(id_product, id_shop):
data = api_get("combinations", {
"filter[id_product]": id_product,
"id_shop": id_shop,
"display": "full",
})
return data.get("combinations") or []
def product_default_combination(id_product):
data = api_get(f"products/{id_product}", {"display": "full"})
raw = (data.get("product") or {}).get("id_default_combination")
return int(raw) if raw not in (None, "", "0") else None
def scan_product(id_product, shops):
findings = []
id_default_combination = product_default_combination(id_product)
for shop in shops:
id_shop = int(shop["id"])
shop_active = str(shop.get("active", "1")) == "1"
combos = combinations_for_product_shop(id_product, id_shop)
state = classify_default_combination_state(combos, id_default_combination, shop_active)
if state not in ("OK", "NOT_APPLICABLE"):
findings.append({
"id_product": id_product,
"id_shop": id_shop,
"state": state,
"combinations": combos,
})
return findings
def repair_finding(finding):
id_product = finding["id_product"]
id_shop = finding["id_shop"]
state = finding["state"]
combos = finding["combinations"]
if state == "DUPLICATE_DEFAULT":
defaults = [c for c in combos if str(c.get("default_on")) == "1"]
survivor = min(defaults, key=lambda c: int(c["id_product_attribute"]))
extras = [c for c in defaults if c is not survivor]
elif state == "MISSING_DEFAULT":
survivor = min(combos, key=lambda c: int(c["id_product_attribute"]))
extras = []
elif state == "POINTER_MISMATCH":
defaults = [c for c in combos if str(c.get("default_on")) == "1"]
survivor = defaults[0]
extras = []
else:
return
for extra in extras:
pa_id = extra["id_product_attribute"]
log.info("Product %s shop %s: clearing default_on on id_product_attribute %s. %s",
id_product, id_shop, pa_id, "would write" if DRY_RUN else "writing")
if not DRY_RUN:
api_put(f"combinations/{pa_id}", {**extra, "default_on": 0})
pa_id = survivor["id_product_attribute"]
log.info("Product %s shop %s: setting id_default_combination to %s. %s",
id_product, id_shop, pa_id, "would write" if DRY_RUN else "writing")
if not DRY_RUN:
api_put(f"products/{id_product}", {"id_default_combination": pa_id})
def run():
shops = all_shops()
total_findings = 0
for id_product in range(ID_PRODUCT_START, ID_PRODUCT_END + 1):
findings = scan_product(id_product, shops)
for finding in findings:
log.warning("Product %s shop %s: %s", finding["id_product"], finding["id_shop"], finding["state"])
repair_finding(finding)
total_findings += 1
log.info("Done. %d product/shop finding(s) %s.", total_findings, "to repair" if DRY_RUN else "repaired")
if __name__ == "__main__":
run()
/**
* Diagnose duplicate or missing default combinations across PrestaShop shops.
*
* In multistore, the default combination flag is meant to be scoped per shop
* through product_attribute_shop, but the unique index behind product_default
* was not always shop aware in older 1.6 style code paths. Creating or
* converting a default combination on a second shop can then collide with
* the default already set on the first shop, and the failed write can leave
* a shop with two combinations flagged default_on=1, or with none at all.
*
* This script reads every shop, then for each product in a given id range
* pulls that product's combinations filtered to each id_shop and classifies
* the state with a pure function. It only reports by default. Set
* DRY_RUN=false to also apply a two step repair per flagged product and
* shop: clear every extra default row in that shop first, one PUT per
* id_product_attribute, then PUT the product's id_default_combination to
* the surviving row.
*
* Guide: https://www.allanninal.dev/prestashop/multistore-default-combination-duplicate-key/
*/
import { pathToFileURL } from "node:url";
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ID_PRODUCT_START = Number(process.env.ID_PRODUCT_START || 1);
const ID_PRODUCT_END = Number(process.env.ID_PRODUCT_END || 1);
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
export function classifyDefaultCombinationState(combinations, idDefaultCombination, shopActive) {
if (!combinations || combinations.length === 0) return "NOT_APPLICABLE";
const defaultFlags = combinations.filter((c) => String(c.default_on) === "1");
if (defaultFlags.length > 1) return "DUPLICATE_DEFAULT";
if (defaultFlags.length === 0) return shopActive ? "MISSING_DEFAULT" : "NOT_APPLICABLE";
const onlyDefault = defaultFlags[0];
if (idDefaultCombination !== null && idDefaultCombination !== undefined
&& onlyDefault.id_product_attribute !== idDefaultCombination) {
return "POINTER_MISMATCH";
}
return "OK";
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiPut(path, body) {
const res = await fetch(`${PRESTASHOP_URL}/api/${path}?output_format=JSON`, {
method: "PUT",
headers: { Authorization: authHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function allShops() {
const data = await apiGet("shops", { display: "full" });
return data.shops || [];
}
async function combinationsForProductShop(idProduct, idShop) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
id_shop: idShop,
display: "full",
});
return data.combinations || [];
}
async function productDefaultCombination(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
const raw = data.product?.id_default_combination;
return raw && raw !== "0" ? Number(raw) : null;
}
async function scanProduct(idProduct, shops) {
const findings = [];
const idDefaultCombination = await productDefaultCombination(idProduct);
for (const shop of shops) {
const idShop = Number(shop.id);
const shopActive = String(shop.active ?? "1") === "1";
const combos = await combinationsForProductShop(idProduct, idShop);
const state = classifyDefaultCombinationState(combos, idDefaultCombination, shopActive);
if (state !== "OK" && state !== "NOT_APPLICABLE") {
findings.push({ id_product: idProduct, id_shop: idShop, state, combinations: combos });
}
}
return findings;
}
async function repairFinding(finding) {
const { id_product: idProduct, id_shop: idShop, state, combinations: combos } = finding;
let survivor;
let extras = [];
if (state === "DUPLICATE_DEFAULT") {
const defaults = combos.filter((c) => String(c.default_on) === "1");
survivor = defaults.reduce((a, b) => (Number(a.id_product_attribute) <= Number(b.id_product_attribute) ? a : b));
extras = defaults.filter((c) => c !== survivor);
} else if (state === "MISSING_DEFAULT") {
survivor = combos.reduce((a, b) => (Number(a.id_product_attribute) <= Number(b.id_product_attribute) ? a : b));
} else if (state === "POINTER_MISMATCH") {
survivor = combos.filter((c) => String(c.default_on) === "1")[0];
} else {
return;
}
for (const extra of extras) {
const paId = extra.id_product_attribute;
console.log(`Product ${idProduct} shop ${idShop}: clearing default_on on id_product_attribute ${paId}. ${DRY_RUN ? "would write" : "writing"}`);
if (!DRY_RUN) await apiPut(`combinations/${paId}`, { ...extra, default_on: 0 });
}
const paId = survivor.id_product_attribute;
console.log(`Product ${idProduct} shop ${idShop}: setting id_default_combination to ${paId}. ${DRY_RUN ? "would write" : "writing"}`);
if (!DRY_RUN) await apiPut(`products/${idProduct}`, { id_default_combination: paId });
}
export async function run() {
const shops = await allShops();
let totalFindings = 0;
for (let idProduct = ID_PRODUCT_START; idProduct <= ID_PRODUCT_END; idProduct++) {
const findings = await scanProduct(idProduct, shops);
for (const finding of findings) {
console.warn(`Product ${finding.id_product} shop ${finding.id_shop}: ${finding.state}`);
await repairFinding(finding);
totalFindings++;
}
}
console.log(`Done. ${totalFindings} product/shop finding(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification rule is the part most worth testing, because it decides whether a product and shop pair gets reported and repaired at all. Because we kept classify_default_combination_state pure, the tests need no network and no PrestaShop store. They just feed in plain combination lists and check the verdict.
from diagnose_multistore_default_combination import classify_default_combination_state
def combo(**over):
base = {"id": 1, "id_product_attribute": 10, "default_on": "0"}
base.update(over)
return base
def test_not_applicable_when_no_combinations():
assert classify_default_combination_state([], None, True) == "NOT_APPLICABLE"
def test_ok_when_exactly_one_default_matches_pointer():
combos = [combo(id_product_attribute=10, default_on="1"), combo(id_product_attribute=11, default_on="0")]
assert classify_default_combination_state(combos, 10, True) == "OK"
def test_duplicate_default_when_two_rows_flagged():
combos = [combo(id_product_attribute=10, default_on="1"), combo(id_product_attribute=11, default_on="1")]
assert classify_default_combination_state(combos, 10, True) == "DUPLICATE_DEFAULT"
def test_missing_default_on_active_shop():
combos = [combo(id_product_attribute=10, default_on="0"), combo(id_product_attribute=11, default_on="0")]
assert classify_default_combination_state(combos, None, True) == "MISSING_DEFAULT"
def test_missing_default_ignored_on_inactive_shop():
combos = [combo(id_product_attribute=10, default_on="0")]
assert classify_default_combination_state(combos, None, False) == "NOT_APPLICABLE"
def test_pointer_mismatch_when_product_points_elsewhere():
combos = [combo(id_product_attribute=10, default_on="1")]
assert classify_default_combination_state(combos, 99, True) == "POINTER_MISMATCH"
def test_ok_when_pointer_is_none_and_one_default_exists():
combos = [combo(id_product_attribute=10, default_on="1")]
assert classify_default_combination_state(combos, None, True) == "OK"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyDefaultCombinationState } from "./diagnose-multistore-default-combination.js";
const combo = (over = {}) => ({ id: 1, id_product_attribute: 10, default_on: "0", ...over });
test("not applicable when no combinations", () => {
assert.equal(classifyDefaultCombinationState([], null, true), "NOT_APPLICABLE");
});
test("ok when exactly one default matches the pointer", () => {
const combos = [combo({ id_product_attribute: 10, default_on: "1" }), combo({ id_product_attribute: 11, default_on: "0" })];
assert.equal(classifyDefaultCombinationState(combos, 10, true), "OK");
});
test("duplicate default when two rows are flagged", () => {
const combos = [combo({ id_product_attribute: 10, default_on: "1" }), combo({ id_product_attribute: 11, default_on: "1" })];
assert.equal(classifyDefaultCombinationState(combos, 10, true), "DUPLICATE_DEFAULT");
});
test("missing default on an active shop", () => {
const combos = [combo({ id_product_attribute: 10, default_on: "0" }), combo({ id_product_attribute: 11, default_on: "0" })];
assert.equal(classifyDefaultCombinationState(combos, null, true), "MISSING_DEFAULT");
});
test("missing default ignored on an inactive shop", () => {
const combos = [combo({ id_product_attribute: 10, default_on: "0" })];
assert.equal(classifyDefaultCombinationState(combos, null, false), "NOT_APPLICABLE");
});
test("pointer mismatch when the product points elsewhere", () => {
const combos = [combo({ id_product_attribute: 10, default_on: "1" })];
assert.equal(classifyDefaultCombinationState(combos, 99, true), "POINTER_MISMATCH");
});
test("ok when pointer is null and one default exists", () => {
const combos = [combo({ id_product_attribute: 10, default_on: "1" })];
assert.equal(classifyDefaultCombinationState(combos, null, true), "OK");
});
Case studies
A second shop that never got a clean default
A homeware brand added a second shop for a regional storefront and reused its existing catalog. Staff tried to set a different default combination for a handful of products on the new shop, and a few of those saves failed with a duplicate entry for product_default. Nobody noticed the partial failure until customers on the new shop reported that some product pages showed no variant selected at all.
Running the diagnostic across both shops surfaced exactly which products had MISSING_DEFAULT on the new shop, all from the same rollout week. The dry run report matched what staff remembered trying to change, and turning off dry run repaired every one with a single lowest id pick, without touching the shop that already worked.
An import that quietly left two shops disagreeing
A multistore catalog synced combinations from a supplier feed for both shops in one batch job. One shop's default write succeeded and the other's silently kept the previous default because the job never checked the response closely enough to notice the duplicate entry error it swallowed.
The diagnostic flagged the affected products as POINTER_MISMATCH, since the product's id_default_combination no longer pointed at the row the shop's combinations list actually flagged default. The team fixed the import to check for that error going forward, and used the repair path once, with dry run first, to bring the existing catalog back into agreement.
After running this diagnostic regularly, a duplicate entry error on product_default stops being a mystery and becomes a short list of product and shop pairs with a clear verdict. Nothing gets touched until a human reads the dry run report, and the repair only ever writes the rows that a shop's own combinations list proves are wrong, in the order that keeps the unique key from tripping again.
FAQ
Why does creating a default combination on a second shop throw a duplicate key error?
In multistore, the default combination flag is meant to be scoped per shop, but the unique index behind it was not always shop aware in older code paths. When you create or convert a default combination on a second shop, the write can collide with the default flag already set for the first shop, and PrestaShop returns a duplicate entry error for key product_default instead of letting each shop keep its own default.
How do I find which products and shops are affected?
Pull every product with the products resource, then for each product call the combinations resource once per id_shop from the shops resource. A shop is inconsistent when more than one combination shows default_on=1 for that shop, when none do while the product is active there, or when the product's id_default_combination pointer does not match the row that actually has default_on=1 in that shop's list.
Is it safe to auto-fix a duplicate or missing default combination?
Only behind a dry run flag, and only one id_product_attribute write at a time. The safe order is to clear default_on on every extra default row in that shop first, then point the product's id_default_combination at the surviving row, so there is never a moment with two shop scoped defaults that could re-trip the same unique key.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: 500 error, duplicate entry for key product_default in multistore when changing a product with combinations into a standard product and when creating combinations on the second shop. github.com/PrestaShop/PrestaShop/issues/9664
- PrestaShop GitHub: cannot create separate product combinations. github.com/PrestaShop/PrestaShop/issues/20272
- PrestaShop GitHub: multishop default combinations not reassigned if you delete it. github.com/PrestaShop/PrestaShop/issues/12244
On the solution:
- PrestaShop Developer Documentation: the combinations resource. devdocs.prestashop-project.org/9/webservice/resources/combinations
- PrestaShop Developer Documentation: the products resource. devdocs.prestashop-project.org/9/webservice/resources/products
- PrestaShop Developer Documentation: the Webservice API, authentication and request format. devdocs.prestashop-project.org/9/webservice
Stuck on a tricky one?
If you have a problem in PrestaShop products, combinations, multistore, 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 clear up your multistore catalog?
If this saved you a confusing SQL error or a product page with no variant selected, 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