Reconciler Products, Variants and Media

Deleting a product variant leaves stale configurator setting rows

A merchant opens the variant overview, right clicks one row that is no longer needed, and picks Delete. The variant disappears from the grid, the product count drops, and everything looks clean. But the row Shopware kept to remember which property group options that variant used, its product_configurator_setting row, is still sitting in the database, pointing at a productId that no longer exists. Here is why the single row delete and the regenerate variants workflow disagree, and a small script that finds every orphaned row without touching anything by default.

Python and Node.js Shopware Admin API Safe by default (dry run)
The short answer

When a merchant deletes a single variant from the context menu in the variant overview, Shopware only sends a DELETE against the product entity for that one row. The product_configurator_setting rows that pin that variant's productId to its chosen property_group_option values live in a separate aggregate, ProductConfiguratorSettingDefinition, and they are only cleaned up by the uncheck option and regenerate variants workflow, not by the ad hoc single row delete. That is a confirmed core bug, shopware/shopware#12716. The setting rows survive with a productId that no longer resolves to anything, and there is no enforced cascade to remove them. Run a small Python or Node.js script that pulls every live product id, pulls every product-configurator-setting row, and reports any row whose productId is not in the live set. By default it only reports, it does not delete. Full code, tests, and sources are below.

The problem in plain words

A variant in Shopware 6 is a product row like any other, just one that has a parentId pointing at the main product and a set of property_group_option values attached through product_configurator_setting. That setting row is what tells Shopware, and the configurator, which color, size, or material this specific variant represents.

Shopware gives merchants two different ways to remove a variant. One is the proper workflow, uncheck an option in the configurator and regenerate variants, which is variant aware and cleans up the configurator settings tied to whatever it removes. The other is a plain context menu delete on a single row in the variant overview grid, which merchants reach for constantly because it is faster for a one off cleanup. That second path only issues a DELETE against product for that row's id. It does not know about, and does not touch, the product_configurator_setting rows that reference the same productId.

Delete one variant context menu, one row DELETE product only this one row variant row is gone separate aggregate, never touched Setting row kept product_configurator _setting still exists productId no longer exists Repeat this over enough ad hoc deletes and the table quietly fills with orphans no cascade, no cleanup, no error anywhere
The single row delete does exactly what it was asked to do, remove one product row. Nothing in that path knows to also remove the configurator setting rows that pointed at it.

Why it happens

This is not a corrupted table, it is two deletion paths that were built for two different jobs and never reconciled. A few ways stores end up with orphaned rows:

None of this throws an error anywhere in the Administration. The variant overview grid looks correct because it renders from product, not from product_configurator_setting. The orphaned rows are invisible until something reads the configurator settings directly, at which point they can resurface as variant options with no value in the storefront configurator, or simply bloat every query that joins against that table. This is a confirmed core bug tracked as shopware/shopware#12716, and a related issue, #15201, shows the same divergence causing errors when variants are regenerated after a delete.

The key insight

An orphaned row is not proof that something is broken right now, it is a leftover from a delete that only ever touched half the picture. The safe fix is not to delete on sight. It is to build the set of every product id that still exists, cross reference every product_configurator_setting row's productId against that set, and only ever act on rows whose productId you can independently confirm is truly gone, since a false positive during an in flight import is destructive to delete.

The fix, as a flow

We do not change how variant deletes work and we do not touch the variant overview. The script pulls the full set of live product ids, parents and variants together, then pulls every product-configurator-setting row and checks its productId against that set with one pure function. By default the script only reports the stale ids it finds. Deleting them is a separate, explicit step that only runs once DRY_RUN is turned off and each id has been re verified.

Search product build live product id set Search settings every configurator row findOrphanedConfiguratorSettings pure decision function productId in live set? yes, still valid, skip no, orphaned Report the id delete only if verified and DRY_RUN is false
The pure function only ever names ids to report. Deleting is a separate, explicit, re verified step that runs only when dry run is turned off.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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 both search calls and for the delete calls.

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

Build the set of live product ids

Search product for every id that still exists, parents and variants together, paging with page and limit and reading total-count-mode:1 so we know when to stop. This is the reference set every configurator setting row gets checked against.

step3.py
def search_products(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/product", token, body)


def fetch_live_product_ids(token):
    ids = set()
    page = 1
    while True:
        data = search_products(token, page=page)
        batch = data.get("data", [])
        ids.update(row["id"] for row in batch)
        if not batch or page * 500 >= data.get("total", 0):
            break
        page += 1
    return ids
step3.js
async function searchProducts(token, page = 1, limit = 500) {
  const body = { page, limit, filter: [], "total-count-mode": 1 };
  return api("POST", "/api/search/product", token, body);
}

async function fetchLiveProductIds(token) {
  const ids = new Set();
  let page = 1;
  while (true) {
    const data = await searchProducts(token, page);
    const batch = data.data || [];
    for (const row of batch) ids.add(row.id);
    if (batch.length === 0 || page * 500 >= (data.total || 0)) break;
    page++;
  }
  return ids;
}
4

Pull every product-configurator-setting row and decide, with one pure function

Search product-configurator-setting for every row, keeping just id and productId, then hand the full list and the live id set to a pure function. A row is orphaned only when its productId is not in the live set. Nothing here calls the network, so it is trivial to unit test with fixture arrays and sets.

decide.py
def search_configurator_settings(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [],
        "associations": {"option": {}},
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/product-configurator-setting", token, body)


def find_orphaned_configurator_settings(configurator_settings, live_product_ids):
    return [
        row["id"]
        for row in configurator_settings
        if row["productId"] not in live_product_ids
    ]
decide.js
async function searchConfiguratorSettings(token, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [],
    associations: { option: {} },
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/product-configurator-setting", token, body);
}

export function findOrphanedConfiguratorSettings(configuratorSettings, liveProductIds) {
  return configuratorSettings
    .filter((row) => !liveProductIds.has(row.productId))
    .map((row) => row.id);
}
5

Repair only behind an explicit flag, after re verifying

By default the script only reports the orphaned ids, it never deletes anything. When DRY_RUN is off, it independently re confirms each stale productId is unresolvable, for example a GET /api/product/{productId} returning 404, before calling DELETE /api/product-configurator-setting/{id}, or batching the same deletes through POST /api/_action/sync. There is no stateMachineState involved anywhere in this entity, this is a plain DAL delete, never a state transition.

Run it safe

Always start with DRY_RUN=true. A row can look orphaned for a moment during a valid in flight operation such as a mid import, so the script only deletes a row after independently re verifying its productId is truly gone, and even then only when you have explicitly turned dry run off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because it only ever reports orphaned rows unless you both disable dry run and let the script re verify each id before deleting it.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

find_orphaned_configurator_settings.py
"""Find Shopware 6 product_configurator_setting rows orphaned by a single variant delete, safely.

Deleting one variant from the variant overview context menu only issues a DELETE against
the product entity for that row. The product_configurator_setting rows that pin that
variant's productId to its chosen property_group_option values are a separate aggregate,
and they are only cleaned up by the uncheck option and regenerate variants workflow, not
by the ad hoc single row delete (shopware/shopware#12716). This script builds the set of
every live product id, pulls every product-configurator-setting row, and reports any row
whose productId is not in the live set. By default it only reports. Only when DRY_RUN is
off does it independently re verify each id and delete the confirmed orphans.
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_orphaned_configurator_settings")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").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_products(token, page=1, limit=500):
    body = {"page": page, "limit": limit, "filter": [], "total-count-mode": 1}
    return api("POST", "/api/search/product", token, body)


def fetch_live_product_ids(token):
    ids = set()
    page = 1
    while True:
        data = search_products(token, page=page)
        batch = data.get("data", [])
        ids.update(row["id"] for row in batch)
        if not batch or page * 500 >= data.get("total", 0):
            break
        page += 1
    return ids


def search_configurator_settings(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [],
        "associations": {"option": {}},
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/product-configurator-setting", token, body)


def fetch_all_configurator_settings(token):
    rows = []
    page = 1
    while True:
        data = search_configurator_settings(token, page=page)
        batch = data.get("data", [])
        rows.extend({"id": row["id"], "productId": row["productId"]} for row in batch)
        if not batch or page * 500 >= data.get("total", 0):
            break
        page += 1
    return rows


def find_orphaned_configurator_settings(configurator_settings, live_product_ids):
    return [
        row["id"]
        for row in configurator_settings
        if row["productId"] not in live_product_ids
    ]


def product_is_gone(token, product_id):
    try:
        api("GET", f"/api/product/{product_id}", token)
        return False
    except requests.HTTPError as exc:
        return exc.response is not None and exc.response.status_code == 404


def delete_configurator_setting(token, setting_id):
    api("DELETE", f"/api/product-configurator-setting/{setting_id}", token)


def run():
    token = get_token()
    live_ids = fetch_live_product_ids(token)
    settings = fetch_all_configurator_settings(token)
    by_id = {row["id"]: row for row in settings}

    orphaned_ids = find_orphaned_configurator_settings(settings, live_ids)
    log.info("Found %d orphaned product-configurator-setting row(s).", len(orphaned_ids))

    if DRY_RUN:
        for setting_id in orphaned_ids:
            log.warning("Would delete %s (productId=%s)", setting_id, by_id[setting_id]["productId"])
        log.info("Done. %d row(s) to delete (dry run).", len(orphaned_ids))
        return

    deleted = 0
    for setting_id in orphaned_ids:
        product_id = by_id[setting_id]["productId"]
        if not product_is_gone(token, product_id):
            log.info("Skipping %s, productId %s resolved after re check.", setting_id, product_id)
            continue
        delete_configurator_setting(token, setting_id)
        deleted += 1
    log.info("Done. %d row(s) deleted.", deleted)


if __name__ == "__main__":
    run()
find-orphaned-configurator-settings.js
/**
 * Find Shopware 6 product_configurator_setting rows orphaned by a single variant delete, safely.
 *
 * Deleting one variant from the variant overview context menu only issues a DELETE against
 * the product entity for that row. The product_configurator_setting rows that pin that
 * variant's productId to its chosen property_group_option values are a separate aggregate,
 * and they are only cleaned up by the uncheck option and regenerate variants workflow, not
 * by the ad hoc single row delete (shopware/shopware#12716). This script builds the set of
 * every live product id, pulls every product-configurator-setting row, and reports any row
 * whose productId is not in the live set. By default it only reports. Only when DRY_RUN is
 * off does it independently re verify each id and delete the confirmed orphans.
 * 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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function findOrphanedConfiguratorSettings(configuratorSettings, liveProductIds) {
  return configuratorSettings
    .filter((row) => !liveProductIds.has(row.productId))
    .map((row) => row.id);
}

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,
  });
  return res;
}

async function jsonApi(method, path, token, jsonBody) {
  const res = await api(method, path, token, jsonBody);
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function searchProducts(token, page = 1, limit = 500) {
  const body = { page, limit, filter: [], "total-count-mode": 1 };
  return jsonApi("POST", "/api/search/product", token, body);
}

async function fetchLiveProductIds(token) {
  const ids = new Set();
  let page = 1;
  while (true) {
    const data = await searchProducts(token, page);
    const batch = data.data || [];
    for (const row of batch) ids.add(row.id);
    if (batch.length === 0 || page * 500 >= (data.total || 0)) break;
    page++;
  }
  return ids;
}

async function searchConfiguratorSettings(token, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [],
    associations: { option: {} },
    "total-count-mode": 1,
  };
  return jsonApi("POST", "/api/search/product-configurator-setting", token, body);
}

async function fetchAllConfiguratorSettings(token) {
  const rows = [];
  let page = 1;
  while (true) {
    const data = await searchConfiguratorSettings(token, page);
    const batch = data.data || [];
    for (const row of batch) rows.push({ id: row.id, productId: row.productId });
    if (batch.length === 0 || page * 500 >= (data.total || 0)) break;
    page++;
  }
  return rows;
}

async function productIsGone(token, productId) {
  const res = await api("GET", `/api/product/${productId}`, token);
  return res.status === 404;
}

async function deleteConfiguratorSetting(token, settingId) {
  await jsonApi("DELETE", `/api/product-configurator-setting/${settingId}`, token);
}

export async function run() {
  const token = await getToken();
  const liveIds = await fetchLiveProductIds(token);
  const settings = await fetchAllConfiguratorSettings(token);
  const byId = new Map(settings.map((row) => [row.id, row]));

  const orphanedIds = findOrphanedConfiguratorSettings(settings, liveIds);
  console.log(`Found ${orphanedIds.length} orphaned product-configurator-setting row(s).`);

  if (DRY_RUN) {
    for (const settingId of orphanedIds) {
      const row = byId.get(settingId);
      console.warn(`Would delete ${settingId} (productId=${row.productId})`);
    }
    console.log(`Done. ${orphanedIds.length} row(s) to delete (dry run).`);
    return;
  }

  let deleted = 0;
  for (const settingId of orphanedIds) {
    const row = byId.get(settingId);
    const gone = await productIsGone(token, row.productId);
    if (!gone) {
      console.log(`Skipping ${settingId}, productId ${row.productId} resolved after re check.`);
      continue;
    }
    await deleteConfiguratorSetting(token, settingId);
    deleted++;
  }
  console.log(`Done. ${deleted} row(s) deleted.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The cross reference is the part most worth testing, because it decides which rows get reported and, eventually, deleted. Because find_orphaned_configurator_settings is pure set membership with no I/O, the test needs no network and no Shopware instance. It just feeds in plain fixtures and checks the answer.

test_stale_configurator_settings.py
from find_orphaned_configurator_settings import find_orphaned_configurator_settings


def setting(**over):
    base = {"id": "a" * 32, "productId": "b" * 32}
    base.update(over)
    return base


def test_no_orphans_when_all_products_live():
    settings = [setting(id="a" * 32, productId="b" * 32)]
    live_ids = {"b" * 32}
    assert find_orphaned_configurator_settings(settings, live_ids) == []


def test_finds_orphan_when_product_id_missing():
    settings = [setting(id="a" * 32, productId="b" * 32)]
    live_ids = {"c" * 32}
    assert find_orphaned_configurator_settings(settings, live_ids) == ["a" * 32]


def test_mixed_batch_returns_only_orphaned_ids():
    settings = [
        setting(id="a" * 32, productId="live1".ljust(32, "0")),
        setting(id="b" * 32, productId="gone1".ljust(32, "0")),
        setting(id="c" * 32, productId="live2".ljust(32, "0")),
        setting(id="d" * 32, productId="gone2".ljust(32, "0")),
    ]
    live_ids = {"live1".ljust(32, "0"), "live2".ljust(32, "0")}
    result = find_orphaned_configurator_settings(settings, live_ids)
    assert sorted(result) == sorted(["b" * 32, "d" * 32])


def test_empty_settings_returns_empty_list():
    assert find_orphaned_configurator_settings([], {"a" * 32}) == []


def test_empty_live_set_flags_every_row_as_orphaned():
    settings = [setting(id="a" * 32, productId="b" * 32), setting(id="c" * 32, productId="d" * 32)]
    result = find_orphaned_configurator_settings(settings, set())
    assert sorted(result) == sorted(["a" * 32, "c" * 32])
stale-configurator-settings.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanedConfiguratorSettings } from "./find-orphaned-configurator-settings.js";

const setting = (over = {}) => ({ id: "a".repeat(32), productId: "b".repeat(32), ...over });

test("no orphans when all products are live", () => {
  const settings = [setting({ id: "a".repeat(32), productId: "b".repeat(32) })];
  const liveIds = new Set(["b".repeat(32)]);
  assert.deepEqual(findOrphanedConfiguratorSettings(settings, liveIds), []);
});

test("finds orphan when productId is missing from live set", () => {
  const settings = [setting({ id: "a".repeat(32), productId: "b".repeat(32) })];
  const liveIds = new Set(["c".repeat(32)]);
  assert.deepEqual(findOrphanedConfiguratorSettings(settings, liveIds), ["a".repeat(32)]);
});

test("mixed batch returns only orphaned ids", () => {
  const settings = [
    setting({ id: "a".repeat(32), productId: "live1".padEnd(32, "0") }),
    setting({ id: "b".repeat(32), productId: "gone1".padEnd(32, "0") }),
    setting({ id: "c".repeat(32), productId: "live2".padEnd(32, "0") }),
    setting({ id: "d".repeat(32), productId: "gone2".padEnd(32, "0") }),
  ];
  const liveIds = new Set(["live1".padEnd(32, "0"), "live2".padEnd(32, "0")]);
  const result = findOrphanedConfiguratorSettings(settings, liveIds);
  assert.deepEqual(result.slice().sort(), ["b".repeat(32), "d".repeat(32)].sort());
});

test("empty settings returns empty list", () => {
  assert.deepEqual(findOrphanedConfiguratorSettings([], new Set(["a".repeat(32)])), []);
});

test("empty live set flags every row as orphaned", () => {
  const settings = [setting({ id: "a".repeat(32), productId: "b".repeat(32) }), setting({ id: "c".repeat(32), productId: "d".repeat(32) })];
  const result = findOrphanedConfiguratorSettings(settings, new Set());
  assert.deepEqual(result.slice().sort(), ["a".repeat(32), "c".repeat(32)].sort());
});

Case studies

Manual cleanup

A furniture store trimmed a bloated variant grid one row at a time

A furniture retailer had a sofa configured with fabric and leg finish combinations that grew to over a hundred variants during a rushed launch. A merchandiser opened the variant overview and, over a few afternoons, right clicked and deleted the combinations nobody ever ordered. The grid looked exactly right afterward, down to the handful of finishes that actually sold.

Months later, a developer investigating a slow configurator query found the product_configurator_setting table still carried rows for every deleted combination. Running the reconciler in dry run against the shop found a few hundred stale rows, all pointing at product ids that returned 404. Re verifying each one and then applying the delete shrank the table back down without touching a single live variant.

Import script cleanup

A migration script pruned variants directly through the API

A team migrating from another platform imported far more color and size combinations than the catalog needed, then wrote a follow up script that called DELETE /api/product/{id} for every variant id on an exclusion list, to prune the excess before going live. The script worked exactly as written and the excess variants disappeared from every storefront listing.

Before launch, the team ran the same reconciler used here in dry run and found it flagged thousands of orphaned configurator setting rows left by their own pruning script. They fixed the pruning script separately to also clean up configurator settings going forward, then used this reconciler once to clear out the backlog it had already created.

What good looks like

After this runs, every product_configurator_setting row's productId resolves to a product that actually exists. Rows left behind by a single row variant delete are surfaced in a report first, never deleted on sight, and only removed once a separate check has independently confirmed the product behind them is truly gone. The variant overview never looked wrong to begin with, but the configurator no longer carries dead weight, and future queries against that table stop tripping over ids that lead nowhere.

FAQ

Why does deleting a variant leave product_configurator_setting rows behind in Shopware 6?

Deleting one variant from the variant overview context menu only issues a delete against the product entity for that row. The product_configurator_setting rows that pin that variant's productId to its chosen property_group_option values are a separate aggregate, and they are only cleaned up by the uncheck option and regenerate variants workflow, not by the single row delete. The two deletion paths diverge, so the setting rows survive with a productId that no longer exists.

Is it safe to auto delete orphaned product_configurator_setting rows?

Not by default. A row can look orphaned for a moment during a valid in flight operation such as a mid import, so the safe default is to report the stale ids rather than delete them. Only once a script has independently re verified that each productId is truly unresolvable, for example by confirming the product returns a 404, should it delete those rows, and even then a dry run first is the safer path.

How do I detect stale product_configurator_setting rows?

Pull every live product id, parents and variants, from POST /api/search/product, then pull every row from POST /api/search/product-configurator-setting and read its productId field. Any configurator setting row whose productId is not in the live product id set no longer has a product behind it, and is a candidate to report or, once confirmed, to delete with DELETE /api/product-configurator-setting/{id}.

Related field notes

Citations

On the problem:

  1. Deleting variants in the variant overview does not consider its entries in product_configurator_setting. github.com/shopware/shopware/issues/12716
  2. Produktvarianten ohne Wert in Shopware 6? So behebst du das Problem. lenz-ebusiness.de/2025/09/16/produktvarianten-ohne-wert-in-shopware-6-so-behebst-du-das-problem
  3. Altering variants (re generation) leads to errors in duplicated variant products when variants are to be deleted. github.com/shopware/shopware/issues/15201

On the solution:

  1. ProductConfiguratorSetting, Admin API reference. shopware.stoplight.io/docs/admin-api/c2NoOjE0MzUxMjg1-product-configurator-setting
  2. Removing Associated Data, Shopware Developer Documentation. developer.shopware.com/docs/guides/plugins/plugins/framework/data-handling/deleting-associated-data.html
  3. Shopware 6, Tutorials and FAQs, SQL Tips and Tricks. docs.shopware.com/en/shopware-6-en/tutorials-and-faq/sql-tips-tricks

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.

Contact me on LinkedIn

Did this clean up an orphaned row?

If this saved you a confusing table or a bloated configurator query, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopware field notes