Reconciler URL Rewrites
Duplicate url_key blocks product resave after migration
You open a migrated product, change nothing important, hit save, and Magento throws back "URL key for specified store already exists." The product looks fine. The url_key field looks fine. But somewhere else in the catalog sits another product with the same url_key, or one that normalizes to the same request_path once the suffix and category path are applied, and Magento 2 will not let either of them save until that collision is resolved. Here is why Magento 1 never caught this, why Magento 2 refuses to, and a small script that finds every collision over REST and reports a safe rename.
Magento 1 and many external carts or PIMs never enforced url_key uniqueness, so a migrated catalog frequently carries two or more products whose url_key collides once the store's URL suffix and category path are applied. Magento 2 enforces a unique constraint on url_rewrite(request_path, store_id), so resaving one of the colliding products calls UrlPersist, which tries to insert a request_path row that already belongs to a different entity_id and throws an AlreadyExistsException, blocking the save. Run a small Python or Node.js script that pulls every product over GET /rest/V1/products, groups them by their expected request_path, and reports each collision group with a proposed rename of <sku>-<original-url-key> for every product except the one it keeps as the true owner. It only calls the repair PUT when DRY_RUN=false, because renaming a live url_key changes a public-facing URL. Full code, tests, and sources are below.
The problem in plain words
Every product's public URL is a row Magento keeps in url_rewrite: a request_path the browser asks for, tied to a specific store_id. Magento 2 treats that pair as unique. Only one product is allowed to own a given request_path in a given store.
Magento 1 did not enforce this at the url_key attribute level the way Magento 2 does, and neither do most external carts or PIMs that stores migrate away from. So it was entirely possible, and common, for two products to end up with the same url_key, or with different url_key values that still collide once the store's URL suffix (like .html) and category path are appended to form the final request_path. When a migration tool or a bulk import bulk-inserts url_rewrite rows without deduplicating first, that collision quietly lands in the table. Nothing breaks yet. The storefront may even resolve to one of the two products just fine. It is only when someone opens the other colliding product and saves it that Magento 2's own validation and the database's unique constraint both refuse the write.
Why it happens
Magento 2 validates url_key uniqueness per store view on save, and the database backs that up with a unique constraint on url_rewrite(request_path, store_id). A few common ways stores end up with a catalog that already violates it before anyone touches a save button:
- Magento 1 never enforced
url_keyuniqueness at the attribute level, so two products with the same name or a copy-pasted key could both save fine there, a pattern reported inmagento/magento2issue #12412. - An external cart or PIM migrating into Magento 2 generates its own
url_keyvalues with no awareness of Magento's uniqueness rule, and the migration tool copies them over as is. - The data migration tool bulk-inserts
url_rewriterows straight from the source without deduplicating first, the exact failure mode tracked inmagento/data-migration-toolissue #606. - A category being renamed or reassigned changes the effective
request_pathfor products inside it, so two products whose bareurl_keyvalues were previously distinct can still collide once the category path is applied, a variant of the category save conflict reported inmagento/magento2issue #7298.
The result is confusing because nothing looks wrong until someone tries to save. The product edit form shows one url_key, no visible duplicate, and yet Magento insists the URL key already exists somewhere else in the same store. See the citations at the end for the exact reports.
The REST API has no endpoint that lists url_rewrite rows directly, so a script cannot ask Magento "which request_paths collide" in one call. What it can do is pull every product's sku and url_key custom attribute from GET /rest/V1/products, compute the expected request_path the same way Magento would, and group products by that computed path. For a real fix, reading the url_rewrite table directly with a grouped count query is more reliable, since it also tells you which row is autogenerated versus a manual redirect. Either way, the repair is never a direct write to url_rewrite. It is a PUT to the colliding product's own url_key, forcing Magento to regenerate a unique rewrite on save, and it stays behind a dry run guard because it changes a public-facing SEO URL.
The fix, as a flow
The script fetches products over REST, groups them by their expected request_path and store_id, and for every group with more than one product picks a keeper, preferring a manual redirect over an autogenerated row, otherwise the lowest entity_id as the original owner. Every other product in the group gets a proposed new url_key of <sku>-<original-url-key>. In dry run, the script only prints that plan. Only with DRY_RUN=false does it call PUT /rest/V1/products/{sku} to apply the rename.
Build it step by step
Get an admin bearer token
Call POST /rest/V1/integration/admin/token with your admin username and password, or use a preconfigured integration token. Either way you end up with a bearer token you send as Authorization: Bearer <token> on every call. Keep the token and the store URL in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export URL_SUFFIX=".html"
export DRY_RUN="true" # start safe, change to false to allow the rename PUT
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export URL_SUFFIX=".html"
export DRY_RUN="true" // start safe, change to false to allow the rename PUT
Talk to the Magento REST API
Every call goes to {MAGENTO_URL}/rest/V1 with your token in the Authorization header. A small helper sends the request and raises on a non success status, and we reuse it for reading products and for the repair PUT.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Pull every product's sku and url_key
GET /rest/V1/products paged with searchCriteria[pageSize] and [currentPage] returns every product, including the url_key custom attribute, the numeric id, and updated_at. Because REST has no direct url_rewrite listing endpoint, this is the input the pure decision function groups by expected request_path. For a repair that also knows which row is is_autogenerated, pair this with a read-only grouped-count query against url_rewrite.
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_products():
products, page = [], 1
while True:
params = {"searchCriteria[pageSize]": 100, "searchCriteria[currentPage]": page}
result = api_get("/products", params)
items = result.get("items", [])
for item in items:
products.append({
"sku": item["sku"],
"entityId": item["id"],
"urlKey": custom_attr(item.get("custom_attributes"), "url_key", item["sku"]),
"updatedAt": item.get("updated_at", ""),
})
if len(items) < 100:
return products
page += 1
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function fetchProducts() {
const products = [];
let page = 1;
while (true) {
const params = { "searchCriteria[pageSize]": 100, "searchCriteria[currentPage]": page };
const result = await apiGet("/products", params);
const items = result.items || [];
for (const item of items) {
products.push({
sku: item.sku,
entityId: item.id,
urlKey: customAttr(item.custom_attributes, "url_key", item.sku),
updatedAt: item.updated_at || "",
});
}
if (items.length < 100) return products;
page++;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the products already fetched and an index of url_rewrite rows keyed by request_path, and returns one decision record per colliding product. It groups by (request_path, store_id), and for any group with more than one entity_id, picks the keeper first by a manual redirect over an autogenerated row, otherwise the lowest entityId as the original owner. Every other product in the group gets a deterministic new url_key of sku-urlKey, lowercased and slugified. No network calls, so it is easy to test with fixture arrays.
import re
def slugify(value):
value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return re.sub(r"-+", "-", value)
def pick_keeper(rows):
manual = [r for r in rows if not r.get("isAutogenerated", True)]
if manual:
return min(manual, key=lambda r: r["entityId"])
return min(rows, key=lambda r: r["entityId"])
def resolve_url_key_collisions(products, request_path_index):
decisions = []
for request_path, rows in request_path_index.items():
entity_ids = {r["entityId"] for r in rows}
if len(entity_ids) <= 1:
continue
keeper_row = pick_keeper(rows)
for row in rows:
product = next((p for p in products if p["entityId"] == row["entityId"]), None)
if product is None:
continue
if row["entityId"] == keeper_row["entityId"]:
decisions.append({
"sku": product["sku"], "oldUrlKey": product["urlKey"],
"newUrlKey": product["urlKey"], "requestPath": request_path, "action": "keep",
})
else:
new_key = slugify(f"{product['sku']}-{product['urlKey']}")
decisions.append({
"sku": product["sku"], "oldUrlKey": product["urlKey"],
"newUrlKey": new_key, "requestPath": request_path, "action": "rename",
})
return decisions
function slugify(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
}
function pickKeeper(rows) {
const manual = rows.filter((r) => !r.isAutogenerated);
const pool = manual.length ? manual : rows;
return pool.reduce((a, b) => (b.entityId < a.entityId ? b : a));
}
export function resolveUrlKeyCollisions(products, requestPathIndex) {
const decisions = [];
for (const [requestPath, rows] of requestPathIndex) {
const entityIds = new Set(rows.map((r) => r.entityId));
if (entityIds.size <= 1) continue;
const keeperRow = pickKeeper(rows);
for (const row of rows) {
const product = products.find((p) => p.entityId === row.entityId);
if (!product) continue;
if (row.entityId === keeperRow.entityId) {
decisions.push({ sku: product.sku, oldUrlKey: product.urlKey, newUrlKey: product.urlKey, requestPath, action: "keep" });
} else {
const newKey = slugify(`${product.sku}-${product.urlKey}`);
decisions.push({ sku: product.sku, oldUrlKey: product.urlKey, newUrlKey: newKey, requestPath, action: "rename" });
}
}
}
return decisions;
}
Apply a rename only when DRY_RUN is false
For every decision with action: 'rename', the repair is PUT /rest/V1/products/{sku} with the new url_key as a custom attribute. Magento then regenerates a unique url_rewrite row on save. This is guarded by DRY_RUN because renaming a live SEO URL changes a public-facing link. Truncating rows directly in url_rewrite is not done via REST at all and needs CLI access plus a full reindex, so that path is reported only, never executed.
def api_put(path, payload):
r = requests.put(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, json=payload, timeout=30)
r.raise_for_status()
return r.json()
def rename_url_key(sku, new_url_key):
payload = {"product": {"sku": sku, "custom_attributes": [
{"attribute_code": "url_key", "value": new_url_key}
]}}
return api_put(f"/products/{sku}", payload)
async function apiPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function renameUrlKey(sku, newUrlKey) {
const payload = {
product: { sku, custom_attributes: [{ attribute_code: "url_key", value: newUrlKey }] },
};
return apiPut(`/products/${sku}`, payload);
}
Wire it together with a dry run guard
The loop ties every piece together. In dry run it only logs each decision, sku, old and new url_key, and request_path, with no writes at all. With DRY_RUN=false it issues the rename PUT one product at a time, skipping any decision marked keep. True orphans or rows with no live entity_id at all still need CLI access and a reindex, so this script only reports on those, it never touches them.
Always start with DRY_RUN=true and read the planned renames before changing anything. A rename changes a public-facing URL, so review the list, and never truncate or delete rows directly in url_rewrite from a script, that path needs CLI access, a full reindex with bin/magento indexer:reindex catalog_url_rewrite_product, cron, and a cache flush.
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 only ever renames a colliding product's own url_key through the normal REST API, one at a time.
"""Detect duplicate Magento url_key collisions that block a product resave,
and repair the fixable ones, safely.
Magento 1 and many external carts or PIMs never enforced url_key uniqueness,
so a migrated catalog can carry two or more products whose url_key collides
once the store's URL suffix and category path are applied. Magento 2
enforces a unique constraint on url_rewrite(request_path, store_id), so
resaving one of the colliding products throws AlreadyExistsException.
This script fetches every product over REST, groups them by their expected
request_path, keeps the row Magento already treats as authoritative (a
manual redirect over an autogenerated one, else the lowest entity_id), and
proposes a new url_key of sku-original_url_key for every other colliding
product. It only calls the repair PUT when DRY_RUN=false, because renaming
a live url_key changes a public-facing SEO URL. Report only by default.
"""
import os
import re
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("duplicate_url_key")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
URL_SUFFIX = os.environ.get("URL_SUFFIX", ".html")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, payload):
r = requests.put(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, json=payload, timeout=30)
r.raise_for_status()
return r.json()
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_products():
products, page = [], 1
while True:
params = {"searchCriteria[pageSize]": 100, "searchCriteria[currentPage]": page}
result = api_get("/products", params)
items = result.get("items", [])
for item in items:
products.append({
"sku": item["sku"],
"entityId": item["id"],
"urlKey": custom_attr(item.get("custom_attributes"), "url_key", item["sku"]),
"updatedAt": item.get("updated_at", ""),
})
if len(items) < 100:
return products
page += 1
def build_request_path_index(products, url_suffix):
"""Fallback index built from products alone, grouping by the expected
request_path (url_key + suffix). A real migration should instead read
url_rewrite directly for is_autogenerated accuracy; this covers the
common case where two products share or normalize to one request_path."""
index = {}
for p in products:
request_path = f"{p['urlKey']}{url_suffix}"
index.setdefault(request_path, []).append({
"entityId": p["entityId"],
"requestPath": request_path,
"storeId": 1,
"isAutogenerated": True,
})
return index
def slugify(value):
value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return re.sub(r"-+", "-", value)
def pick_keeper(rows):
manual = [r for r in rows if not r.get("isAutogenerated", True)]
if manual:
return min(manual, key=lambda r: r["entityId"])
return min(rows, key=lambda r: r["entityId"])
def resolve_url_key_collisions(products, request_path_index):
decisions = []
for request_path, rows in request_path_index.items():
entity_ids = {r["entityId"] for r in rows}
if len(entity_ids) <= 1:
continue
keeper_row = pick_keeper(rows)
for row in rows:
product = next((p for p in products if p["entityId"] == row["entityId"]), None)
if product is None:
continue
if row["entityId"] == keeper_row["entityId"]:
decisions.append({
"sku": product["sku"], "oldUrlKey": product["urlKey"],
"newUrlKey": product["urlKey"], "requestPath": request_path, "action": "keep",
})
else:
new_key = slugify(f"{product['sku']}-{product['urlKey']}")
decisions.append({
"sku": product["sku"], "oldUrlKey": product["urlKey"],
"newUrlKey": new_key, "requestPath": request_path, "action": "rename",
})
return decisions
def rename_url_key(sku, new_url_key):
payload = {"product": {"sku": sku, "custom_attributes": [
{"attribute_code": "url_key", "value": new_url_key}
]}}
return api_put(f"/products/{sku}", payload)
def run():
products = fetch_products()
request_path_index = build_request_path_index(products, URL_SUFFIX)
decisions = resolve_url_key_collisions(products, request_path_index)
renamed = 0
for decision in decisions:
if decision["action"] != "rename":
continue
log.warning(
"Collision on %s: sku=%s %s -> %s",
decision["requestPath"], decision["sku"], decision["oldUrlKey"], decision["newUrlKey"],
)
if not DRY_RUN:
rename_url_key(decision["sku"], decision["newUrlKey"])
renamed += 1
log.info("Done. %d colliding product(s) %s.", renamed, "to rename" if DRY_RUN else "renamed")
if __name__ == "__main__":
run()
/**
* Detect duplicate Magento url_key collisions that block a product resave,
* and repair the fixable ones, safely.
*
* Magento 1 and many external carts or PIMs never enforced url_key
* uniqueness, so a migrated catalog can carry two or more products whose
* url_key collides once the store's URL suffix and category path are
* applied. Magento 2 enforces a unique constraint on
* url_rewrite(request_path, store_id), so resaving one of the colliding
* products throws AlreadyExistsException.
*
* This script fetches every product over REST, groups them by their
* expected request_path, keeps the row Magento already treats as
* authoritative, and proposes a new url_key for every other colliding
* product. It only calls the repair PUT when DRY_RUN=false.
*
* Guide: https://www.allanninal.dev/magento/duplicate-url-key-blocks-resave/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "dummy-token";
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const URL_SUFFIX = process.env.URL_SUFFIX || ".html";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function slugify(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
}
function pickKeeper(rows) {
const manual = rows.filter((r) => !r.isAutogenerated);
const pool = manual.length ? manual : rows;
return pool.reduce((a, b) => (b.entityId < a.entityId ? b : a));
}
export function resolveUrlKeyCollisions(products, requestPathIndex) {
const decisions = [];
for (const [requestPath, rows] of requestPathIndex) {
const entityIds = new Set(rows.map((r) => r.entityId));
if (entityIds.size <= 1) continue;
const keeperRow = pickKeeper(rows);
for (const row of rows) {
const product = products.find((p) => p.entityId === row.entityId);
if (!product) continue;
if (row.entityId === keeperRow.entityId) {
decisions.push({ sku: product.sku, oldUrlKey: product.urlKey, newUrlKey: product.urlKey, requestPath, action: "keep" });
} else {
const newKey = slugify(`${product.sku}-${product.urlKey}`);
decisions.push({ sku: product.sku, oldUrlKey: product.urlKey, newUrlKey: newKey, requestPath, action: "rename" });
}
}
}
return decisions;
}
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function apiPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function fetchProducts() {
const products = [];
let page = 1;
while (true) {
const params = { "searchCriteria[pageSize]": 100, "searchCriteria[currentPage]": page };
const result = await apiGet("/products", params);
const items = result.items || [];
for (const item of items) {
products.push({
sku: item.sku,
entityId: item.id,
urlKey: customAttr(item.custom_attributes, "url_key", item.sku),
updatedAt: item.updated_at || "",
});
}
if (items.length < 100) return products;
page++;
}
}
function buildRequestPathIndex(products, urlSuffix) {
const index = new Map();
for (const p of products) {
const requestPath = `${p.urlKey}${urlSuffix}`;
if (!index.has(requestPath)) index.set(requestPath, []);
index.get(requestPath).push({ entityId: p.entityId, requestPath, storeId: 1, isAutogenerated: true });
}
return index;
}
async function renameUrlKey(sku, newUrlKey) {
const payload = {
product: { sku, custom_attributes: [{ attribute_code: "url_key", value: newUrlKey }] },
};
return apiPut(`/products/${sku}`, payload);
}
export async function run() {
const products = await fetchProducts();
const requestPathIndex = buildRequestPathIndex(products, URL_SUFFIX);
const decisions = resolveUrlKeyCollisions(products, requestPathIndex);
let renamed = 0;
for (const decision of decisions) {
if (decision.action !== "rename") continue;
console.warn(`Collision on ${decision.requestPath}: sku=${decision.sku} ${decision.oldUrlKey} -> ${decision.newUrlKey}`);
if (!DRY_RUN) await renameUrlKey(decision.sku, decision.newUrlKey);
renamed++;
}
console.log(`Done. ${renamed} colliding product(s) ${DRY_RUN ? "to rename" : "renamed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The collision rule is the part most worth testing, because it decides which product keeps its url_key and which one gets renamed on a real store's catalog. Because resolve_url_key_collisions is pure, the test needs no network and no Magento store. It just feeds in fixture arrays and checks the answer.
from duplicate_url_key import resolve_url_key_collisions
def product(**over):
base = {"sku": "SKU-1", "entityId": 1, "urlKey": "blue-widget", "updatedAt": "2026-01-01T00:00:00Z"}
base.update(over)
return base
def row(**over):
base = {"entityId": 1, "requestPath": "blue-widget.html", "storeId": 1, "isAutogenerated": True}
base.update(over)
return base
def test_no_decisions_when_request_path_is_unique():
products = [product(sku="SKU-1", entityId=1)]
index = {"blue-widget.html": [row(entityId=1)]}
assert resolve_url_key_collisions(products, index) == []
def test_lowest_entity_id_is_kept_when_both_autogenerated():
products = [product(sku="SKU-1", entityId=1), product(sku="SKU-2", entityId=2, urlKey="blue-widget")]
index = {"blue-widget.html": [row(entityId=1), row(entityId=2)]}
decisions = resolve_url_key_collisions(products, index)
kept = next(d for d in decisions if d["action"] == "keep")
renamed = next(d for d in decisions if d["action"] == "rename")
assert kept["sku"] == "SKU-1"
assert renamed["sku"] == "SKU-2"
assert renamed["newUrlKey"] == "sku-2-blue-widget"
def test_manual_redirect_is_kept_over_autogenerated():
products = [product(sku="SKU-1", entityId=1), product(sku="SKU-2", entityId=2, urlKey="blue-widget")]
index = {"blue-widget.html": [row(entityId=1, isAutogenerated=True), row(entityId=2, isAutogenerated=False)]}
decisions = resolve_url_key_collisions(products, index)
kept = next(d for d in decisions if d["action"] == "keep")
assert kept["sku"] == "SKU-2"
def test_new_url_key_is_slugified():
products = [product(sku="SKU-1", entityId=1), product(sku="SKU 2!", entityId=2, urlKey="Blue Widget")]
index = {"blue-widget.html": [row(entityId=1), row(entityId=2)]}
decisions = resolve_url_key_collisions(products, index)
renamed = next(d for d in decisions if d["action"] == "rename")
assert renamed["newUrlKey"] == "sku-2-blue-widget"
def test_three_way_collision_keeps_only_one():
products = [product(sku="A", entityId=3), product(sku="B", entityId=1), product(sku="C", entityId=2)]
index = {"blue-widget.html": [row(entityId=3), row(entityId=1), row(entityId=2)]}
decisions = resolve_url_key_collisions(products, index)
kept = [d for d in decisions if d["action"] == "keep"]
renamed = [d for d in decisions if d["action"] == "rename"]
assert len(kept) == 1
assert kept[0]["sku"] == "B"
assert len(renamed) == 2
def test_missing_product_for_a_row_is_skipped():
products = [product(sku="SKU-1", entityId=1)]
index = {"blue-widget.html": [row(entityId=1), row(entityId=99)]}
decisions = resolve_url_key_collisions(products, index)
assert all(d["sku"] != "" for d in decisions)
assert len(decisions) == 1
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveUrlKeyCollisions } from "./duplicate-url-key.js";
const product = (over = {}) => ({ sku: "SKU-1", entityId: 1, urlKey: "blue-widget", updatedAt: "2026-01-01T00:00:00Z", ...over });
const row = (over = {}) => ({ entityId: 1, requestPath: "blue-widget.html", storeId: 1, isAutogenerated: true, ...over });
test("no decisions when request_path is unique", () => {
const products = [product({ sku: "SKU-1", entityId: 1 })];
const index = new Map([["blue-widget.html", [row({ entityId: 1 })]]]);
assert.deepEqual(resolveUrlKeyCollisions(products, index), []);
});
test("lowest entityId is kept when both autogenerated", () => {
const products = [product({ sku: "SKU-1", entityId: 1 }), product({ sku: "SKU-2", entityId: 2, urlKey: "blue-widget" })];
const index = new Map([["blue-widget.html", [row({ entityId: 1 }), row({ entityId: 2 })]]]);
const decisions = resolveUrlKeyCollisions(products, index);
const kept = decisions.find((d) => d.action === "keep");
const renamed = decisions.find((d) => d.action === "rename");
assert.equal(kept.sku, "SKU-1");
assert.equal(renamed.sku, "SKU-2");
assert.equal(renamed.newUrlKey, "sku-2-blue-widget");
});
test("manual redirect is kept over autogenerated", () => {
const products = [product({ sku: "SKU-1", entityId: 1 }), product({ sku: "SKU-2", entityId: 2, urlKey: "blue-widget" })];
const index = new Map([["blue-widget.html", [row({ entityId: 1, isAutogenerated: true }), row({ entityId: 2, isAutogenerated: false })]]]);
const decisions = resolveUrlKeyCollisions(products, index);
const kept = decisions.find((d) => d.action === "keep");
assert.equal(kept.sku, "SKU-2");
});
test("new url_key is slugified", () => {
const products = [product({ sku: "SKU-1", entityId: 1 }), product({ sku: "SKU 2!", entityId: 2, urlKey: "Blue Widget" })];
const index = new Map([["blue-widget.html", [row({ entityId: 1 }), row({ entityId: 2 })]]]);
const decisions = resolveUrlKeyCollisions(products, index);
const renamed = decisions.find((d) => d.action === "rename");
assert.equal(renamed.newUrlKey, "sku-2-blue-widget");
});
test("three way collision keeps only one", () => {
const products = [product({ sku: "A", entityId: 3 }), product({ sku: "B", entityId: 1 }), product({ sku: "C", entityId: 2 })];
const index = new Map([["blue-widget.html", [row({ entityId: 3 }), row({ entityId: 1 }), row({ entityId: 2 })]]]);
const decisions = resolveUrlKeyCollisions(products, index);
const kept = decisions.filter((d) => d.action === "keep");
const renamed = decisions.filter((d) => d.action === "rename");
assert.equal(kept.length, 1);
assert.equal(kept[0].sku, "B");
assert.equal(renamed.length, 2);
});
test("missing product for a row is skipped", () => {
const products = [product({ sku: "SKU-1", entityId: 1 })];
const index = new Map([["blue-widget.html", [row({ entityId: 1 }), row({ entityId: 99 })]]]);
const decisions = resolveUrlKeyCollisions(products, index);
assert.equal(decisions.length, 1);
});
Case studies
Two products with the same name blocked every resave
A store migrated from Magento 1, where a "Blue Widget" and a discontinued "Blue Widget (2019)" had both quietly saved with the same url_key, something Magento 1 never checked. The migration copied both into the catalog. Nobody noticed until a merchandiser tried to update the current product's price and got "URL key for specified store already exists" with no obvious duplicate in sight, the exact confusion reported in magento/magento2 issue #12412.
Running the collision script against the exported product list surfaced the pair immediately, with the older entityId kept and the newer SKU renamed to sku-2-blue-widget. Reviewing the plan in dry run first confirmed it was safe, and the rename PUT unblocked the resave.
A category rename created a request_path collision after the fact
An external PIM generated url_key values with no knowledge of Magento's per-store uniqueness rule. Two unrelated products had distinct keys on their own, but a later category reorganization meant both effectively resolved to the same request_path once the category path was applied, similar to the category save conflict in magento/magento2 issue #7298.
The script's grouped view showed both SKUs under one colliding request_path. The team kept the manually redirected product as the true owner and renamed the other, avoiding a guess at which one "should" win.
After running this against a freshly migrated catalog, every url_key collision is a named, reviewable plan instead of a mysterious save error. Nobody has to hunt for the other product sharing a request_path by hand, and no url_key changes without a person reading the exact sku, old key, and new key first. Resaves that used to throw AlreadyExistsException now go through clean.
FAQ
Why does saving a migrated Magento product throw URL key for specified store already exists?
Magento 1 and many external carts or PIMs never enforced url_key uniqueness, so a migrated catalog can carry two or more products whose url_key values collide once the store's URL suffix and category path are applied. Magento 2 enforces a unique constraint on url_rewrite(request_path, store_id), so when you resave one of the colliding products, UrlPersist tries to insert a request_path row that already belongs to a different entity_id and throws an AlreadyExistsException.
Is it safe to auto-rename url_key values with a script?
Renaming a live url_key changes a public-facing SEO URL, so it should never happen silently. The safe pattern is DRY_RUN=true by default, so the script only prints the planned sku, old url_key, new url_key, and request_path for every collision, and never calls the PUT that renames anything until a person reviews the plan and turns dry run off.
How does the script decide which colliding product keeps the original url_key?
For every group of products sharing the same request_path and store_id, the pure decision function keeps the row Magento already treats as authoritative, preferring a manually redirected row over an autogenerated one, and otherwise the lowest entity_id as the original owner. Every other product in the group gets a new url_key derived deterministically as sku plus the original url_key.
Related field notes
Citations
On the problem:
- magento/magento2: Product duplication of imported products "fails" due to url rewrites. github.com/magento/magento2/issues/12412
- magento/magento2: "URL key for specified store already exists." cannot save category. github.com/magento/magento2/issues/7298
- magento/data-migration-tool: Duplicate URL rewrites. github.com/magento/data-migration-tool/issues/606
On the solution:
- Adobe Commerce/Magento REST API: Products endpoints. developer.adobe.com order-management-create-order
- Adobe Commerce/Magento Developer Docs: Search using REST APIs. developer.adobe.com performing-searches
- Adobe Commerce/Magento Developer Docs: URL rewrites. developer.adobe.com url-rewrites
Stuck on a tricky one?
If you have a problem in Magento catalog data, URL rewrites, cron, or MSI stock 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 unblock your resave?
If this saved you from hunting for a phantom duplicate across a migrated catalog, 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