Reconciler Products, Variants and Media
Variant listing config points at a removed main variant
A parent product's variant listing config decides which variant the storefront shows on a category or listing page, and it makes that decision by holding one specific variant's product.id in mainVariantId. Delete that variant, or clone the parent, and the pointer can be left dangling or aimed at a variant that belongs to someone else now. Shopware never checks. The config just sits there until the storefront tries to resolve it and something breaks quietly.
Shopware 6 stores a parent product's variant listing config, including mainVariantId, displayParent, and configuratorGroupConfig, as a direct reference to one variant's product.id. It is not a managed foreign key with cascade or null-on-delete behavior. Deleting that variant through the context menu, or cloning the product, can leave mainVariantId pointing at an id that no longer exists or that now belongs to a different parent, and the config is never re-validated afterward. The fix is a small reconciler: fetch every parent with a non-null mainVariantId, resolve those ids in bulk, cross-check that each resolved variant's parentId still matches its own parent, and PATCH mainVariantId to null for anything dangling or reparented, which is the safe documented fallback. Full code, tests, and sources are below.
The problem in plain words
Every variant group needs a variant to show on category pages, search results, and anywhere else products are listed. Shopware calls that choice the variant listing config, and the field that matters most is mainVariantId, a UUID that names the exact variant the storefront should render for the group.
That UUID is just a value sitting on the parent product. Nothing in Shopware watches it the way a real foreign key would. If the variant it names gets deleted, or a clone operation shuffles which parent owns which variants, mainVariantId keeps pointing at the same id regardless. The parent product looks completely normal in the Administration list. It is only when the storefront actually tries to resolve that id, to decide what to render on a listing page, that the dangling reference becomes visible, usually as a 404 or an unpredictable fallback to some other variant nobody chose.
Why it happens
The variant listing config was built to point at one variant, not to track that variant's lifecycle. A few concrete gaps make the dangling reference easy to end up with and hard to notice:
- Deleting a single variant through the context menu in the variant overview removes the product row, but it also fails to clean up its
product_configurator_settingrows, per shopware/shopware#12716, and it does not touch the parent'smainVariantIdat all if that variant happened to be the chosen main one. - Cloning a product can change or drop the variant listing config on the original product, per shopware/shopware#10944, and the clone's own variants get new
product.idvalues that the oldmainVariantIdwas never updated to reference. - More broadly, the variant listing config is not kept in sync when products are cloned or variants are deleted, tracked as an open gap in shopware/shopware#3897.
- Because
mainVariantIdis just a UUID column, Shopware's search will silently omit an unresolvablemainVariantassociation instead of erroring, so the parent product looks completely fine until you specifically check whether that association came back empty.
This is a known modeling gap, not a one-off bug. See the citations at the end for the exact issues and the admin API docs describing how the field is meant to work.
Picking which variant represents a group on listing pages is a merchandising decision, not something a script should guess. That is why the safe fix is narrow. We only ever clear a mainVariantId that is provably dangling, either because the id it names does not resolve at all, or because the variant it resolves to now belongs to a different parent after a clone. Clearing it to null is the documented, safe fallback, since Shopware then falls back to its own default variant resolution. We never try to auto-pick a replacement variant. If the parent still has live variants, we flag it instead, so a human chooses the new main variant in the Administration.
The fix, as a flow
We never touch the storefront or guess a replacement. The script lists parents with a non-null mainVariantId, resolves those ids in bulk against the product table, cross-checks the parent id on anything that does resolve, and either clears the dangling ones or flags the reparented ones for a human, all gated by DRY_RUN.
Build it step by step
Get an Admin API access token
Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and the credentials in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" // start safe, change to false to write
Authenticate and call the Admin API
A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for every search and for the PATCH calls.
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},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_search(path, token, body):
r = requests.post(
f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
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" },
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 apiSearch(path, token, body) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
const data = await res.json();
return data.data;
}
List parents with a mainVariantId, then resolve those ids
Search product for a non-null mainVariantId. Then collect those ids and search product again with equalsAny on id. Diffing the requested ids against the returned ids tells you exactly which ones are dangling, and reading each resolved product's parentId tells you which ones now belong to a different parent.
def search_parents_with_main_variant(token, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "not", "queries": [{"type": "equals", "field": "mainVariantId", "value": None}]}],
"total-count-mode": 1,
}
return api_search("/api/search/product", token, body)
def resolve_variant_ids(token, ids, limit=500):
if not ids:
return []
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equalsAny", "field": "id", "value": ids}],
"total-count-mode": 1,
}
return api_search("/api/search/product", token, body)
async function searchParentsWithMainVariant(token, limit = 500) {
const body = {
page: 1,
limit,
filter: [{ type: "not", queries: [{ type: "equals", field: "mainVariantId", value: null }] }],
"total-count-mode": 1,
};
return apiSearch("/api/search/product", token, body);
}
async function resolveVariantIds(token, ids, limit = 500) {
if (ids.length === 0) return [];
const body = {
page: 1,
limit,
filter: [{ type: "equalsAny", field: "id", value: ids }],
"total-count-mode": 1,
};
return apiSearch("/api/search/product", token, body);
}
Decide, with one pure function
Keep the decision in its own function that takes a parent, the set of variant ids that actually resolved, and a map from variant id to that variant's real parentId. A pure function like this needs no network call, so it is directly unit-testable with fixture id lists. It returns none when there is nothing to check, clear when the reference is dangling or reparented, or in this article's more careful policy, flag when a human should decide instead.
def decide_main_variant_repair(parent, resolved_variant_ids, variant_parent_map):
main_variant_id = parent.get("mainVariantId")
if not main_variant_id:
return {"action": "none"}
if main_variant_id not in resolved_variant_ids:
return {"action": "clear", "reason": "deleted"}
if variant_parent_map.get(main_variant_id) != parent["id"]:
return {"action": "clear", "reason": "reparented"}
return {"action": "none"}
export function decideMainVariantRepair(parent, resolvedVariantIds, variantParentMap) {
const mainVariantId = parent.mainVariantId;
if (!mainVariantId) return { action: "none" };
if (!resolvedVariantIds.has(mainVariantId)) return { action: "clear", reason: "deleted" };
if (variantParentMap.get(mainVariantId) !== parent.id) return { action: "clear", reason: "reparented" };
return { action: "none" };
}
Clear the stale pointer, never guess a replacement
When the action is clear, PATCH the parent with mainVariantId set to null. That is the safe, documented fallback, since Shopware then resolves the listing with its own default logic instead of chasing a dead id. We never try to auto-pick a new main variant, since that choice belongs to a merchandiser, not to a script.
def clear_main_variant(token, parent_id):
r = requests.patch(
f"{SHOPWARE_URL}/api/product/{parent_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"mainVariantId": None},
timeout=30,
)
r.raise_for_status()
async function clearMainVariant(token, parentId) {
const res = await fetch(`${SHOPWARE_URL}/api/product/${parentId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ mainVariantId: null }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product patch`);
}
Wire it together with a dry run guard
The loop ties every piece together. For each parent with a mainVariantId, run the pure decision function against the resolved id set and the parent map, and only write when it says to clear. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs each {parentProductId, productNumber, staleMainVariantId}. Read the output, agree with it, then switch it off to let it write.
Never write a guessed replacement variant into mainVariantId, this script only ever clears the field to null. Always start with DRY_RUN=true, review the logged list of stale references, and only enable writes once you have confirmed each one is genuinely dangling or reparented.
The full code
Here is the complete script in one file for each language. It authenticates, lists parents with a non-null mainVariantId, resolves those ids in bulk, applies the pure decision function, and clears the stale pointer for anything dangling or reparented, respecting DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find and clear Shopware 6 parent products whose variant listing config
mainVariantId points at a variant that was deleted or now belongs to a
different parent, without ever guessing a replacement variant.
Shopware stores mainVariantId (plus displayParent and
configuratorGroupConfig) as a plain UUID reference to one variant's
product.id. It is not a managed foreign key, so deleting that variant
(which also fails to clean up product_configurator_setting rows, see
shopware/shopware#12716) or cloning the product (shopware/shopware#10944,
#3897) can leave the pointer dangling or aimed at the wrong parent. Nothing
re-validates it afterward.
Lists parents with a non-null mainVariantId, resolves those ids in bulk,
cross-checks each resolved variant's parentId, and PATCHes mainVariantId to
null for anything dangling or reparented, which is the safe documented
fallback. Never auto-picks a replacement variant. Run on a schedule, gated
by DRY_RUN. 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("fix_stale_main_variant")
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 decide_main_variant_repair(parent, resolved_variant_ids, variant_parent_map):
"""Pure decision. No I/O. Takes a plain parent dict, a set of ids that
actually resolved, and a map from variant id to its real parentId."""
main_variant_id = parent.get("mainVariantId")
if not main_variant_id:
return {"action": "none"}
if main_variant_id not in resolved_variant_ids:
return {"action": "clear", "reason": "deleted"}
if variant_parent_map.get(main_variant_id) != parent["id"]:
return {"action": "clear", "reason": "reparented"}
return {"action": "none"}
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},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_search(path, token, body):
r = requests.post(
f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def search_parents_with_main_variant(token, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "not", "queries": [{"type": "equals", "field": "mainVariantId", "value": None}]}],
"total-count-mode": 1,
}
return api_search("/api/search/product", token, body)
def resolve_variant_ids(token, ids, limit=500):
if not ids:
return []
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equalsAny", "field": "id", "value": ids}],
"total-count-mode": 1,
}
return api_search("/api/search/product", token, body)
def to_plain_parent(raw):
attrs = raw.get("attributes", raw)
return {
"id": attrs["id"],
"productNumber": attrs.get("productNumber"),
"mainVariantId": attrs.get("mainVariantId"),
}
def to_plain_variant(raw):
attrs = raw.get("attributes", raw)
return {"id": attrs["id"], "parentId": attrs.get("parentId")}
def clear_main_variant(token, parent_id):
r = requests.patch(
f"{SHOPWARE_URL}/api/product/{parent_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"mainVariantId": None},
timeout=30,
)
r.raise_for_status()
def run():
token = get_token()
raw_parents = search_parents_with_main_variant(token)
parents = [to_plain_parent(raw) for raw in raw_parents]
log.info("Found %d parent(s) with a non-null mainVariantId.", len(parents))
variant_ids = [p["mainVariantId"] for p in parents if p["mainVariantId"]]
raw_variants = resolve_variant_ids(token, variant_ids)
variants = [to_plain_variant(raw) for raw in raw_variants]
resolved_variant_ids = {v["id"] for v in variants}
variant_parent_map = {v["id"]: v["parentId"] for v in variants}
fixed = 0
for parent in parents:
result = decide_main_variant_repair(parent, resolved_variant_ids, variant_parent_map)
if result["action"] != "clear":
continue
log.warning(
"Parent %s (%s) has a stale mainVariantId %s [%s]. %s",
parent["id"], parent["productNumber"], parent["mainVariantId"], result["reason"],
"would clear" if DRY_RUN else "clearing",
)
if not DRY_RUN:
clear_main_variant(token, parent["id"])
fixed += 1
log.info("Done. %d parent(s) %s.", fixed, "to clear" if DRY_RUN else "cleared")
if __name__ == "__main__":
run()
/**
* Find and clear Shopware 6 parent products whose variant listing config
* mainVariantId points at a variant that was deleted or now belongs to a
* different parent, without ever guessing a replacement variant.
*
* Shopware stores mainVariantId (plus displayParent and
* configuratorGroupConfig) as a plain UUID reference to one variant's
* product.id. It is not a managed foreign key, so deleting that variant
* (which also fails to clean up product_configurator_setting rows, see
* shopware/shopware#12716) or cloning the product (shopware/shopware#10944,
* #3897) can leave the pointer dangling or aimed at the wrong parent.
* Nothing re-validates it afterward.
*
* Lists parents with a non-null mainVariantId, resolves those ids in bulk,
* cross-checks each resolved variant's parentId, and PATCHes mainVariantId
* to null for anything dangling or reparented, which is the safe
* documented fallback. Never auto-picks a replacement variant. Run on a
* schedule, gated by DRY_RUN. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/variant-listing-config-stale-main-variant/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideMainVariantRepair(parent, resolvedVariantIds, variantParentMap) {
const mainVariantId = parent.mainVariantId;
if (!mainVariantId) return { action: "none" };
if (!resolvedVariantIds.has(mainVariantId)) return { action: "clear", reason: "deleted" };
if (variantParentMap.get(mainVariantId) !== parent.id) return { action: "clear", reason: "reparented" };
return { action: "none" };
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "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 apiSearch(path, token, body) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
const data = await res.json();
return data.data;
}
async function searchParentsWithMainVariant(token, limit = 500) {
const body = {
page: 1,
limit,
filter: [{ type: "not", queries: [{ type: "equals", field: "mainVariantId", value: null }] }],
"total-count-mode": 1,
};
return apiSearch("/api/search/product", token, body);
}
async function resolveVariantIds(token, ids, limit = 500) {
if (ids.length === 0) return [];
const body = {
page: 1,
limit,
filter: [{ type: "equalsAny", field: "id", value: ids }],
"total-count-mode": 1,
};
return apiSearch("/api/search/product", token, body);
}
function toPlainParent(raw) {
const attrs = raw.attributes || raw;
return { id: attrs.id, productNumber: attrs.productNumber, mainVariantId: attrs.mainVariantId };
}
function toPlainVariant(raw) {
const attrs = raw.attributes || raw;
return { id: attrs.id, parentId: attrs.parentId };
}
async function clearMainVariant(token, parentId) {
const res = await fetch(`${SHOPWARE_URL}/api/product/${parentId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ mainVariantId: null }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product patch`);
}
export async function run() {
const token = await getToken();
const rawParents = await searchParentsWithMainVariant(token);
const parents = rawParents.map(toPlainParent);
console.log(`Found ${parents.length} parent(s) with a non-null mainVariantId.`);
const variantIds = parents.map((p) => p.mainVariantId).filter(Boolean);
const rawVariants = await resolveVariantIds(token, variantIds);
const variants = rawVariants.map(toPlainVariant);
const resolvedVariantIds = new Set(variants.map((v) => v.id));
const variantParentMap = new Map(variants.map((v) => [v.id, v.parentId]));
let fixed = 0;
for (const parent of parents) {
const result = decideMainVariantRepair(parent, resolvedVariantIds, variantParentMap);
if (result.action !== "clear") continue;
console.warn(
`Parent ${parent.id} (${parent.productNumber}) has a stale mainVariantId ${parent.mainVariantId} [${result.reason}]. ${DRY_RUN ? "would clear" : "clearing"}`
);
if (!DRY_RUN) await clearMainVariant(token, parent.id);
fixed++;
}
console.log(`Done. ${fixed} parent(s) ${DRY_RUN ? "to clear" : "cleared"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which parents get their mainVariantId cleared. Because decide_main_variant_repair is pure and makes no network calls, just set and map lookups, the test needs no Shopware store and no live product catalog.
from fix_stale_main_variant import decide_main_variant_repair
PARENT_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
VARIANT_ID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
OTHER_PARENT_ID = "cccccccccccccccccccccccccccccccc"
def parent(**over):
base = {"id": PARENT_ID, "mainVariantId": VARIANT_ID}
base.update(over)
return base
def test_none_when_main_variant_id_is_null():
result = decide_main_variant_repair(parent(mainVariantId=None), set(), {})
assert result["action"] == "none"
def test_clear_when_variant_id_never_resolved():
result = decide_main_variant_repair(parent(), set(), {})
assert result["action"] == "clear"
assert result["reason"] == "deleted"
def test_clear_when_variant_belongs_to_different_parent():
resolved = {VARIANT_ID}
parent_map = {VARIANT_ID: OTHER_PARENT_ID}
result = decide_main_variant_repair(parent(), resolved, parent_map)
assert result["action"] == "clear"
assert result["reason"] == "reparented"
def test_none_when_variant_resolves_to_same_parent():
resolved = {VARIANT_ID}
parent_map = {VARIANT_ID: PARENT_ID}
result = decide_main_variant_repair(parent(), resolved, parent_map)
assert result["action"] == "none"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideMainVariantRepair } from "./fix-stale-main-variant.js";
const PARENT_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const VARIANT_ID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const OTHER_PARENT_ID = "cccccccccccccccccccccccccccccccc";
const parent = (over = {}) => ({ id: PARENT_ID, mainVariantId: VARIANT_ID, ...over });
test("none when mainVariantId is null", () => {
const result = decideMainVariantRepair(parent({ mainVariantId: null }), new Set(), new Map());
assert.equal(result.action, "none");
});
test("clear when variant id never resolved", () => {
const result = decideMainVariantRepair(parent(), new Set(), new Map());
assert.equal(result.action, "clear");
assert.equal(result.reason, "deleted");
});
test("clear when variant belongs to a different parent", () => {
const resolved = new Set([VARIANT_ID]);
const parentMap = new Map([[VARIANT_ID, OTHER_PARENT_ID]]);
const result = decideMainVariantRepair(parent(), resolved, parentMap);
assert.equal(result.action, "clear");
assert.equal(result.reason, "reparented");
});
test("none when variant resolves to the same parent", () => {
const resolved = new Set([VARIANT_ID]);
const parentMap = new Map([[VARIANT_ID, PARENT_ID]]);
const result = decideMainVariantRepair(parent(), resolved, parentMap);
assert.equal(result.action, "none");
});
Case studies
The seasonal color that got removed
A furniture store discontinued a color variant and deleted it through the variant overview's context menu. It happened to be the one variant chosen as the group's mainVariantId. The parent product still opened fine in the Administration, so nobody thought twice.
Weeks later, a category page for that product line started 404ing intermittently. Running the reconciler in dry run surfaced exactly one stale parent, with the dead id logged next to the product number. A human confirmed the group still had three live variants, chose a sensible new main variant in the Administration, and the reconciler cleared the rest automatically on the next scheduled pass.
The cloned bestseller that pointed at the wrong parent
A team cloned a bestselling product to start a new seasonal line, keeping the same variant structure. The clone's variants got fresh product.id values, but the original product's mainVariantId stayed as it was, and in one case a variant migration during the clone left it aimed at an id that was now owned by the new clone instead of the original.
The cross-parent check caught it immediately: the resolved variant's parentId did not match the original parent's own id. The reconciler flagged it as reparented, cleared the stale pointer once DRY_RUN was turned off, and the original product's listing page went back to resolving correctly.
After this runs on a schedule, a deleted or reparented variant never leaves a parent product silently broken on the storefront. Dangling mainVariantId references get cleared to the safe null fallback automatically, and parents that still need a human decision are surfaced with the exact stale id logged, not guessed at. Merchandisers keep control over which variant represents a group, and nobody discovers the problem from a 404 in production.
FAQ
Why does my Shopware 6 parent product still point at a deleted variant?
The parent's variant listing config stores mainVariantId as a plain UUID reference to one variant's product.id, and Shopware does not manage it as a foreign key with cascade or null-on-delete behavior. Deleting that variant, or cloning the product, can leave mainVariantId pointing at an id that no longer exists or no longer belongs to that parent, and nothing re-validates the config afterward.
Is it safe to clear a stale mainVariantId with a script?
Yes, clearing it with PATCH /api/product/{id} and mainVariantId set to null is the safe, documented fallback, because Shopware then falls back to its own default variant resolution. The script should only clear references that are confirmed dangling or reparented, and should flag parents that still have live variants for a human to pick a replacement, rather than guessing one.
Should a script auto-pick a new main variant after clearing the stale one?
No. Which variant represents a product group on category and listing pages is a merchandising decision, not a technical one. The safe pattern is to null the dangling mainVariantId so Shopware falls back to its default resolution, and flag the parent for a human to choose a deliberate replacement in the Administration if the group still has live variants.
Related field notes
Citations
On the problem:
- Variant listing config is not updated correctly when products are cloned or variants are deleted. github.com/shopware/shopware/issues/3897
- Product cloning: VariantListingConfig is changed or deleted on the original product. github.com/shopware/shopware/issues/10944
- Deleting variants in the variant overview does not consider its entries in product_configurator_setting. github.com/shopware/shopware/issues/12716
On the solution:
- Product entity reference in the Admin API, including mainVariantId and the variant listing config. shopware.stoplight.io/docs/admin-api entity reference
- Shopware Developer Documentation: Products, variants, and how the parent-variant relationship is modeled. developer.shopware.com/docs/concepts/commerce/catalog/products.html
- Shopware 6 Catalogues: creating products, variant listing, and configurator settings. docs.shopware.com/en/shopware-6-en/catalogues/products
Stuck on a tricky one?
If you have a problem in Shopware products, variants, orders, or inventory that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear a stale reference for you?
If this saved you a mystery 404 on a listing page or a confusing storefront bug, 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