Reconciler Products, Variants and Media
Sync API re-runs create duplicate configurator setting entries in Shopware 6
An external PIM or ERP pushes variant configuration through the Sync API, and it works. Then the same sync runs again the next day, and the same product ends up with two rows for the same option in product_configurator_setting. Run it a third time and there are three. Nothing errors, the storefront still looks fine at first glance, but the table quietly fills with rows that all claim to configure the same option on the same product. Here is why Shopware's upsert lets this happen and a small script that finds every duplicate group so you can clean it up on your terms.
The Sync API's upsert action matches an existing row only by its id. When the system driving the sync never persisted the id Shopware assigned to a product-configurator-setting row the first time, and only tracks productId plus optionId as its own logical key, every re-run looks like a new entity to Shopware, so it inserts a fresh row instead of updating the old one. There is no unique constraint on the combination of productId, productVersionId, and optionId to stop this at the database level, so duplicates for the same product and option pile up with each run. Run a small Python or Node.js script that searches product-configurator-setting, groups the rows by that composite key, and flags every row beyond the oldest one as a duplicate. Deletion is off by default. Full code, tests, and sources are below.
The problem in plain words
A product-configurator-setting row is Shopware's link between a product and one property option it can be configured with, for example a specific size or color that makes up one of its variants. When you sync variant configuration through the Sync API, you send a list of these settings and Shopware is supposed to either update the ones that already exist or create the ones that do not.
The trouble is how Shopware decides "already exists." The Sync API upsert action looks strictly at the id field in the payload. If a row with that id is already in the database, it gets updated. If not, or if no id is sent at all, Shopware generates a new one and inserts a brand new row. Many integrations only know a product and an option as their logical identity, so they never bother storing the UUID Shopware handed back after the first sync. The next time the same integration runs, it sends the same logical data again, Shopware sees no matching id, and it creates another row for the exact same product and option.
Why it happens
This is not a bug in a single sync call, it is a gap between how the Sync API matches rows and what most integrations track as their identity. A few common ways stores end up with duplicate groups:
- A PIM or ERP integration that only stores its own product and option identifiers, and never persists the
idShopware returns for theproduct-configurator-settingrow it created. - A sync job that gets retried after a timeout or a partial failure, resending the same variant configuration payload without an
idbecause the first response was never received. - A migration script that recreates configurator settings from a source system on every run instead of checking what already exists in Shopware first.
- A scheduled full re-sync, meant to be a safety net that keeps configuration current, that runs on every deploy or every night without diffing against the previous state.
Shopware's own storage layer does not stop any of this. There is no unique constraint on the combination of productId, productVersionId, and optionId in the product_configurator_setting table, so the database accepts every duplicate insert without complaint. The problem is tracked upstream as an open issue against Shopware, and it comes up regularly in the Shopware community forum from people integrating variant data through the Sync API. See the citations at the end for the exact threads and docs.
The rows are not corrupted data, they are the predictable result of an upsert that was only ever given half of what it needed to match. Shopware did what the Sync API is documented to do: match by id, and insert when there is no match. So the fix is not to change how the integration syncs. It is to periodically look at what accumulated, group the rows by the real logical key of product and option, and only then decide what should be removed.
The fix, as a flow
We do not change the sync job that is producing the duplicates, and we do not silently delete anything. The script searches product-configurator-setting, groups the rows by the composite key of productId, optionId, and productVersionId, and runs a small pure rule over each group: a group with one row is left alone, and a group with more than one row is reduced to the single oldest row by createdAt, with the rest reported as duplicates. Removal only happens when you explicitly enable it and turn off dry run.
Build it step by step
Get Admin API credentials
Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" # start safe, change to false to apply
export ENABLE_DELETE="false" # must also be true before anything is deleted
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" // start safe, change to false to apply
export ENABLE_DELETE="false" // must also be true before anything is deleted
Authenticate and talk to the Admin API
A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search and for the Sync API delete action.
import os, requests
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Search every product-configurator-setting row
Page through product-configurator-setting with the option association included, sorted by productId, and read total-count-mode:1 so we know when to stop. You can scope this to one product with an equals filter on productId while you are testing, then drop the filter to sweep the whole catalog.
def search_configurator_settings(token, page=1, limit=500, product_id=None):
body = {
"page": page,
"limit": limit,
"associations": {"option": {}},
"sort": [{"field": "productId", "order": "ASC"}],
"total-count-mode": 1,
}
if product_id:
body["filter"] = [{"type": "equals", "field": "productId", "value": product_id}]
return api("POST", "/api/search/product-configurator-setting", token, body)
async function searchConfiguratorSettings(token, page = 1, limit = 500, productId = null) {
const body = {
page,
limit,
associations: { option: {} },
sort: [{ field: "productId", order: "ASC" }],
"total-count-mode": 1,
};
if (productId) {
body.filter = [{ type: "equals", field: "productId", value: productId }];
}
return api("POST", "/api/search/product-configurator-setting", token, body);
}
Group the rows and decide, with one pure function
The heart of this fix is findDuplicateConfiguratorSettings. It takes the already-fetched rows and groups them by the composite key productId:optionId:productVersionId. Inside each group it sorts by createdAt ascending, tie-breaking on the id string if two rows share a timestamp, and keeps the earliest row as the canonical one. Only groups with more than one row show up in the result, each carrying the id to keep and the ids to remove. It does no I/O, so it is easy to test and easy to trust.
def find_duplicate_configurator_settings(rows):
groups = {}
for row in rows:
key = f"{row['productId']}:{row['optionId']}:{row['productVersionId']}"
groups.setdefault(key, []).append(row)
results = []
for key, group_rows in groups.items():
if len(group_rows) <= 1:
continue
ordered = sorted(group_rows, key=lambda r: (r["createdAt"], r["id"]))
keep = ordered[0]
duplicates = ordered[1:]
results.append({
"productId": keep["productId"],
"optionId": keep["optionId"],
"keepId": keep["id"],
"duplicateIds": [r["id"] for r in duplicates],
})
return results
export function findDuplicateConfiguratorSettings(rows) {
const groups = new Map();
for (const row of rows) {
const key = `${row.productId}:${row.optionId}:${row.productVersionId}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
}
const results = [];
for (const groupRows of groups.values()) {
if (groupRows.length <= 1) continue;
const ordered = [...groupRows].sort((a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const [keep, ...duplicates] = ordered;
results.push({
productId: keep.productId,
optionId: keep.optionId,
keepId: keep.id,
duplicateIds: duplicates.map((r) => r.id),
});
}
return results;
}
Report by default, delete only when explicitly enabled
For every group the function returns, log the product, option, the id being kept, and the duplicate ids. Deletion is a separate, deliberate step: it only runs when both ENABLE_DELETE is true and DRY_RUN is false. When it does run, remove the duplicates through the Sync API delete action, POST /api/_action/sync with an entity of product-configurator-setting and action delete, passing the duplicate ids as the payload.
Always start with DRY_RUN=true and ENABLE_DELETE=false. This lets the script only report the duplicate groups it finds. Duplicate removal can affect live storefront configurator rendering and cached variant matrices, so treat every group as a report to review first, and only flip both flags once you have checked that the kept row is truly the one you want to survive.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every duplicate group it finds, respects both the dry run flag and the explicit delete flag, and is safe to run again and again because it only ever proposes removing rows that are not the earliest row in a duplicate group.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 product-configurator-setting rows duplicated by repeat Sync API runs.
The Sync API upsert action matches an existing row only by its id. When an external
system syncs variant configuration without persisting the id Shopware assigned to a
product-configurator-setting row, and only tracks productId plus optionId as its own
logical key, every re-run looks like a new entity, so Shopware inserts a fresh row
instead of updating the one already there. There is no unique constraint on the
combination of productId, productVersionId, and optionId, so duplicates for the same
product and option pile up silently with each run.
This script searches product-configurator-setting, groups the rows by that composite
key, and reports every group with more than one row. Deletion is off by default. It
only runs when ENABLE_DELETE is true and DRY_RUN is false, and even then it only ever
removes the rows that are not the single oldest row in a duplicate group.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_duplicate_configurator_settings")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
PRODUCT_ID = os.environ.get("CONFIGURATOR_PRODUCT_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ENABLE_DELETE = os.environ.get("ENABLE_DELETE", "false").lower() == "true"
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
def search_configurator_settings(token, page=1, limit=500, product_id=None):
body = {
"page": page,
"limit": limit,
"associations": {"option": {}},
"sort": [{"field": "productId", "order": "ASC"}],
"total-count-mode": 1,
}
if product_id:
body["filter"] = [{"type": "equals", "field": "productId", "value": product_id}]
return api("POST", "/api/search/product-configurator-setting", token, body)
def find_duplicate_configurator_settings(rows):
groups = {}
for row in rows:
key = f"{row['productId']}:{row['optionId']}:{row['productVersionId']}"
groups.setdefault(key, []).append(row)
results = []
for key, group_rows in groups.items():
if len(group_rows) <= 1:
continue
ordered = sorted(group_rows, key=lambda r: (r["createdAt"], r["id"]))
keep = ordered[0]
duplicates = ordered[1:]
results.append({
"productId": keep["productId"],
"optionId": keep["optionId"],
"keepId": keep["id"],
"duplicateIds": [r["id"] for r in duplicates],
})
return results
def delete_duplicates(token, duplicate_ids):
payload = [{"entity": "product-configurator-setting", "action": "delete",
"payload": [{"id": dup_id} for dup_id in duplicate_ids]}]
api("POST", "/api/_action/sync", token, payload)
def fetch_all_rows(token):
rows = []
page = 1
while True:
data = search_configurator_settings(token, page=page, product_id=PRODUCT_ID or None)
batch = data.get("data", [])
rows.extend(batch)
if not batch or page * 500 >= data.get("total", 0):
break
page += 1
return rows
def run():
token = get_token()
rows = fetch_all_rows(token)
duplicate_groups = find_duplicate_configurator_settings(rows)
to_delete = 0
deleted = 0
for group in duplicate_groups:
log.warning(
"DUPLICATE productId=%s optionId=%s keep=%s duplicates=%d",
group["productId"], group["optionId"], group["keepId"], len(group["duplicateIds"]),
)
to_delete += len(group["duplicateIds"])
if DRY_RUN or not ENABLE_DELETE:
continue
delete_duplicates(token, group["duplicateIds"])
deleted += len(group["duplicateIds"])
acting = (not DRY_RUN) and ENABLE_DELETE
log.info(
"Done. %d duplicate group(s) found, %d row(s) %s.",
len(duplicate_groups),
deleted if acting else to_delete,
"deleted" if acting else "to delete (report only)",
)
if __name__ == "__main__":
run()
/**
* Find Shopware 6 product-configurator-setting rows duplicated by repeat Sync API runs.
*
* The Sync API upsert action matches an existing row only by its id. When an external
* system syncs variant configuration without persisting the id Shopware assigned to a
* product-configurator-setting row, and only tracks productId plus optionId as its own
* logical key, every re-run looks like a new entity, so Shopware inserts a fresh row
* instead of updating the one already there. There is no unique constraint on the
* combination of productId, productVersionId, and optionId, so duplicates for the same
* product and option pile up silently with each run.
*
* This script searches product-configurator-setting, groups the rows by that composite
* key, and reports every group with more than one row. Deletion is off by default. It
* only runs when ENABLE_DELETE is true and DRY_RUN is false, and even then it only ever
* removes the rows that are not the single oldest row in a duplicate group.
* Run on a schedule. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const PRODUCT_ID = process.env.CONFIGURATOR_PRODUCT_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ENABLE_DELETE = (process.env.ENABLE_DELETE || "false").toLowerCase() === "true";
export function findDuplicateConfiguratorSettings(rows) {
const groups = new Map();
for (const row of rows) {
const key = `${row.productId}:${row.optionId}:${row.productVersionId}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
}
const results = [];
for (const groupRows of groups.values()) {
if (groupRows.length <= 1) continue;
const ordered = [...groupRows].sort((a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const [keep, ...duplicates] = ordered;
results.push({
productId: keep.productId,
optionId: keep.optionId,
keepId: keep.id,
duplicateIds: duplicates.map((r) => r.id),
});
}
return results;
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function searchConfiguratorSettings(token, page = 1, limit = 500, productId = null) {
const body = {
page,
limit,
associations: { option: {} },
sort: [{ field: "productId", order: "ASC" }],
"total-count-mode": 1,
};
if (productId) {
body.filter = [{ type: "equals", field: "productId", value: productId }];
}
return api("POST", "/api/search/product-configurator-setting", token, body);
}
async function deleteDuplicates(token, duplicateIds) {
const payload = [{
entity: "product-configurator-setting",
action: "delete",
payload: duplicateIds.map((id) => ({ id })),
}];
await api("POST", "/api/_action/sync", token, payload);
}
async function fetchAllRows(token) {
const rows = [];
let page = 1;
while (true) {
const data = await searchConfiguratorSettings(token, page, 500, PRODUCT_ID || null);
const batch = data.data || [];
rows.push(...batch);
if (batch.length === 0 || page * 500 >= (data.total || 0)) break;
page++;
}
return rows;
}
export async function run() {
const token = await getToken();
const rows = await fetchAllRows(token);
const duplicateGroups = findDuplicateConfiguratorSettings(rows);
let toDelete = 0;
let deleted = 0;
for (const group of duplicateGroups) {
console.warn(
`DUPLICATE productId=${group.productId} optionId=${group.optionId} keep=${group.keepId} duplicates=${group.duplicateIds.length}`
);
toDelete += group.duplicateIds.length;
if (DRY_RUN || !ENABLE_DELETE) continue;
await deleteDuplicates(token, group.duplicateIds);
deleted += group.duplicateIds.length;
}
const acting = !DRY_RUN && ENABLE_DELETE;
console.log(
`Done. ${duplicateGroups.length} duplicate group(s) found, ${acting ? deleted : toDelete} row(s) ${acting ? "deleted" : "to delete (report only)"}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The grouping and keep-or-remove decision is the part most worth testing, because it decides which rows are ever proposed for deletion. Because findDuplicateConfiguratorSettings is pure, no network and no Shopware instance is needed. It just takes a plain array of rows and checks the answer.
from find_duplicate_configurator_settings import find_duplicate_configurator_settings
PRODUCT = "a" * 32
OPTION = "b" * 32
VERSION = "c" * 32
def row(**over):
base = {
"id": "d" * 32,
"productId": PRODUCT,
"optionId": OPTION,
"productVersionId": VERSION,
"createdAt": "2026-07-01T00:00:00.000+00:00",
}
base.update(over)
return base
def test_no_duplicates_when_single_row_per_key():
rows = [row(id="1" * 32)]
assert find_duplicate_configurator_settings(rows) == []
def test_flags_duplicates_and_keeps_oldest():
rows = [
row(id="2" * 32, createdAt="2026-07-02T00:00:00.000+00:00"),
row(id="1" * 32, createdAt="2026-07-01T00:00:00.000+00:00"),
row(id="3" * 32, createdAt="2026-07-03T00:00:00.000+00:00"),
]
result = find_duplicate_configurator_settings(rows)
assert len(result) == 1
entry = result[0]
assert entry["productId"] == PRODUCT
assert entry["optionId"] == OPTION
assert entry["keepId"] == "1" * 32
assert sorted(entry["duplicateIds"]) == sorted(["2" * 32, "3" * 32])
def test_tie_breaks_on_id_when_created_at_matches():
same_time = "2026-07-01T00:00:00.000+00:00"
rows = [
row(id="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", createdAt=same_time),
row(id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", createdAt=same_time),
]
result = find_duplicate_configurator_settings(rows)
assert result[0]["keepId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
def test_separate_groups_for_different_options():
other_option = "e" * 32
rows = [
row(id="1" * 32, optionId=OPTION),
row(id="2" * 32, optionId=OPTION),
row(id="3" * 32, optionId=other_option),
]
result = find_duplicate_configurator_settings(rows)
assert len(result) == 1
assert result[0]["optionId"] == OPTION
def test_separate_groups_for_different_product_versions():
other_version = "f" * 32
rows = [
row(id="1" * 32, productVersionId=VERSION),
row(id="2" * 32, productVersionId=other_version),
]
assert find_duplicate_configurator_settings(rows) == []
def test_empty_input_returns_no_groups():
assert find_duplicate_configurator_settings([]) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateConfiguratorSettings } from "./find-duplicate-configurator-settings.js";
const PRODUCT = "a".repeat(32);
const OPTION = "b".repeat(32);
const VERSION = "c".repeat(32);
const row = (over = {}) => ({
id: "d".repeat(32),
productId: PRODUCT,
optionId: OPTION,
productVersionId: VERSION,
createdAt: "2026-07-01T00:00:00.000+00:00",
...over,
});
test("no duplicates when single row per key", () => {
const rows = [row({ id: "1".repeat(32) })];
assert.deepEqual(findDuplicateConfiguratorSettings(rows), []);
});
test("flags duplicates and keeps oldest", () => {
const rows = [
row({ id: "2".repeat(32), createdAt: "2026-07-02T00:00:00.000+00:00" }),
row({ id: "1".repeat(32), createdAt: "2026-07-01T00:00:00.000+00:00" }),
row({ id: "3".repeat(32), createdAt: "2026-07-03T00:00:00.000+00:00" }),
];
const result = findDuplicateConfiguratorSettings(rows);
assert.equal(result.length, 1);
const entry = result[0];
assert.equal(entry.productId, PRODUCT);
assert.equal(entry.optionId, OPTION);
assert.equal(entry.keepId, "1".repeat(32));
assert.deepEqual(entry.duplicateIds.slice().sort(), ["2".repeat(32), "3".repeat(32)].sort());
});
test("tie breaks on id when createdAt matches", () => {
const sameTime = "2026-07-01T00:00:00.000+00:00";
const rows = [
row({ id: "z".repeat(32), createdAt: sameTime }),
row({ id: "a".repeat(32), createdAt: sameTime }),
];
const result = findDuplicateConfiguratorSettings(rows);
assert.equal(result[0].keepId, "a".repeat(32));
});
test("separate groups for different options", () => {
const otherOption = "e".repeat(32);
const rows = [
row({ id: "1".repeat(32), optionId: OPTION }),
row({ id: "2".repeat(32), optionId: OPTION }),
row({ id: "3".repeat(32), optionId: otherOption }),
];
const result = findDuplicateConfiguratorSettings(rows);
assert.equal(result.length, 1);
assert.equal(result[0].optionId, OPTION);
});
test("separate groups for different product versions", () => {
const otherVersion = "f".repeat(32);
const rows = [
row({ id: "1".repeat(32), productVersionId: VERSION }),
row({ id: "2".repeat(32), productVersionId: otherVersion }),
];
assert.deepEqual(findDuplicateConfiguratorSettings(rows), []);
});
test("empty input returns no groups", () => {
assert.deepEqual(findDuplicateConfiguratorSettings([]), []);
});
Case studies
A furniture brand's nightly PIM sync doubled its configurator settings twice a week
A furniture retailer synced variant configuration from its PIM into Shopware every night through the Sync API. The PIM tracked products and options by its own SKU and attribute codes and never stored the product-configurator-setting id Shopware returned. Every sync sent the same options again without an id, and by the end of a month several hundred products had two or three rows for the same option.
Running the reconciler in report mode against the full catalog surfaced every duplicate group in minutes, grouped cleanly by product and option, each with the oldest row already identified as the one to keep. The team fixed the root cause in the PIM connector separately, then used the same script with deletion enabled to clear out the backlog that had already accumulated.
A timeout-triggered retry quietly tripled rows for a handful of high-traffic products
An apparel store's sync job occasionally timed out on large payloads, and the job runner automatically retried the whole batch. Each retry resent the same configurator settings without ids, so a handful of the store's best-selling products ended up with three rows per option instead of one, right as a seasonal sale was starting to drive traffic to their configurator pages.
The store ran the script in dry run first, confirmed the duplicate groups matched the retry pattern in their logs, then enabled deletion for just the affected products using the productId filter. The configurator kept working the whole time, since the storefront was already reading from whichever row it happened to pick up, but the table stopped growing once the retry bug was fixed upstream.
After this runs on a schedule in report mode, every duplicate group in product_configurator_setting is visible with the row that should stay already identified, instead of being invisible until someone stumbles onto it in the database. When you are ready, the same script removes the duplicates behind an explicit flag, one group at a time, never guessing and never touching a group that only has one row. The real fix, having the sync job persist the row id it gets back, still belongs in the integration, this script just keeps the mess from spreading in the meantime.
FAQ
Why does the Sync API create a new configurator setting instead of updating the old one?
The Sync API upsert action matches an existing row only by its id. If the system syncing your variant configuration only tracks the productId and optionId as its logical key and never saves the id Shopware generated for the product-configurator-setting row, every re-run looks like a brand new entity to Shopware, so it inserts another row instead of updating the one already there.
Is it safe to auto-delete duplicate configurator setting rows?
Not by default. Deleting the wrong row can affect live storefront configurator rendering and cached variant matrices, so the safe pattern is to report the duplicate groups first, review them, and only enable actual deletion once you have confirmed which row in each group should stay. Dry run should always be the starting point.
How do I find duplicate product-configurator-setting rows in Shopware 6?
Search the product-configurator-setting entity, page through every row, and group them by the composite key of productId, optionId, and productVersionId. There is no unique database constraint stopping duplicates for that combination, so any group with more than one row id is a duplicate set, and the oldest row by createdAt is the one to keep.
Related field notes
Citations
On the problem:
- GitHub: Sync API variant configuration duplicates in the product_configurator_setting table. github.com/shopware/shopware/issues/597
- Shopware Community Forum: updating configurator options through the Sync API. forum.shopware.com/t/configurator-options-updaten-via-sync-api/90750
- Shopware Community Forum: updating variant products through the REST Sync API. forum.shopware.com/t/update-variant-products-via-rest-syncapi/97894
On the solution:
- Shopware Admin API: the ProductConfiguratorSetting entity. shopware.stoplight.io/docs/admin-api/7bf09ab20f03f-product-configurator-setting
- Shopware Admin API: Bulk Payloads and the Sync API. shopware.stoplight.io/docs/admin-api/faf8f8e4e13a0-bulk-payloads
- Shopware Admin API: Product Data. shopware.stoplight.io/docs/admin-api/935c55fbf5072-product-data
Stuck on a tricky one?
If you have a problem in Shopware order states, stock, message queue, or data integrity 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 clean up a duplicate sync?
If this saved you a confusing table or a broken configurator matrix, 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