Diagnostic Catalog & Products
Product duplication leaves a broken partial product after an error
Someone clicks Duplicate on a product with a few combinations, and PrestaShop throws a 500 error partway through. The admin screen looks like the whole thing failed. It did not. A new product now sits in your catalog, sometimes still active and visible on the storefront, missing some or all of its combinations, features, images, or stock rows, because nothing rolled it back when the error hit. Here is why PrestaShop leaves this half-built product behind and a small script that finds it so a human can decide what to do about it.
PrestaShop's product duplication, in AdminProductsController::processDuplicate and Product::duplicateProduct, runs as a long, non-transactional sequence of separate inserts: the base product row first, then a loop over combinations, then features, images, accessories, tags, and specific prices. If any single step throws, such as a duplicate-key error on product_default with a missing shop context, or a blocked multi-statement query in GroupReduction::duplicateReduction, PrestaShop shows a 500 error but never rolls back the new product row already inserted. Run a small Python or Node.js script that pulls recently created products through GET /api/products, compares each candidate's combination, feature, and stock counts against expectations, and flags any product that looks like a partial duplicate. Full code, tests, and a dry run guard are below.
The problem in plain words
Duplicating a product in PrestaShop is not one database write, it is dozens. The admin controller first inserts a brand-new base product row, then walks the source product's combinations one at a time and inserts a matching attribute row and its option value links for each, then does the same for features, images, accessories, tags, and specific prices. Each of those is its own separate INSERT, run one after another, with no transaction wrapping the sequence.
That means the moment any single step in that long chain throws, whatever ran before it has already been committed to the database. PrestaShop shows the merchant a 500 or technical error page, which reads like the whole duplication failed and nothing happened. In reality the new product row from the very first step is still there, often still active and visible on the storefront, but with zero or partial combinations, missing product_option_values links, or missing features and images, depending on exactly which iteration of which loop failed.
Why it happens
The root cause is architectural: duplication is a long sequence of independent writes with no transaction boundary around them. A few concrete ways the loop throws partway through:
- A duplicate-key error on
product_defaultwhen the shop context is missing or ambiguous during a multistore duplication. - A blocked multi-statement query inside
GroupReduction::duplicateReductionwhen PDO is used and_PS_ALLOW_MULTI_STATEMENTS_QUERIES_is false, which stops the specific price and group reduction cache rows from copying. - A supplier or combination edge case, such as an attribute combination whose option values were edited or removed on the source product between page load and save.
- A timeout or memory limit on a product with a large number of combinations, images, or feature values, which kills the request mid-loop rather than raising a clean, catchable error.
This is documented, reproducible behavior across multiple PrestaShop versions, not a one-off misconfiguration on your store. PrestaShop's own issue tracker has multiple open reports of exactly this shape: duplication shows a 500 error page, and the product is still duplicated, just broken. See the citations at the end for the exact reports.
A partial duplicate can be broken in many different ways: zero combinations, some combinations but no features, combinations with no matching stock row. Guessing which pieces to rebuild and recreating them automatically is unsafe, because you cannot know from the outside which step failed or whether the merchant even wants that clone to exist. The safe move is to detect the shape of the damage precisely, report it, and let a human decide whether to finish the duplication by hand, deactivate the broken row, or delete it.
The fix, as a flow
We do not touch the admin duplication flow itself. We add a read-only job that pulls recently created products through the Webservice API, checks each one's combinations, features, and stock rows against what a real duplicate should have, and reports anything that looks like a partial duplicate. Only when a human explicitly turns DRY_RUN off does the script take the one safe corrective action: deactivating the broken product so it cannot be sold while still broken. It never deletes anything and never tries to recreate missing pieces.
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 products, combinations, and stock_availables 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();
}
Pull recently created products and their children
Filter products by date_add, since duplication always creates a brand-new id_product with a fresh timestamp. For each candidate, fetch its combinations, its features from associations.product_features, and its stock_availables rows, so the decision function has everything it needs.
def recent_products(date_from, date_to):
data = api_get("products", {
"filter[date_add]": f"[{date_from},{date_to}]",
"display": "full",
"limit": "200",
})
return data.get("products") or []
def combinations_for(id_product):
data = api_get("combinations", {
"filter[id_product]": id_product,
"display": "full",
})
return data.get("combinations") or []
def stock_rows_for(id_product):
data = api_get("stock_availables", {
"filter[id_product]": id_product,
"display": "full",
})
return data.get("stock_availables") or []
async function recentProducts(dateFrom, dateTo) {
const data = await apiGet("products", {
"filter[date_add]": `[${dateFrom},${dateTo}]`,
display: "full",
limit: "200",
});
return data.products || [];
}
async function combinationsFor(idProduct) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
display: "full",
});
return data.combinations || [];
}
async function stockRowsFor(idProduct) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
display: "full",
});
return data.stock_availables || [];
}
Decide, with one pure function
The decision that matters is spotting the shape of the damage: a product that looks like a copy, with fewer combinations, features, or stock rows than it should have. Keeping this pure and free of any HTTP call means we can test every branch with plain dictionaries, no store required.
def _is_copy(product):
for field in ("reference", "name"):
value = str(product.get(field) or "").strip().lower()
if value.endswith("(copy)") or "copy" in value:
return True
return False
def _has_orphaned_stock(combinations, stock_rows):
stocked_attrs = {row.get("id_product_attribute") for row in stock_rows}
for combo in combinations:
if combo.get("id") not in stocked_attrs:
return True
return False
def classify_duplicate_integrity(product, combinations, features, stock_rows,
sibling_combination_count=None):
is_copy = _is_copy(product)
if is_copy and len(combinations) == 0 and (sibling_combination_count or 0) > 0:
return "MISSING_COMBINATIONS"
if is_copy and len(features) == 0 and product.get("expected_features"):
return "MISSING_FEATURES"
if combinations and _has_orphaned_stock(combinations, stock_rows):
return "ORPHANED_STOCK"
if (is_copy and sibling_combination_count is not None
and len(combinations) < sibling_combination_count):
return "SUSPECT_PARTIAL_DUPLICATE"
return "OK"
function isCopy(product) {
for (const field of ["reference", "name"]) {
const value = String(product[field] || "").trim().toLowerCase();
if (value.endsWith("(copy)") || value.includes("copy")) return true;
}
return false;
}
function hasOrphanedStock(combinations, stockRows) {
const stockedAttrs = new Set(stockRows.map((row) => row.id_product_attribute));
return combinations.some((combo) => !stockedAttrs.has(combo.id));
}
export function classifyDuplicateIntegrity(product, combinations, features, stockRows, siblingCombinationCount = null) {
const copy = isCopy(product);
if (copy && combinations.length === 0 && (siblingCombinationCount || 0) > 0) {
return "MISSING_COMBINATIONS";
}
if (copy && features.length === 0 && product.expected_features) {
return "MISSING_FEATURES";
}
if (combinations.length && hasOrphanedStock(combinations, stockRows)) {
return "ORPHANED_STOCK";
}
if (copy && siblingCombinationCount !== null && combinations.length < siblingCombinationCount) {
return "SUSPECT_PARTIAL_DUPLICATE";
}
return "OK";
}
Report by default, deactivate only when a human approves it
By default the job only logs each suspect product as JSON, with the classification and the counts that triggered it, so a human can decide whether to finish the duplication by hand, delete the row, or leave it. Only when DRY_RUN is false does it fetch the current product body with GET /api/products/{id}, set active to 0, and PUT the full body back, since PrestaShop's Webservice PUT requires the complete resource, not a partial patch. It never deletes a product on its own.
Always start with DRY_RUN=true. A broken partial duplicate can be missing different pieces in different orders, so never let a script guess what to rebuild. The only write this script ever makes is setting active to 0 on a product it classified as a suspect, and only after a human turns dry run off. It never deletes, never recreates combinations, and never touches the source product.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every suspect duplicate it finds with the reason, respects the dry run flag, and the only write it can make is deactivating a broken product so it cannot be sold in its broken state.
"""Find, and only on explicit confirmation deactivate, PrestaShop products
left broken by a product duplication that errored out partway through.
PrestaShop's AdminProductsController::processDuplicate and Product::duplicateProduct
run as a long, non-transactional sequence of separate INSERT operations: the base
product row first, then a loop over combinations, features, images, accessories,
tags, and specific prices. If any single step throws, PrestaShop shows a 500 error
but never rolls back the new product row already committed in the first step. This
is documented across multiple versions (GitHub issues #19053, #19574, #31737).
This script pulls recently created products through the Webservice API, fetches
each candidate's combinations, features, and stock_availables rows, and classifies
the shape of the damage with a pure decision function. By default it only reports.
Set DRY_RUN=false to let it deactivate (active=0) a product it classified as a
suspect partial duplicate. It never deletes a product and never tries to recreate
missing combinations, features, or images.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_broken_duplicates")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
DATE_FROM = os.environ.get("DATE_FROM", "2000-01-01")
DATE_TO = os.environ.get("DATE_TO", "2100-01-01")
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 _is_copy(product):
"""A product PrestaShop's duplicate action produced carries the string
'copy' PrestaShop appends to the reference, or a name ending in (copy)."""
for field in ("reference", "name"):
value = str(product.get(field) or "").strip().lower()
if value.endswith("(copy)") or "copy" in value:
return True
return False
def _has_orphaned_stock(combinations, stock_rows):
"""A combination whose id_product_attribute has no matching
stock_availables row means the duplication died between the combination
insert and the stock/attribute-value linkage step."""
stocked_attrs = {row.get("id_product_attribute") for row in stock_rows}
for combo in combinations:
if combo.get("id") not in stocked_attrs:
return True
return False
def classify_duplicate_integrity(product, combinations, features, stock_rows,
sibling_combination_count=None):
"""Pure decision logic, no I/O. Takes already-fetched API JSON fragments
and returns one of:
OK, MISSING_COMBINATIONS, MISSING_FEATURES, ORPHANED_STOCK,
SUSPECT_PARTIAL_DUPLICATE
product: the /api/products/{id} JSON body (has 'reference', 'active', etc,
plus an optional 'expected_features' hint from the caller).
combinations: list of /api/combinations entries filtered by id_product.
features: product['associations']['product_features'] equivalent,
pre-extracted list.
stock_rows: list of /api/stock_availables entries filtered by id_product.
sibling_combination_count: expected combo count from the presumed source
product, if known.
"""
is_copy = _is_copy(product)
if is_copy and len(combinations) == 0 and (sibling_combination_count or 0) > 0:
return "MISSING_COMBINATIONS"
if is_copy and len(features) == 0 and product.get("expected_features"):
return "MISSING_FEATURES"
if combinations and _has_orphaned_stock(combinations, stock_rows):
return "ORPHANED_STOCK"
if (is_copy and sibling_combination_count is not None
and len(combinations) < sibling_combination_count):
return "SUSPECT_PARTIAL_DUPLICATE"
return "OK"
def recent_products(date_from, date_to):
data = api_get("products", {
"filter[date_add]": f"[{date_from},{date_to}]",
"display": "full",
"limit": "200",
})
return data.get("products") or []
def combinations_for(id_product):
data = api_get("combinations", {
"filter[id_product]": id_product,
"display": "full",
})
return data.get("combinations") or []
def stock_rows_for(id_product):
data = api_get("stock_availables", {
"filter[id_product]": id_product,
"display": "full",
})
return data.get("stock_availables") or []
def features_for(product):
associations = product.get("associations") or {}
return associations.get("product_features") or []
def deactivate(product):
body = dict(product)
body["active"] = "0"
id_product = product["id"]
log.warning("Deactivating suspect duplicate product %s", id_product)
if not DRY_RUN:
api_put(f"products/{id_product}", {"product": body})
def run():
candidates = recent_products(DATE_FROM, DATE_TO)
flagged = 0
for product in candidates:
id_product = product["id"]
combinations = combinations_for(id_product)
features = features_for(product)
stock_rows = stock_rows_for(id_product)
verdict = classify_duplicate_integrity(product, combinations, features, stock_rows)
if verdict == "OK":
continue
flagged += 1
print(json.dumps({
"id_product": id_product,
"reference": product.get("reference"),
"date_add": product.get("date_add"),
"verdict": verdict,
"combinations_found": len(combinations),
"features_found": len(features),
"stock_rows_found": len(stock_rows),
}))
if not DRY_RUN:
deactivate(product)
log.info("Done. %d suspect duplicate(s) found among %d recent product(s).",
flagged, len(candidates))
if __name__ == "__main__":
run()
/**
* Find, and only on explicit confirmation deactivate, PrestaShop products
* left broken by a product duplication that errored out partway through.
*
* PrestaShop's AdminProductsController::processDuplicate and
* Product::duplicateProduct run as a long, non-transactional sequence of
* separate INSERT operations: the base product row first, then a loop over
* combinations, features, images, accessories, tags, and specific prices. If
* any single step throws, PrestaShop shows a 500 error but never rolls back
* the new product row already committed in the first step. This is
* documented across multiple versions (GitHub issues #19053, #19574, #31737).
*
* This script pulls recently created products through the Webservice API,
* fetches each candidate's combinations, features, and stock_availables
* rows, and classifies the shape of the damage with a pure decision
* function. By default it only reports. Set DRY_RUN=false to let it
* deactivate (active=0) a product it classified as a suspect partial
* duplicate. It never deletes a product and never tries to recreate missing
* combinations, features, or images.
*
* Guide: https://www.allanninal.dev/prestashop/broken-product-duplicate-after-error/
*/
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 DATE_FROM = process.env.DATE_FROM || "2000-01-01";
const DATE_TO = process.env.DATE_TO || "2100-01-01";
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
function isCopy(product) {
for (const field of ["reference", "name"]) {
const value = String(product[field] || "").trim().toLowerCase();
if (value.endsWith("(copy)") || value.includes("copy")) return true;
}
return false;
}
function hasOrphanedStock(combinations, stockRows) {
const stockedAttrs = new Set(stockRows.map((row) => row.id_product_attribute));
return combinations.some((combo) => !stockedAttrs.has(combo.id));
}
/**
* Pure decision logic, no I/O. Takes already-fetched API JSON fragments and
* returns one of: OK, MISSING_COMBINATIONS, MISSING_FEATURES,
* ORPHANED_STOCK, SUSPECT_PARTIAL_DUPLICATE.
*/
export function classifyDuplicateIntegrity(product, combinations, features, stockRows, siblingCombinationCount = null) {
const copy = isCopy(product);
if (copy && combinations.length === 0 && (siblingCombinationCount || 0) > 0) {
return "MISSING_COMBINATIONS";
}
if (copy && features.length === 0 && product.expected_features) {
return "MISSING_FEATURES";
}
if (combinations.length && hasOrphanedStock(combinations, stockRows)) {
return "ORPHANED_STOCK";
}
if (copy && siblingCombinationCount !== null && combinations.length < siblingCombinationCount) {
return "SUSPECT_PARTIAL_DUPLICATE";
}
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 recentProducts(dateFrom, dateTo) {
const data = await apiGet("products", {
"filter[date_add]": `[${dateFrom},${dateTo}]`,
display: "full",
limit: "200",
});
return data.products || [];
}
async function combinationsFor(idProduct) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
display: "full",
});
return data.combinations || [];
}
async function stockRowsFor(idProduct) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
display: "full",
});
return data.stock_availables || [];
}
function featuresFor(product) {
return (product.associations && product.associations.product_features) || [];
}
async function deactivate(product) {
const body = { ...product, active: "0" };
console.warn(`Deactivating suspect duplicate product ${product.id}`);
if (!DRY_RUN) {
await apiPut(`products/${product.id}`, { product: body });
}
}
export async function run() {
const candidates = await recentProducts(DATE_FROM, DATE_TO);
let flagged = 0;
for (const product of candidates) {
const combinations = await combinationsFor(product.id);
const features = featuresFor(product);
const stockRows = await stockRowsFor(product.id);
const verdict = classifyDuplicateIntegrity(product, combinations, features, stockRows);
if (verdict === "OK") continue;
flagged++;
console.log(JSON.stringify({
id_product: product.id,
reference: product.reference,
date_add: product.date_add,
verdict,
combinations_found: combinations.length,
features_found: features.length,
stock_rows_found: stockRows.length,
}));
if (!DRY_RUN) await deactivate(product);
}
console.log(`Done. ${flagged} suspect duplicate(s) found among ${candidates.length} recent product(s).`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification decision is the part most worth testing, because it decides which products get reported as broken and, once dry run is off, which one gets deactivated. Because we kept classify_duplicate_integrity pure, the test needs no network and no PrestaShop store. It just feeds in plain dictionaries and checks the answer.
from find_broken_duplicates import classify_duplicate_integrity
def product(**over):
base = {"id": 1, "reference": "SKU-1 (copy)", "name": "Widget (copy)", "active": True}
base.update(over)
return base
def combo(id_):
return {"id": id_, "id_product_attribute": id_}
def stock(id_product_attribute):
return {"id_product_attribute": id_product_attribute}
def test_ok_when_not_a_copy_and_nothing_missing():
assert classify_duplicate_integrity(
product(reference="SKU-1", name="Widget"), [combo(1)], [], [stock(1)]
) == "OK"
def test_missing_combinations_when_copy_has_none_but_sibling_did():
result = classify_duplicate_integrity(product(), [], [], [], sibling_combination_count=3)
assert result == "MISSING_COMBINATIONS"
def test_missing_features_when_copy_has_none_but_expected_some():
result = classify_duplicate_integrity(
product(expected_features=True), [], [], []
)
assert result == "MISSING_FEATURES"
def test_orphaned_stock_when_combination_lacks_matching_stock_row():
result = classify_duplicate_integrity(
product(), [combo(1), combo(2)], [], [stock(1)]
)
assert result == "ORPHANED_STOCK"
def test_suspect_partial_duplicate_when_fewer_combinations_than_sibling():
result = classify_duplicate_integrity(
product(), [combo(1)], [], [stock(1)], sibling_combination_count=3
)
assert result == "SUSPECT_PARTIAL_DUPLICATE"
def test_ok_when_copy_but_combination_count_matches_sibling():
result = classify_duplicate_integrity(
product(), [combo(1), combo(2)], [], [stock(1), stock(2)], sibling_combination_count=2
)
assert result == "OK"
def test_not_a_copy_is_never_flagged_even_with_fewer_combinations():
result = classify_duplicate_integrity(
product(reference="SKU-1", name="Widget"), [], [], [], sibling_combination_count=3
)
assert result == "OK"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyDuplicateIntegrity } from "./find-broken-duplicates.js";
const product = (over = {}) => ({ id: 1, reference: "SKU-1 (copy)", name: "Widget (copy)", active: true, ...over });
const combo = (id) => ({ id, id_product_attribute: id });
const stock = (idProductAttribute) => ({ id_product_attribute: idProductAttribute });
test("OK when not a copy and nothing missing", () => {
const result = classifyDuplicateIntegrity(product({ reference: "SKU-1", name: "Widget" }), [combo(1)], [], [stock(1)]);
assert.equal(result, "OK");
});
test("MISSING_COMBINATIONS when copy has none but sibling did", () => {
const result = classifyDuplicateIntegrity(product(), [], [], [], 3);
assert.equal(result, "MISSING_COMBINATIONS");
});
test("MISSING_FEATURES when copy has none but expected some", () => {
const result = classifyDuplicateIntegrity(product({ expected_features: true }), [], [], []);
assert.equal(result, "MISSING_FEATURES");
});
test("ORPHANED_STOCK when a combination lacks a matching stock row", () => {
const result = classifyDuplicateIntegrity(product(), [combo(1), combo(2)], [], [stock(1)]);
assert.equal(result, "ORPHANED_STOCK");
});
test("SUSPECT_PARTIAL_DUPLICATE when fewer combinations than sibling", () => {
const result = classifyDuplicateIntegrity(product(), [combo(1)], [], [stock(1)], 3);
assert.equal(result, "SUSPECT_PARTIAL_DUPLICATE");
});
test("OK when copy but combination count matches sibling", () => {
const result = classifyDuplicateIntegrity(product(), [combo(1), combo(2)], [], [stock(1), stock(2)], 2);
assert.equal(result, "OK");
});
test("not a copy is never flagged even with fewer combinations", () => {
const result = classifyDuplicateIntegrity(product({ reference: "SKU-1", name: "Widget" }), [], [], [], 3);
assert.equal(result, "OK");
});
Case studies
A clone that was still active with zero variants
An apparel store duplicated a twelve-variant product across a multistore setup. A missing shop context on the new row threw a duplicate-key error on product_default a few iterations into the combinations loop, and the admin screen showed a plain 500 page. Staff assumed nothing happened and tried again, creating a second broken clone before anyone noticed the first one had gone live with zero variants and a generic placeholder price.
Running the detection script against products created that day found both clones instantly, each flagged MISSING_COMBINATIONS against a sibling count of twelve. A merchandiser deactivated both from the report and rebuilt the one variant set they actually needed by hand.
Orphaned stock rows nobody would have found by browsing
A home goods retailer with thousands of SKUs duplicated products regularly to spin up seasonal variants. A handful of duplications died between the combination insert and the stock linkage step, most likely from timeouts on products with many combinations and images, leaving combinations with no matching stock_availables row. Storefront pages loaded fine, but checkout failed unpredictably for a specific size or color.
A weekly scheduled run of the script caught the ORPHANED_STOCK cases well before they became a pattern of failed checkouts, letting the catalog team fix stock rows for the specific combinations affected instead of hunting through support tickets.
Run on a schedule, this turns a silent, half-built product into a same-day, precisely labeled report: which product, which kind of damage, and the exact counts that triggered it. Nothing is rebuilt or deleted automatically. A human who understands the source product decides whether to finish the duplication by hand, deactivate the broken row, or remove it, and the script's only possible write, deactivating, only happens once they say so.
FAQ
Why does duplicating a PrestaShop product sometimes leave a broken copy behind?
PrestaShop duplicates a product as a long sequence of separate INSERT operations, one for the base product, then more for combinations, features, images, and other data, with no database transaction wrapping the whole thing. If any single step throws, PrestaShop shows a 500 error, but the new product row created in the first step is never rolled back, so a partial, often still active, product is left in the catalog.
Can I safely auto-fix a broken duplicate product with a script?
No, not by recreating the missing pieces automatically. A partial duplicate can be missing combinations, features, images, or stock rows in many different combinations, and guessing what to rebuild is unsafe. The safe pattern is to report the exact suspect product and its missing counts by default, and only deactivate it, never delete it, when a human explicitly turns off dry run.
How do I find products broken by a failed duplication after the fact?
Look at recently created products through GET /api/products filtered by date_add, since duplication always creates a brand-new id_product with a fresh date_add. A product whose reference contains the copy marker PrestaShop appends, but whose combination, feature, or stock_availables counts are lower than a sibling product's, is a strong signal that the duplication died partway through.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Duplicate product result in 500 error page (product is still duplicated). github.com/PrestaShop/PrestaShop/issues/19053
- PrestaShop GitHub: Duplicate combination product results in 500 error page (product is still duplicated). github.com/PrestaShop/PrestaShop/issues/19574
- PrestaShop GitHub: Duplicate product fails when using PDO and there are multiple rows for duplicated product in the product_group_reduction_cache. github.com/PrestaShop/PrestaShop/issues/31737
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: additional list parameters for filtering and display. devdocs.prestashop-project.org/8/webservice/tutorials/advanced-use/additional-list-parameters
Stuck on a tricky one?
If you have a problem in PrestaShop catalog data, products, orders, or the Webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a broken clone before a customer did?
If this saved you a confusing storefront bug or a checkout failure, 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