Diagnostic Catalog & Products
Duplicate product reference or SKU allowed across different products
Someone creates a new product, or clicks Duplicate product on an existing one, types or copies a reference, and saves. PrestaShop takes it without complaint, even though that exact reference already sits on a completely different product somewhere else in the catalog. Now your accounting export, your marketplace feed, and your barcode scanner cannot tell the two products apart. Here is why PrestaShop lets this happen and a small script that finds every collision so a human can decide what to do about it.
ps_product.reference has no unique, or even indexed-unique, database constraint, and none of the back office product form, the Duplicate product action, or the Webservice API check other products before saving. So two different id_product rows can carry the identical reference string indefinitely. Run a small Python or Node.js script that pulls the catalog through GET /api/products, groups products by their normalized reference, skips blank references, and flags any reference used by more than one product id as a collision. Full code, tests, and a dry run guard are below.
The problem in plain words
A reference or SKU is supposed to be the one string that uniquely names a product across your whole business: the code your accountant reconciles against, the identifier your marketplace listing uses, the number printed on the barcode a warehouse scanner reads. That only works if it is actually unique.
PrestaShop never enforces that. The ps_product table stores reference as a plain column with no unique key behind it. You can type the same reference into two different products in the back office, one after another, and both saves succeed. You can duplicate a product with PrestaShop's own Duplicate product action, and the clone keeps the exact same reference as the original until someone remembers to change it. Nothing in the save path looks at any other row in the table first.
Why it happens
The root cause sits at the database layer, and every write path inherits it. A few ways stores end up with real collisions:
- A staff member manually types a reference on a new product without checking whether it is already used somewhere else in a large catalog.
- PrestaShop's own Duplicate product action clones a product, including its reference, and the clone is saved before anyone edits the copied value.
- A bulk import or a Webservice API integration pushes products from an external system whose own SKUs are not de-duplicated before they reach PrestaShop.
- Two people, or two integrations, create products at nearly the same time using a shared naming convention that happens to collide.
This is a known, unaddressed gap, not a misconfiguration on your store. PrestaShop's own bug tracker confirms there is no native way to prevent a duplicate reference or SKU on save, and forum threads going back years ask for the feature to be added natively. See the citations at the end for the exact reports and threads.
A reference is a promise to the rest of your business that this string means one specific product. Once two products share it, you cannot fix that by guessing which one is wrong. The safe move is to enumerate the whole catalog, group by reference, and report every case where more than one product id shares a string, then let a human who knows which external system depends on which reference decide the rename.
The fix, as a flow
We do not touch the back office form or the Duplicate product action. We add a read-only job that pulls the catalog through the Webservice API, groups products by a normalized reference, and reports every group with more than one product id. Blank references are skipped since PrestaShop allows and commonly has many of them. Nothing is renamed until a human supplies an explicit resolution and DRY_RUN is turned off.
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 and combinations 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 the catalog with references
Ask only for the fields the decision needs: id, reference, name, and active. Use display=full&limit=0, or page through with limit, so a large catalog does not time out in one request.
def all_products():
data = api_get("products", {
"display": "[id,reference,name,active]",
"limit": "0",
})
return data.get("products") or []
def all_combinations():
data = api_get("combinations", {
"display": "[id,id_product,reference]",
})
return data.get("combinations") or []
async function allProducts() {
const data = await apiGet("products", {
display: "[id,reference,name,active]",
limit: "0",
});
return data.products || [];
}
async function allCombinations() {
const data = await apiGet("combinations", {
display: "[id,id_product,reference]",
});
return data.combinations || [];
}
Decide, with one pure function
The decision that matters is grouping products by a normalized reference and keeping only the groups with more than one distinct product id. A blank reference is not a collision, since PrestaShop allows and commonly has many blank references, so we strip whitespace and skip an empty result before grouping. Keeping this pure and free of any HTTP call means we can test it with plain lists.
def find_reference_collisions(products):
groups = {}
for product in products:
ref = (product.get("reference") or "").strip()
if not ref:
continue
groups.setdefault(ref, []).append(product)
collisions = {}
for ref, group in groups.items():
if len(group) <= 1:
continue
collisions[ref] = sorted(group, key=lambda p: int(p["id"]))
return collisions
export function findReferenceCollisions(products) {
const groups = new Map();
for (const product of products) {
const ref = (product.reference || "").trim();
if (!ref) continue;
if (!groups.has(ref)) groups.set(ref, []);
groups.get(ref).push(product);
}
const collisions = {};
for (const [ref, group] of groups) {
if (group.length <= 1) continue;
collisions[ref] = [...group].sort((a, b) => Number(a.id) - Number(b.id));
}
return collisions;
}
Cross-check combination references too
PrestaShop does not enforce uniqueness on combination-level references either, so a variant on one product can collide with a variant on another. Run the same grouping logic against combinations, keyed by id_product instead of the bare combination id, so the report tells you which products are actually involved.
def find_combination_reference_collisions(combinations):
normalized = [
{"id": c["id"], "reference": c.get("reference"), "name": f"product {c['id_product']}", "active": True}
for c in combinations
]
return find_reference_collisions(normalized)
export function findCombinationReferenceCollisions(combinations) {
const normalized = combinations.map((c) => ({
id: c.id,
reference: c.reference,
name: `product ${c.id_product}`,
active: true,
}));
return findReferenceCollisions(normalized);
}
Report by default, rewrite only when a human approves it
By default the job only logs each collision as JSON lines, with the reference and the colliding product ids and names, so a human can decide which product keeps the string and what new reference each other one should get. Only when DRY_RUN is false and an operator supplies that resolution map does it fetch the current product body with GET /api/products/{id}, change only reference, and PUT the full body back, since PrestaShop's Webservice PUT requires the complete resource, not a partial patch.
Always start with DRY_RUN=true. Never let a script guess which product keeps the reference and rename the other automatically. References map to accounting, POS, marketplace, and barcode systems, so a wrong rename can break a mapping that was correct. The script only writes when you supply the resolution map yourself, and it never deletes or merges products.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every collision it finds, respects the dry run flag, and only ever renames a reference for a product id you explicitly listed in the resolution map.
"""Find, and only on explicit confirmation rewrite, duplicate PrestaShop
product references or SKUs across different products.
ps_product.reference has no unique, or even indexed-unique, database
constraint. The back office product form, the Duplicate product action, and
the Webservice API layer never check other rows before INSERT/UPDATE, so two
different id_product rows can carry the identical reference string
indefinitely. This is a known, unaddressed gap tracked on PrestaShop's own
bug tracker (GitHub #13413).
This script pulls the catalog through the Webservice API, groups products by
a normalized (trimmed) reference, skips blank references since PrestaShop
allows and commonly has many, and reports every reference used by more than
one product id. It optionally cross-checks combinations, since PrestaShop
does not enforce uniqueness there either. By default it only reports. Set
DRY_RUN=false and supply RESOLUTION_MAP (a JSON object of {"id": "new
reference"}) to let it PUT a renamed reference for the ids you name. It never
renames the product you did not name, never merges, and never deletes.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_reference_collisions")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RESOLUTION_MAP = json.loads(os.environ.get("RESOLUTION_MAP", "{}"))
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 find_reference_collisions(products):
"""products: list of {"id": int, "reference": str, "name": str, "active": bool}
as returned by GET /api/products?display=[id,reference,name,active].
Normalize reference by stripping whitespace, skip entries whose
normalized reference is empty, group the rest by that string, and return
only the groups where the same reference is attached to two or more
distinct product ids, sorted by id. Pure: no HTTP calls, no side effects.
Empty dict means no collisions.
"""
groups = {}
for product in products:
ref = (product.get("reference") or "").strip()
if not ref:
continue
groups.setdefault(ref, []).append(product)
collisions = {}
for ref, group in groups.items():
if len(group) <= 1:
continue
collisions[ref] = sorted(group, key=lambda p: int(p["id"]))
return collisions
def find_combination_reference_collisions(combinations):
normalized = [
{"id": c["id"], "reference": c.get("reference"), "name": f"product {c['id_product']}", "active": True}
for c in combinations
]
return find_reference_collisions(normalized)
def all_products():
data = api_get("products", {
"display": "[id,reference,name,active]",
"limit": "0",
})
return data.get("products") or []
def all_combinations():
data = api_get("combinations", {
"display": "[id,id_product,reference]",
})
return data.get("combinations") or []
def apply_resolution(id_product, new_reference):
current = api_get(f"products/{id_product}")["product"]
current["reference"] = new_reference
log.info("Renaming product %s reference to %s", id_product, new_reference)
if not DRY_RUN:
api_put(f"products/{id_product}", {"product": current})
def run():
products = all_products()
combinations = all_combinations()
product_collisions = find_reference_collisions(products)
combo_collisions = find_combination_reference_collisions(combinations)
for ref, group in product_collisions.items():
print(json.dumps({
"reference": ref,
"colliding_ids": [p["id"] for p in group],
"names": [p["name"] for p in group],
}))
for ref, group in combo_collisions.items():
print(json.dumps({
"combination_reference": ref,
"colliding_ids": [c["id"] for c in group],
"products": [c["name"] for c in group],
}))
if not DRY_RUN and RESOLUTION_MAP:
for id_product, new_reference in RESOLUTION_MAP.items():
apply_resolution(id_product, new_reference)
log.info(
"Done. %d product reference collision(s), %d combination reference collision(s).",
len(product_collisions), len(combo_collisions),
)
if __name__ == "__main__":
run()
/**
* Find, and only on explicit confirmation rewrite, duplicate PrestaShop
* product references or SKUs across different products.
*
* ps_product.reference has no unique, or even indexed-unique, database
* constraint. The back office product form, the Duplicate product action, and
* the Webservice API layer never check other rows before INSERT/UPDATE, so two
* different id_product rows can carry the identical reference string
* indefinitely. This is a known, unaddressed gap tracked on PrestaShop's own
* bug tracker (GitHub #13413).
*
* This script pulls the catalog through the Webservice API, groups products
* by a normalized (trimmed) reference, skips blank references since
* PrestaShop allows and commonly has many, and reports every reference used
* by more than one product id. By default it only reports. Set DRY_RUN=false
* and supply RESOLUTION_MAP (a JSON object of {"id": "new reference"}) to let
* it PUT a renamed reference for the ids you name. It never renames a
* product you did not name, never merges, and never deletes.
*
* Guide: https://www.allanninal.dev/prestashop/duplicate-product-reference-sku/
*/
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 RESOLUTION_MAP = JSON.parse(process.env.RESOLUTION_MAP || "{}");
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
/**
* products: list of {id, reference, name, active} as returned by
* GET /api/products?display=[id,reference,name,active].
*
* Normalize reference by trimming, skip entries whose normalized reference
* is empty, group the rest by that string, and return only the groups where
* the same reference is attached to two or more distinct product ids, sorted
* by id. Pure: no network, no side effects. Empty object means no collisions.
*/
export function findReferenceCollisions(products) {
const groups = new Map();
for (const product of products) {
const ref = (product.reference || "").trim();
if (!ref) continue;
if (!groups.has(ref)) groups.set(ref, []);
groups.get(ref).push(product);
}
const collisions = {};
for (const [ref, group] of groups) {
if (group.length <= 1) continue;
collisions[ref] = [...group].sort((a, b) => Number(a.id) - Number(b.id));
}
return collisions;
}
export function findCombinationReferenceCollisions(combinations) {
const normalized = combinations.map((c) => ({
id: c.id,
reference: c.reference,
name: `product ${c.id_product}`,
active: true,
}));
return findReferenceCollisions(normalized);
}
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 allProducts() {
const data = await apiGet("products", {
display: "[id,reference,name,active]",
limit: "0",
});
return data.products || [];
}
async function allCombinations() {
const data = await apiGet("combinations", {
display: "[id,id_product,reference]",
});
return data.combinations || [];
}
async function applyResolution(idProduct, newReference) {
const current = (await apiGet(`products/${idProduct}`)).product;
current.reference = newReference;
console.log(`Renaming product ${idProduct} reference to ${newReference}`);
if (!DRY_RUN) {
await apiPut(`products/${idProduct}`, { product: current });
}
}
export async function run() {
const products = await allProducts();
const combinations = await allCombinations();
const productCollisions = findReferenceCollisions(products);
const comboCollisions = findCombinationReferenceCollisions(combinations);
for (const [ref, group] of Object.entries(productCollisions)) {
console.log(JSON.stringify({
reference: ref,
colliding_ids: group.map((p) => p.id),
names: group.map((p) => p.name),
}));
}
for (const [ref, group] of Object.entries(comboCollisions)) {
console.log(JSON.stringify({
combination_reference: ref,
colliding_ids: group.map((c) => c.id),
products: group.map((c) => c.name),
}));
}
if (!DRY_RUN && Object.keys(RESOLUTION_MAP).length) {
for (const [idProduct, newReference] of Object.entries(RESOLUTION_MAP)) {
await applyResolution(idProduct, newReference);
}
}
console.log(
`Done. ${Object.keys(productCollisions).length} product reference collision(s), ` +
`${Object.keys(comboCollisions).length} combination reference collision(s).`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The grouping decision is the part most worth testing, because it decides which references get reported as collisions and which product ids are named in the report. Because we kept find_reference_collisions pure, the test needs no network and no PrestaShop store. It just feeds in plain lists and checks the answer.
from find_reference_collisions import find_reference_collisions, find_combination_reference_collisions
def product(**over):
base = {"id": 1, "reference": "SKU-123", "name": "Widget", "active": True}
base.update(over)
return base
def test_no_collision_when_references_are_unique():
products = [product(id=1, reference="SKU-1"), product(id=2, reference="SKU-2")]
assert find_reference_collisions(products) == {}
def test_finds_collision_for_same_reference_on_two_ids():
products = [product(id=45, name="Red shirt"), product(id=812, name="Blue shirt")]
result = find_reference_collisions(products)
assert list(result.keys()) == ["SKU-123"]
assert [p["id"] for p in result["SKU-123"]] == [45, 812]
def test_blank_reference_is_not_a_collision():
products = [product(id=1, reference=""), product(id=2, reference="")]
assert find_reference_collisions(products) == {}
def test_whitespace_only_reference_is_treated_as_blank():
products = [product(id=1, reference=" "), product(id=2, reference=" ")]
assert find_reference_collisions(products) == {}
def test_reference_is_normalized_by_trimming_before_grouping():
products = [product(id=1, reference="SKU-123"), product(id=2, reference=" SKU-123 ")]
result = find_reference_collisions(products)
assert len(result["SKU-123"]) == 2
def test_single_product_with_a_reference_is_not_a_collision():
products = [product(id=1, reference="SKU-1")]
assert find_reference_collisions(products) == {}
def test_combination_references_are_grouped_the_same_way():
combinations = [
{"id": 10, "id_product": 5, "reference": "VAR-9"},
{"id": 11, "id_product": 6, "reference": "VAR-9"},
]
result = find_combination_reference_collisions(combinations)
assert list(result.keys()) == ["VAR-9"]
assert [c["id"] for c in result["VAR-9"]] == [10, 11]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findReferenceCollisions, findCombinationReferenceCollisions } from "./find-reference-collisions.js";
const product = (over = {}) => ({ id: 1, reference: "SKU-123", name: "Widget", active: true, ...over });
test("no collision when references are unique", () => {
const products = [product({ id: 1, reference: "SKU-1" }), product({ id: 2, reference: "SKU-2" })];
assert.deepEqual(findReferenceCollisions(products), {});
});
test("finds a collision for the same reference on two ids", () => {
const products = [product({ id: 45, name: "Red shirt" }), product({ id: 812, name: "Blue shirt" })];
const result = findReferenceCollisions(products);
assert.deepEqual(Object.keys(result), ["SKU-123"]);
assert.deepEqual(result["SKU-123"].map((p) => p.id), [45, 812]);
});
test("blank reference is not a collision", () => {
const products = [product({ id: 1, reference: "" }), product({ id: 2, reference: "" })];
assert.deepEqual(findReferenceCollisions(products), {});
});
test("whitespace-only reference is treated as blank", () => {
const products = [product({ id: 1, reference: " " }), product({ id: 2, reference: " " })];
assert.deepEqual(findReferenceCollisions(products), {});
});
test("reference is normalized by trimming before grouping", () => {
const products = [product({ id: 1, reference: "SKU-123" }), product({ id: 2, reference: " SKU-123 " })];
const result = findReferenceCollisions(products);
assert.equal(result["SKU-123"].length, 2);
});
test("a single product with a reference is not a collision", () => {
const products = [product({ id: 1, reference: "SKU-1" })];
assert.deepEqual(findReferenceCollisions(products), {});
});
test("combination references are grouped the same way", () => {
const combinations = [
{ id: 10, id_product: 5, reference: "VAR-9" },
{ id: 11, id_product: 6, reference: "VAR-9" },
];
const result = findCombinationReferenceCollisions(combinations);
assert.deepEqual(Object.keys(result), ["VAR-9"]);
assert.deepEqual(result["VAR-9"].map((c) => c.id), [10, 11]);
});
Case studies
A clone that never got a new SKU
A homeware store used Duplicate product to spin up seasonal color variants of an existing item, planning to edit the reference on each clone afterward. One clone shipped to the storefront before anyone touched its reference field, so two active products sat in the catalog sharing the exact same SKU, and the accounting export started attributing sales to whichever product id it matched first.
Running the collision report against the full catalog surfaced the pair immediately, with both product names and ids side by side. The catalog manager confirmed which one was the true owner of the original SKU, picked a new reference for the clone, and applied it through the resolution map. A re-run confirmed the group had exactly one id left.
Two supplier feeds using overlapping SKU ranges
A multi-brand retailer imported products from two supplier feeds through the Webservice API, each using its own internal numbering that happened to overlap in a handful of cases. Both imports succeeded silently, since PrestaShop never checked one feed's references against the other's.
A weekly scheduled run of the collision report caught the overlaps within days instead of surfacing as a mispicked order at the warehouse. Because references there mapped to two different supplier systems, a human decided per case which supplier's product kept the original code and which product got a new one, then approved the resolution map before any write happened.
Run on a schedule, this turns a silent catalog defect into a same-day, named list of exactly which product ids collide on which reference. Nothing is renamed until a human who knows what each reference maps to in accounting, POS, or a marketplace approves the new value, so the fix never breaks a mapping that was already correct.
FAQ
Why does PrestaShop let two products share the same reference or SKU?
The reference column on ps_product has no unique or even indexed-unique database constraint, and neither the back office product form, the Duplicate product action, nor the Webservice API check other rows before saving. So a new product or a duplicated one can be saved with a reference that already exists on a different id_product, and nothing stops it.
Can I safely auto-fix duplicate references with a script?
No, not automatically. A reference often maps to an external system such as accounting, a POS, a marketplace listing, or a barcode, so silently rewriting it could break a mapping that is actually correct elsewhere. The safe pattern is a read-only report by default, and a write only when a human supplies an explicit resolution naming which product keeps the reference and what new reference each other product gets.
Do blank product references count as a collision?
No. PrestaShop allows and commonly has many products with an empty reference field, so an empty string is not a meaningful collision and should be excluded from grouping. Only two or more distinct product ids sharing the same non-empty reference string count as a real duplicate.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: how to prevent duplicate reference no. / SKU entry in product creation in PrestaShop 1.7. github.com/PrestaShop/PrestaShop/issues/13413
- PrestaShop Forums: Any way to avoid duplicate SKU (product reference)? prestashop.com/forums/topic/225374
- PrestaShop Forums: Product's Reference code field to be required and unique. prestashop.com/forums/topic/395050
On the solution:
- PrestaShop Developer Documentation: the Webservice API. devdocs.prestashop-project.org/8/webservice
- PrestaShop Developer Documentation: the products resource reference. devdocs.prestashop-project.org/9/webservice/reference
- 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 untangle your duplicate SKUs?
If this saved you a confusing accounting reconciliation or a mispicked order, 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