Reconciler SEO URLs
Duplicate seo_url rows accumulate for the same path in Shopware 6
A category gets renamed, a template changes, a bulk reindex runs one more time than it needed to, and the seo_url table quietly grows a second, third, or fourth row that all claim the same storefront path. Only one of them is marked canonical, but the rest do not disappear, they just sit there, confusing anyone who queries the table directly and slowing down every future lookup on that path. Here is why Shopware leaves the old rows behind and a small script that groups them by path and reconciles each group down to the one row that should stay.
Shopware 6 never overwrites a seo-url row in place. Every time it generates a URL for an entity in a sales channel and language, whether from a template change, a category move, or a reindex, it writes a fresh row and flips isCanonical to true on that new row while leaving the previous rows behind with isCanonical false. When the same path keeps getting regenerated without the stale rows ever being cleaned up, you end up with several rows sharing one seoPathInfo, and only careful grouping tells you which one is actually live. Run a small Python or Node.js script that searches seo-url grouped by pathInfo (the entity's canonical route), finds the groups with more than one row for the same seoPathInfo, and, only behind an explicit apply flag, deletes every row in a group except the one Shopware itself marked canonical. Full code, tests, and sources are below.
The problem in plain words
The seo-url entity is Shopware's link between a friendly storefront path, such as /womens-boots/winter-boots, and the actual route it stands in for, such as a product detail page. When Shopware builds that link, it does not update an existing row to point at the new path. It inserts a new row, and it sets isCanonical true on that new row while any earlier row for the same entity, sales channel, and language keeps existing with isCanonical set back to false.
That design exists so old links keep resolving as redirects instead of turning into dead ends the moment a URL changes. The trade-off is that nothing forces those old rows to ever be removed. A template edit that changes how category paths are built, a category moved to a new parent, or a bulk reindex run that regenerates the same entity's URL more than once in a row can each leave one more stale row behind. Run any of those often enough, especially during a migration or a repeated reindex, and the same seoPathInfo ends up claimed by several rows at once, with only one of them actually canonical and the rest just sitting there.
Why it happens
This is not a bug in any single write. It is a gap between how seo-url keeps history and what a store owner assumes about a table named after a path. A few common ways stores end up with duplicate groups:
- A URL template in Settings, SEO changes, and every affected category or product path is regenerated, leaving the previous path's row behind for every entity that was touched.
- A category is moved to a new parent, which changes its full path, and Shopware writes a new canonical row while the old row stays for redirect purposes.
- A migration or import script calls the reindex more than once for the same batch of entities, for example retried after a timeout, and each pass can leave one more row.
- A custom plugin writes
seo-urlrows directly through the Admin API without first checking whether a row already exists for that entity, sales channel, and language.
None of this throws an error anywhere. The storefront keeps resolving the canonical path correctly because Shopware always reads the row with isCanonical true. The duplicates are invisible until someone queries seo-url directly, or until the table has grown large enough to slow down the lookups that route every storefront request.
The rows are not corrupt, they are history that never got trimmed. Each write did what it was told to do: create a new canonical row and demote the previous one. So the fix is not to change how URLs are generated. It is to group the rows by the path they actually resolve to, find the groups where more than one row is competing for that same path, and, once you can see which row Shopware itself already marked canonical, remove the extras rather than guess which one is current.
The fix, as a flow
We do not touch isCanonical directly and we never write a new canonical row ourselves. The script searches seo-url, groups the rows by seoPathInfo within the same sales channel and language, and for each group runs a small pure rule: a group with only one row is left alone, and a group with more than one row is reconciled by keeping the row marked isCanonical true and marking every other row in that group for deletion. Only when an explicit apply flag is set does the script actually delete those extra rows.
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
// 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
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 delete 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,
},
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 seo-url rows for a sales channel and language
Ask for every seo-url row scoped to one salesChannelId and languageId, since a path can legitimately repeat across different channels or languages and that is not a duplicate. We page through with page and limit, and read total-count-mode:1 so we know when to stop.
def search_seo_urls(token, sales_channel_id, language_id, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [
{"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
{"type": "equals", "field": "languageId", "value": language_id},
],
"sort": [{"field": "seoPathInfo", "order": "ASC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/seo-url", token, body)
async function searchSeoUrls(token, salesChannelId, languageId, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [
{ type: "equals", field: "salesChannelId", value: salesChannelId },
{ type: "equals", field: "languageId", value: languageId },
],
sort: [{ field: "seoPathInfo", order: "ASC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/seo-url", token, body);
}
Group the rows and decide, with one pure function
Group the rows by seoPathInfo in application code first. Then, for each group, a pure function takes the list of rows sharing that path and returns which row to keep and which ids to remove. A group with one row is never touched. A group with several rows keeps the one row with isCanonical true, and if none or more than one row in a group claims to be canonical, the function refuses to guess and reports the group for manual review instead.
def group_by_path(rows):
groups = {}
for row in rows:
groups.setdefault(row["seoPathInfo"], []).append(row)
return groups
def reconcile_seo_url_group(rows):
if len(rows) <= 1:
return {"action": "skip", "reason": "single row for this path", "deleteIds": []}
canonical_rows = [r for r in rows if r.get("isCanonical")]
if len(canonical_rows) != 1:
return {"action": "review", "reason": "zero or multiple canonical rows, needs a human", "deleteIds": []}
canonical_id = canonical_rows[0]["id"]
delete_ids = sorted(r["id"] for r in rows if r["id"] != canonical_id)
return {"action": "delete", "reason": "duplicate rows for the same path", "deleteIds": delete_ids}
export function groupByPath(rows) {
const groups = {};
for (const row of rows) {
if (!groups[row.seoPathInfo]) groups[row.seoPathInfo] = [];
groups[row.seoPathInfo].push(row);
}
return groups;
}
export function reconcileSeoUrlGroup(rows) {
if (rows.length <= 1) {
return { action: "skip", reason: "single row for this path", deleteIds: [] };
}
const canonicalRows = rows.filter((r) => r.isCanonical);
if (canonicalRows.length !== 1) {
return { action: "review", reason: "zero or multiple canonical rows, needs a human", deleteIds: [] };
}
const canonicalId = canonicalRows[0].id;
const deleteIds = rows.map((r) => r.id).filter((id) => id !== canonicalId).sort();
return { action: "delete", reason: "duplicate rows for the same path", deleteIds };
}
Delete the extra rows only behind an explicit apply flag
When a group's action is delete, remove every id in deleteIds with DELETE /api/seo-url/{id}, one row at a time, and only when DRY_RUN is off. A group whose action is review is always left alone, it is never safe to guess which row is canonical when Shopware itself has not marked exactly one.
Always start with DRY_RUN=true. The script only ever proposes deleting a row from a group that has more than one row and exactly one row already marked canonical by Shopware. Anything ambiguous is reported for a human instead of deleted, since a wrongly deleted row can break a working redirect.
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 deletes rows from a group where exactly one row is already the canonical one, leaving every ambiguous group for manual review.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 seo_url rows that duplicate the same path, and reconcile them, safely.
Shopware never overwrites a seo-url row in place. Every regeneration, whether from a
template change, a category move, or a repeated reindex, writes a new row and marks it
isCanonical, while the previous row for that entity is kept with isCanonical false for
redirect history. Repeat that enough times without cleanup and several rows end up
claiming the same seoPathInfo. This script groups seo-url rows by path within a sales
channel and language, and, only behind an explicit apply flag, deletes every row in a
duplicate group except the one Shopware already marked canonical. Any group where zero
or more than one row claims to be canonical is left for manual review.
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("reconcile_duplicate_seo_urls")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
SALES_CHANNEL_ID = os.environ.get("SEO_SALES_CHANNEL_ID", "")
LANGUAGE_ID = os.environ.get("SEO_LANGUAGE_ID", "")
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_seo_urls(token, sales_channel_id, language_id, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [
{"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
{"type": "equals", "field": "languageId", "value": language_id},
],
"sort": [{"field": "seoPathInfo", "order": "ASC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/seo-url", token, body)
def group_by_path(rows):
groups = {}
for row in rows:
groups.setdefault(row["seoPathInfo"], []).append(row)
return groups
def reconcile_seo_url_group(rows):
if len(rows) <= 1:
return {"action": "skip", "reason": "single row for this path", "deleteIds": []}
canonical_rows = [r for r in rows if r.get("isCanonical")]
if len(canonical_rows) != 1:
return {"action": "review", "reason": "zero or multiple canonical rows, needs a human", "deleteIds": []}
canonical_id = canonical_rows[0]["id"]
delete_ids = sorted(r["id"] for r in rows if r["id"] != canonical_id)
return {"action": "delete", "reason": "duplicate rows for the same path", "deleteIds": delete_ids}
def delete_seo_url(token, seo_url_id):
api("DELETE", f"/api/seo-url/{seo_url_id}", token)
def fetch_all_rows(token):
rows = []
page = 1
while True:
data = search_seo_urls(token, SALES_CHANNEL_ID, LANGUAGE_ID, page=page)
batch = data.get("data", [])
rows.extend(batch)
if not batch or page * 500 >= data.get("total", 0):
break
page += 1
return rows
def run():
if not SALES_CHANNEL_ID or not LANGUAGE_ID:
raise RuntimeError("Set SEO_SALES_CHANNEL_ID and SEO_LANGUAGE_ID before running.")
token = get_token()
rows = fetch_all_rows(token)
groups = group_by_path(rows)
deleted = 0
to_delete = 0
review = 0
for path, group_rows in groups.items():
decision = reconcile_seo_url_group(group_rows)
if decision["action"] == "skip":
continue
if decision["action"] == "review":
review += 1
log.warning("REVIEW path=%s rows=%d reason=%s", path, len(group_rows), decision["reason"])
continue
log.warning(
"DUPLICATE path=%s keep=canonical delete=%d reason=%s",
path, len(decision["deleteIds"]), decision["reason"],
)
to_delete += len(decision["deleteIds"])
if DRY_RUN:
continue
for seo_url_id in decision["deleteIds"]:
delete_seo_url(token, seo_url_id)
deleted += 1
log.info(
"Done. %d duplicate row(s) %s, %d group(s) left for manual review.",
to_delete if DRY_RUN else deleted,
"to delete" if DRY_RUN else "deleted",
review,
)
if __name__ == "__main__":
run()
/**
* Find Shopware 6 seo_url rows that duplicate the same path, and reconcile them, safely.
*
* Shopware never overwrites a seo-url row in place. Every regeneration, whether from a
* template change, a category move, or a repeated reindex, writes a new row and marks it
* isCanonical, while the previous row for that entity is kept with isCanonical false for
* redirect history. Repeat that enough times without cleanup and several rows end up
* claiming the same seoPathInfo. This script groups seo-url rows by path within a sales
* channel and language, and, only behind an explicit apply flag, deletes every row in a
* duplicate group except the one Shopware already marked canonical. Any group where zero
* or more than one row claims to be canonical is left for manual review.
* 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 SALES_CHANNEL_ID = process.env.SEO_SALES_CHANNEL_ID || "";
const LANGUAGE_ID = process.env.SEO_LANGUAGE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function groupByPath(rows) {
const groups = {};
for (const row of rows) {
if (!groups[row.seoPathInfo]) groups[row.seoPathInfo] = [];
groups[row.seoPathInfo].push(row);
}
return groups;
}
export function reconcileSeoUrlGroup(rows) {
if (rows.length <= 1) {
return { action: "skip", reason: "single row for this path", deleteIds: [] };
}
const canonicalRows = rows.filter((r) => r.isCanonical);
if (canonicalRows.length !== 1) {
return { action: "review", reason: "zero or multiple canonical rows, needs a human", deleteIds: [] };
}
const canonicalId = canonicalRows[0].id;
const deleteIds = rows.map((r) => r.id).filter((id) => id !== canonicalId).sort();
return { action: "delete", reason: "duplicate rows for the same path", deleteIds };
}
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 searchSeoUrls(token, salesChannelId, languageId, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [
{ type: "equals", field: "salesChannelId", value: salesChannelId },
{ type: "equals", field: "languageId", value: languageId },
],
sort: [{ field: "seoPathInfo", order: "ASC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/seo-url", token, body);
}
async function deleteSeoUrl(token, seoUrlId) {
await api("DELETE", `/api/seo-url/${seoUrlId}`, token);
}
async function fetchAllRows(token) {
const rows = [];
let page = 1;
while (true) {
const data = await searchSeoUrls(token, SALES_CHANNEL_ID, LANGUAGE_ID, page);
const batch = data.data || [];
rows.push(...batch);
if (batch.length === 0 || page * 500 >= (data.total || 0)) break;
page++;
}
return rows;
}
export async function run() {
if (!SALES_CHANNEL_ID || !LANGUAGE_ID) {
throw new Error("Set SEO_SALES_CHANNEL_ID and SEO_LANGUAGE_ID before running.");
}
const token = await getToken();
const rows = await fetchAllRows(token);
const groups = groupByPath(rows);
let deleted = 0;
let toDelete = 0;
let review = 0;
for (const [path, groupRows] of Object.entries(groups)) {
const decision = reconcileSeoUrlGroup(groupRows);
if (decision.action === "skip") continue;
if (decision.action === "review") {
review++;
console.warn(`REVIEW path=${path} rows=${groupRows.length} reason=${decision.reason}`);
continue;
}
console.warn(
`DUPLICATE path=${path} keep=canonical delete=${decision.deleteIds.length} reason=${decision.reason}`
);
toDelete += decision.deleteIds.length;
if (DRY_RUN) continue;
for (const seoUrlId of decision.deleteIds) {
await deleteSeoUrl(token, seoUrlId);
deleted++;
}
}
console.log(
`Done. ${DRY_RUN ? toDelete : deleted} duplicate row(s) ${DRY_RUN ? "to delete" : "deleted"}, ${review} group(s) left for manual review.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The grouping and the reconcile rule are the parts most worth testing, because together they decide which rows get deleted. Because group_by_path and reconcile_seo_url_group are pure, no network and no Shopware instance is needed. They just take plain lists in and check the answer.
from reconcile_duplicate_seo_urls import group_by_path, reconcile_seo_url_group
def row(**over):
base = {"id": "a" * 32, "seoPathInfo": "/womens-boots/winter-boots", "isCanonical": True}
base.update(over)
return base
def test_skip_when_single_row():
result = reconcile_seo_url_group([row()])
assert result == {"action": "skip", "reason": "single row for this path", "deleteIds": []}
def test_delete_extras_when_one_canonical_row():
rows = [
row(id="a" * 32, isCanonical=False),
row(id="b" * 32, isCanonical=True),
row(id="c" * 32, isCanonical=False),
]
result = reconcile_seo_url_group(rows)
assert result["action"] == "delete"
assert sorted(result["deleteIds"]) == sorted(["a" * 32, "c" * 32])
def test_review_when_no_canonical_row():
rows = [row(id="a" * 32, isCanonical=False), row(id="b" * 32, isCanonical=False)]
result = reconcile_seo_url_group(rows)
assert result == {"action": "review", "reason": "zero or multiple canonical rows, needs a human", "deleteIds": []}
def test_review_when_multiple_canonical_rows():
rows = [row(id="a" * 32, isCanonical=True), row(id="b" * 32, isCanonical=True)]
result = reconcile_seo_url_group(rows)
assert result["action"] == "review"
def test_group_by_path_groups_matching_paths():
rows = [
row(id="a" * 32, seoPathInfo="/p1"),
row(id="b" * 32, seoPathInfo="/p2"),
row(id="c" * 32, seoPathInfo="/p1"),
]
groups = group_by_path(rows)
assert sorted(groups.keys()) == ["/p1", "/p2"]
assert len(groups["/p1"]) == 2
assert len(groups["/p2"]) == 1
def test_empty_group_list_returns_no_groups():
assert group_by_path([]) == {}
import { test } from "node:test";
import assert from "node:assert/strict";
import { groupByPath, reconcileSeoUrlGroup } from "./reconcile-duplicate-seo-urls.js";
const row = (over = {}) => ({ id: "a".repeat(32), seoPathInfo: "/womens-boots/winter-boots", isCanonical: true, ...over });
test("skip when single row", () => {
const result = reconcileSeoUrlGroup([row()]);
assert.deepEqual(result, { action: "skip", reason: "single row for this path", deleteIds: [] });
});
test("delete extras when one canonical row", () => {
const rows = [
row({ id: "a".repeat(32), isCanonical: false }),
row({ id: "b".repeat(32), isCanonical: true }),
row({ id: "c".repeat(32), isCanonical: false }),
];
const result = reconcileSeoUrlGroup(rows);
assert.equal(result.action, "delete");
assert.deepEqual(result.deleteIds.slice().sort(), ["a".repeat(32), "c".repeat(32)].sort());
});
test("review when no canonical row", () => {
const rows = [row({ id: "a".repeat(32), isCanonical: false }), row({ id: "b".repeat(32), isCanonical: false })];
const result = reconcileSeoUrlGroup(rows);
assert.deepEqual(result, { action: "review", reason: "zero or multiple canonical rows, needs a human", deleteIds: [] });
});
test("review when multiple canonical rows", () => {
const rows = [row({ id: "a".repeat(32), isCanonical: true }), row({ id: "b".repeat(32), isCanonical: true })];
const result = reconcileSeoUrlGroup(rows);
assert.equal(result.action, "review");
});
test("groupByPath groups matching paths", () => {
const rows = [
row({ id: "a".repeat(32), seoPathInfo: "/p1" }),
row({ id: "b".repeat(32), seoPathInfo: "/p2" }),
row({ id: "c".repeat(32), seoPathInfo: "/p1" }),
];
const groups = groupByPath(rows);
assert.deepEqual(Object.keys(groups).sort(), ["/p1", "/p2"]);
assert.equal(groups["/p1"].length, 2);
assert.equal(groups["/p2"].length, 1);
});
test("empty group list returns no groups", () => {
assert.deepEqual(groupByPath([]), {});
});
Case studies
A fashion store changed its category path template twice in one migration
A fashion retailer adjusted its SEO URL template to include a parent category segment, triggered a full reindex, then adjusted it again a week later after feedback from the SEO team, triggering a second full reindex. Every affected category and product ended up with two or three seo-url rows sharing what looked like the same final path, and a direct database query for one product returned four rows where only one was ever live.
Running the reconciler in dry run against that sales channel and language grouped the rows cleanly and reported that of a few thousand duplicate groups, all but a handful had exactly one canonical row already. Applying the fix removed the extra rows in one pass, and the handful of ambiguous groups, left over from a partial reindex, were reviewed by hand before anything was deleted.
A product import job that retried after timeouts left triple rows behind
A wholesale importer ran a nightly product sync that called the Shopware indexer for each batch, and a slow database connection caused several batches to time out and get automatically retried by the job runner. Each retry regenerated the SEO URLs for the same products again, and after a month several thousand products had two or three rows competing for the same path.
The team scheduled the reconciler to run weekly in dry run first, confirmed the numbers matched what they expected from the retry logs, then let it run for real. The seo-url table shrank back down, storefront path lookups got faster, and the retried-import bug in the job runner was fixed separately so the duplicates stopped growing.
After this runs on a schedule, a path that used to have three or four competing seo-url rows has exactly one, the row Shopware itself already marked canonical. Groups where Shopware's own data does not clearly say which row is canonical are never guessed at, they are reported for a human to look at. The storefront never notices a difference, because it was already reading the canonical row, but every future lookup on that table gets a little faster and a little easier to trust.
FAQ
Why does Shopware 6 create a new seo_url row instead of reusing the old one?
Shopware's SEO URL generation writes a new row every time it builds a URL for an entity in a sales channel and language, and it marks the previous row as no longer canonical rather than deleting it. When a template, a category move, or a bulk reindex runs repeatedly without the old rows ever being cleaned up, several rows end up pointing at the same seoPathInfo path with only one of them flagged isCanonical true.
Is it safe to delete old seo_url rows automatically?
Only once you have confirmed which row in a group is the current canonical one. The safe pattern is to group rows by their path, keep the canonical row that Shopware itself marked, and only ever remove or unpublish the other rows in that same group. Never touch a row that is the only one for its path, and never touch the canonical row itself.
What does isCanonical mean on a seo-url row?
isCanonical is a boolean field on the seo-url entity. Shopware sets it true on exactly the one row it currently considers the live URL for an entity in a given sales channel and language. Older rows for the same entity are kept for redirect history but flagged isCanonical false, and it is those false rows that pile up into duplicates when cleanup never runs.
Related field notes
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 path?
If this saved you a confusing table or a slow lookup, 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