Skip to content

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.

Python and Node.js PrestaShop Webservice API Diagnostic first, dry run repair
A stack of folders
Photo by Beatriz Perez Moya on Unsplash
The short answer

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.

Shop 1 default_on = 1 (already set) Shop 2 create default combination Unique key not scoped by id_shop Duplicate entry product_default write fails mid transaction Two defaults or none, per shop
Shop 1 already holds a default combination. Creating a default on shop 2 can collide with the same unique key when it is not properly scoped by id_shop, leaving one shop with two flagged defaults and the other with none.

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:

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.

The key insight

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.

GET shops list every id_shop GET combinations per product, per shop classify_default _combination_state State is OK? yes, skip Nothing to report no Report product plus shop plus state If DRY_RUN=false clear extras, then set id_default_combination
The script only reports by default. Behind an explicit DRY_RUN=false flag it clears every extra default row in a shop first, one write at a time, then points id_default_combination at the surviving row.

Build it step by step

1

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.

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 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 write
2

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.

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

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.

step3.py
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
step3.js
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;
}
4

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.

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

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.

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

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.

Run it safe

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.

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.
diagnose_multistore_default_combination.py
"""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-multistore-default-combination.js
/**
 * 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.

test_multistore_default_state.py
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"
multistore-default-state.test.js
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

Store rollout

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.

Product import

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.

What good looks like

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:

  1. 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
  2. PrestaShop GitHub: cannot create separate product combinations. github.com/PrestaShop/PrestaShop/issues/20272
  3. PrestaShop GitHub: multishop default combinations not reassigned if you delete it. github.com/PrestaShop/PrestaShop/issues/12244

On the solution:

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

Contact me on LinkedIn

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

Back to all PrestaShop field notes