Repair Webservice API Data Sync
Setting a default combination via API throws a duplicate key error
You PUT a combination with default_on set to 1, expecting it to become the product's default variant, and PrestaShop hands back a SQL error instead: a duplicate entry for key product_default. Nothing looks wrong in the payload. The problem is that another combination on the same product is still flagged default, and the Webservice API will not clear that flag for you the way the back office does. Here is why the unique key rejects the write and a script that swaps the default combination in the one order that never collides.
ps_product_attribute has a unique key named product_default that allows only one row per id_product to hold default_on=1. When you PUT a combination with default_on set to 1 while another combination on that product already has default_on=1, the write collides with that unique key, because the Webservice API does not clear the old flag the way the back office does in one save. Run a small Python or Node.js script that reads every combinations row for the product, clears default_on on whichever one currently holds it, then sets default_on=1 on the combination you actually want as the new default, as two separate PUT calls in that exact order. Full code, tests, and a dry run guard are below.
The problem in plain words
A PrestaShop product with combinations always has exactly one combination marked as the default. That is what the storefront shows first, and what quick orders and price calculations fall back to when no option is picked. The flag for this lives on the ps_product_attribute table as a column called default_on, and a unique key named product_default makes sure only one row per product can ever have it set to 1.
The back office respects that rule by doing two things in the same save: it clears default_on on whichever combination used to be the default, and only then sets it on the new one. The Webservice combinations resource exposes the same default_on field, but a single PUT to one combination only ever touches that one row. If you send default_on=1 for a combination while a different combination on the same product still carries default_on=1 from before, the insert or update tries to create a second row with the flag set, and the unique key product_default refuses it. PrestaShop returns the write as a duplicate entry error instead of a normal validation error, which is why it looks like a database bug rather than a request you sent in the wrong order.
Why it happens
The root cause is that PrestaShop keeps the default combination fact in two places, and the Webservice API only lets you touch one of them per call. A few situations bring it out:
- Creating a brand new combination with
default_onsent as 1 in the same request, while the product already has an existing combination holdingdefault_on=1from when it was first created. - Updating an existing combination's
default_onto 1 to change which variant shows first, without first clearing the flag on the combination that currently holds it. - An import or sync job that maps a spreadsheet's "is default" column straight onto
default_onfor every row it sends, so more than one combination in the same batch claims the flag. - A multistore catalog where
ps_product.id_default_combinationwas updated through one path but the matchingps_product_attribute.default_onflag was never cleared on the row it replaced.
This is a known limitation of the Webservice API, not a one-off bug in your integration. The PrestaShop core team has confirmed on the tracker that the back office clears default_on on every other combination as part of one save, and that the webservice does not replicate that behavior automatically, which is why the accepted workaround is to update the old default and the new default as two separate calls in the right order. See the citations at the end for the exact threads.
You do not need to touch every combination on the product to fix this, only two rows: the one that currently holds default_on=1, and the one you want to hold it next. Clear the old one first, confirm that write succeeded, and only then set the new one. Reversing that order is exactly what recreates the duplicate entry error, so the order is the whole fix.
The fix, as a flow
We do not change how the back office or the core webservice code works. A script reads the combinations for the product, finds the row that currently has default_on=1, and if it is not already the combination you want, PUTs that old row with default_on=0 first. Only after that succeeds does it PUT the target combination with default_on=1. If the target is already the default, it does nothing.
Build it step by step
Get a Webservice key
In the PrestaShop back office, go to Advanced Parameters, Webservice, and create a key with access to the combinations resource. 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 combinations and find the current default
Pull every combinations row for the product with display=full so you get the default_on flag on each one. Exactly one row should have default_on=1 in a healthy product, so read that row's id before you write anything.
def combinations_for_product(id_product):
data = api_get("combinations", {
"filter[id_product]": id_product,
"display": "full",
})
return data.get("combinations") or []
def current_default_id(rows):
for row in rows:
if int(row.get("default_on") or 0) == 1:
return int(row["id"])
return None
async function combinationsForProduct(idProduct) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
display: "full",
});
return data.combinations || [];
}
function currentDefaultId(rows) {
const row = rows.find((r) => Number(r.default_on || 0) === 1);
return row ? Number(row.id) : null;
}
Decide, with one pure function
The decision that matters is which two writes to make, and in what order. Given the current default id and the target id, the plan is either empty (target is already default), or exactly two steps: clear the old default, then set the new one. Keeping this pure and free of any HTTP call means we can test the ordering with plain values, no PrestaShop store required.
def plan_default_swap(current_default_id, target_id):
if current_default_id == target_id:
return []
steps = []
if current_default_id is not None:
steps.append({"id": current_default_id, "default_on": 0})
steps.append({"id": target_id, "default_on": 1})
return steps
export function planDefaultSwap(currentDefaultId, targetId) {
if (currentDefaultId === targetId) return [];
const steps = [];
if (currentDefaultId !== null && currentDefaultId !== undefined) {
steps.push({ id: currentDefaultId, default_on: 0 });
}
steps.push({ id: targetId, default_on: 1 });
return steps;
}
Apply the plan as ordered PUT calls
Send each step from the plan as its own PUT to the combination's own resource path, in the order the plan returned them. Never send both writes in parallel, and never send the new default before the old one is cleared. That ordering is the entire reason the duplicate entry error disappears.
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 set_default_on(row, default_on):
body = dict(row)
body["default_on"] = default_on
return api_put(f"combinations/{row['id']}", body)
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 setDefaultOn(row, defaultOn) {
const body = { ...row, default_on: defaultOn };
return apiPut(`combinations/${row.id}`, body);
}
Wire it together with a dry run guard
The run loop pulls the combinations, works out the plan, and logs each step it would take. On the first few runs, leave DRY_RUN on so the script only reports the two writes it would make. Read the output, agree with it, then switch it off to let it write for real, in order.
Always start with DRY_RUN=true. Never set default_on=1 on a target combination before the current default has been cleared, and never fire both PUT calls at the same time, since that recreates the exact race the unique key is there to catch.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it would do, respects the dry run flag, and is safe to run again and again because it only ever writes the old default and the new default, in that order, and does nothing at all once the target is already the default.
"""Swap a PrestaShop product's default combination without hitting product_default.
ps_product_attribute has a unique key (product_default) that allows only one
row per id_product to hold default_on=1. The back office clears default_on on
the old default and sets it on the new one in a single save. The Webservice
API does not do that clearing step for you, so PUTting default_on=1 on a new
combination while another one still holds it collides with the unique key and
PrestaShop returns a duplicate entry error for product_default.
This script reads the combinations for a product, finds whichever one
currently holds default_on=1, and if it is not already the target, clears it
first with one PUT, then sets default_on=1 on the target with a second PUT.
If the target is already the default it does nothing. Set DRY_RUN=false to
let it write for real.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("swap_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"
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 combinations_for_product(id_product):
data = api_get("combinations", {
"filter[id_product]": id_product,
"display": "full",
})
return data.get("combinations") or []
def current_default_id(rows):
for row in rows:
if int(row.get("default_on") or 0) == 1:
return int(row["id"])
return None
def plan_default_swap(current_default_id, target_id):
if current_default_id == target_id:
return []
steps = []
if current_default_id is not None:
steps.append({"id": current_default_id, "default_on": 0})
steps.append({"id": target_id, "default_on": 1})
return steps
def set_default_on(row, default_on):
body = dict(row)
body["default_on"] = default_on
return api_put(f"combinations/{row['id']}", body)
def run(id_product, target_id):
rows = combinations_for_product(id_product)
by_id = {int(row["id"]): row for row in rows}
if target_id not in by_id:
raise SystemExit(f"Combination {target_id} was not found on product {id_product}.")
old_default_id = current_default_id(rows)
steps = plan_default_swap(old_default_id, target_id)
if not steps:
log.info("Combination %s is already the default for product %s. Nothing to do.", target_id, id_product)
return
for step in steps:
row = by_id[step["id"]]
log.info(
"Setting combination %s default_on=%s. %s",
step["id"], step["default_on"], "would write" if DRY_RUN else "writing",
)
if not DRY_RUN:
set_default_on(row, step["default_on"])
log.info(
"Done. %s default combination for product %s from %s to %s.",
"Would swap" if DRY_RUN else "Swapped", id_product, old_default_id, target_id,
)
if __name__ == "__main__":
target_product = os.environ.get("TARGET_ID_PRODUCT")
target_combination = os.environ.get("TARGET_ID_COMBINATION")
if not target_product or not target_combination:
raise SystemExit("Set TARGET_ID_PRODUCT and TARGET_ID_COMBINATION to run this.")
run(int(target_product), int(target_combination))
/**
* Swap a PrestaShop product's default combination without hitting product_default.
*
* ps_product_attribute has a unique key (product_default) that allows only one
* row per id_product to hold default_on=1. The back office clears default_on on
* the old default and sets it on the new one in a single save. The Webservice
* API does not do that clearing step for you, so PUTting default_on=1 on a new
* combination while another one still holds it collides with the unique key
* and PrestaShop returns a duplicate entry error for product_default.
*
* This script reads the combinations for a product, finds whichever one
* currently holds default_on=1, and if it is not already the target, clears it
* first with one PUT, then sets default_on=1 on the target with a second PUT.
* If the target is already the default it does nothing. Set DRY_RUN=false to
* let it write for real.
*
* Guide: https://www.allanninal.dev/prestashop/webservice-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";
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
export function currentDefaultId(rows) {
const row = rows.find((r) => Number(r.default_on || 0) === 1);
return row ? Number(row.id) : null;
}
export function planDefaultSwap(currentDefaultId, targetId) {
if (currentDefaultId === targetId) return [];
const steps = [];
if (currentDefaultId !== null && currentDefaultId !== undefined) {
steps.push({ id: currentDefaultId, default_on: 0 });
}
steps.push({ id: targetId, default_on: 1 });
return steps;
}
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 combinationsForProduct(idProduct) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
display: "full",
});
return data.combinations || [];
}
async function setDefaultOn(row, defaultOn) {
const body = { ...row, default_on: defaultOn };
return apiPut(`combinations/${row.id}`, body);
}
export async function run(idProduct, targetId) {
const rows = await combinationsForProduct(idProduct);
const byId = new Map(rows.map((row) => [Number(row.id), row]));
if (!byId.has(targetId)) {
throw new Error(`Combination ${targetId} was not found on product ${idProduct}.`);
}
const oldDefaultId = currentDefaultId(rows);
const steps = planDefaultSwap(oldDefaultId, targetId);
if (steps.length === 0) {
console.log(`Combination ${targetId} is already the default for product ${idProduct}. Nothing to do.`);
return;
}
for (const step of steps) {
const row = byId.get(step.id);
console.log(`Setting combination ${step.id} default_on=${step.default_on}. ${DRY_RUN ? "would write" : "writing"}`);
if (!DRY_RUN) await setDefaultOn(row, step.default_on);
}
console.log(`${DRY_RUN ? "Would swap" : "Swapped"} default combination for product ${idProduct} from ${oldDefaultId} to ${targetId}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const targetProduct = process.env.TARGET_ID_PRODUCT;
const targetCombination = process.env.TARGET_ID_COMBINATION;
if (!targetProduct || !targetCombination) {
console.error("Set TARGET_ID_PRODUCT and TARGET_ID_COMBINATION to run this.");
process.exit(1);
}
run(Number(targetProduct), Number(targetCombination)).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The ordering decision is the part most worth testing, because getting it backwards recreates the exact duplicate entry error this whole fix exists to avoid. Because we kept plan_default_swap and current_default_id pure, the tests need no network and no PrestaShop store. They just feed in plain lists and values and check the plan.
from swap_default_combination import plan_default_swap, current_default_id
def row(**over):
base = {"id": 1, "default_on": 0}
base.update(over)
return base
def test_no_steps_when_target_already_default():
assert plan_default_swap(5, 5) == []
def test_clears_old_default_before_setting_new_one():
steps = plan_default_swap(5, 9)
assert steps == [{"id": 5, "default_on": 0}, {"id": 9, "default_on": 1}]
def test_order_is_always_clear_then_set():
steps = plan_default_swap(3, 4)
assert steps[0]["default_on"] == 0
assert steps[1]["default_on"] == 1
assert steps[0]["id"] != steps[1]["id"]
def test_handles_missing_current_default():
steps = plan_default_swap(None, 7)
assert steps == [{"id": 7, "default_on": 1}]
def test_current_default_id_finds_the_flagged_row():
rows = [row(id=1, default_on=0), row(id=2, default_on=1), row(id=3, default_on=0)]
assert current_default_id(rows) == 2
def test_current_default_id_returns_none_when_nobody_is_flagged():
rows = [row(id=1, default_on=0), row(id=2, default_on=0)]
assert current_default_id(rows) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { planDefaultSwap, currentDefaultId } from "./swap-default-combination.js";
const row = (over = {}) => ({ id: 1, default_on: 0, ...over });
test("no steps when target is already default", () => {
assert.deepEqual(planDefaultSwap(5, 5), []);
});
test("clears old default before setting the new one", () => {
const steps = planDefaultSwap(5, 9);
assert.deepEqual(steps, [{ id: 5, default_on: 0 }, { id: 9, default_on: 1 }]);
});
test("order is always clear then set", () => {
const steps = planDefaultSwap(3, 4);
assert.equal(steps[0].default_on, 0);
assert.equal(steps[1].default_on, 1);
assert.notEqual(steps[0].id, steps[1].id);
});
test("handles a missing current default", () => {
assert.deepEqual(planDefaultSwap(null, 7), [{ id: 7, default_on: 1 }]);
});
test("currentDefaultId finds the flagged row", () => {
const rows = [row({ id: 1, default_on: 0 }), row({ id: 2, default_on: 1 }), row({ id: 3, default_on: 0 })];
assert.equal(currentDefaultId(rows), 2);
});
test("currentDefaultId returns null when nobody is flagged", () => {
const rows = [row({ id: 1, default_on: 0 }), row({ id: 2, default_on: 0 })];
assert.equal(currentDefaultId(rows), null);
});
Case studies
A spreadsheet import that flagged two combinations default
An apparel store imported combinations from a supplier spreadsheet where an "is default" column got mapped straight onto default_on for every row. The first product in the batch already had a default combination from an earlier manual edit, and the import's PUT for the new default row failed with a duplicate entry for product_default, stalling the whole batch partway through.
The team added the swap step ahead of each import row: read the current default first, clear it, then set the new one. The same spreadsheet reran cleanly, and only one combination per product ever carried the flag at a time.
An internal tool that let merchandisers change the default variant
A merchandising team built a small internal page so category managers could pick which color or size showed first on a product card, without opening the full PrestaShop back office. Early versions called the webservice with just the new default's id, and it worked until a category manager tried it on a product where the current default was a discontinued combination they had not touched yet.
Once the tool started reading the combinations first and clearing the old default before setting the new one, the same action that used to throw a duplicate entry error became a clean two step swap, and the merchandising team stopped needing a developer for a routine catalog change.
After this script is in front of any place that sets default_on, changing a product's default combination through the API is a plain two step swap instead of a database error. Only the old default and the new default are ever written, in the order that keeps product_default happy, and a target that is already the default is left alone entirely.
FAQ
Why does setting default_on on a combination throw a duplicate entry error?
ps_product_attribute has a unique key named product_default that only allows one row per id_product to hold default_on=1. The PrestaShop back office clears default_on on every other combination before it sets the new one. The Webservice API does not do that clearing step for you, so if a combination is already flagged default_on=1 and you PUT another combination with default_on=1, the write collides with the unique key and PrestaShop returns a duplicate entry error for product_default.
Do I need to update every combination to fix this?
No. You only need to touch the combination that currently holds default_on=1 and the combination you want to become the new default. Clear default_on on the current default first with one PUT, then set default_on=1 on the new default with a second PUT. Leaving every other combination untouched is both correct and faster.
Is default_on the same as id_default_combination?
They are two places PrestaShop stores the same fact. ps_product_attribute.default_on is the flag on the combination row itself, and ps_product.id_default_combination is a pointer on the product row to that same combination's id. The back office keeps both in sync in one save. The Webservice API resource for combinations only exposes default_on, so a script has to update it in the order that avoids two rows claiming default_on=1 at once.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: adding a combination through the webservice with default_on gives a SQL error, duplicate entry for key product_default. github.com/PrestaShop/PrestaShop/issues/21543
- PrestaShop GitHub: updating a default combination through the webservice does not clear default_on on the old default the way the back office does. github.com/PrestaShop/PrestaShop/issues/12459
- PrestaShop GitHub: duplicate entry for key product_default in multistore when a product's combination state changes. github.com/PrestaShop/PrestaShop/issues/9664
On the solution:
- PrestaShop Developer Documentation: the combinations resource. devdocs.prestashop-project.org/9/webservice/resources/combinations
- PrestaShop Developer Documentation: create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az
- PrestaShop Developer Documentation: authentication with the Webservice API. devdocs.prestashop-project.org/9/webservice/getting-started/authentication
Stuck on a tricky one?
If you have a problem in PrestaShop products, combinations, stock, 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 your duplicate key error?
If this saved you a confusing SQL error or a stalled import, 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