Reconciler Modules, links, and data
Duplicate product handles from a Medusa import
A CSV import ran, maybe twice, maybe with a few blank titles in the sheet, and now two or three products in your store share the exact same handle. Nothing errored. The import finished green. But /store/products/{handle} can only ever resolve to one of them, so whichever product the storefront happens to pick first quietly wins the canonical URL, and the others become orphaned duplicates sitting in the catalog. Here is why Medusa lets this happen and a small reconciler that finds every collision before you decide what to do about it.
Medusa v2's Product Module auto-generates a handle, a kebab-case version of the title, whenever a create payload leaves it out. That generation happens inside createProductsWorkflow or batchProductsWorkflow, one row at a time, and it never queries the rest of your catalog for a collision. The handle column also has no enforced unique constraint in the database, unlike sku or ean on variants. So a CSV import with duplicate or blank titles across rows, or one that gets re-run after a partial failure, can leave several prod_* records sharing one handle. Run a small Python or Node.js script that lists every product, groups them by handle, and reports every group with more than one member so a human can decide what to do. Full code, tests, and a dry run guarded repair option are below.
The problem in plain words
When you create a product in Medusa v2 without a handle field, the Product Module fills one in for you by turning the title into a kebab-case slug. That is convenient for a single product created by hand in the dashboard, because you almost always have a unique title in front of you.
A bulk import is a different situation. A CSV can have two rows with the same title by mistake, or a handful of rows with a blank title that all fall back to the same generated default, or the whole file gets re-run after the first attempt died partway through. Each row is created independently, inside its own step of the batch workflow, and none of those steps look at the rest of the batch or the rest of the store to see if the handle it is about to assign already exists. The result is multiple real, valid prod_* rows that all resolve to the same handle string.
Why it happens
Since the Product Module's handle default runs per row rather than against the full product set, and the handle column carries no enforced unique constraint the way sku and ean do on variants, a few common import patterns reliably produce collisions:
- A CSV has two or more rows with the exact same title, so both rows generate the identical kebab-case handle by default.
- Several rows have a blank or missing title, so they all fall back to whatever the import tool substitutes, producing the same handle for each.
- An import job fails partway through and gets re-run from the start rather than resumed, creating a second full set of products alongside the first set that already succeeded.
- A migration script imports the same source catalog twice, once during a dry run rehearsal and once for real, without checking whether the products already exist.
This is a common source of confusion because the import itself reports success. There is no error, no failed row, nothing in the job log that flags a problem. Storefronts and any code that calls /store/products/{handle} assume the handle is unique, so the collision only shows up later as a wrong product page, a confusing 404 for one of the duplicates, or an SEO report showing two URLs competing for the same content. See the citations at the end for the exact issues and docs.
Finding a duplicate handle is easy. Deciding what to do about it is not, because one of those duplicate products might already have real inventory, a live sales channel link, or completed orders attached to it. So the safe pattern here is not "delete the newer one automatically." It is "list every collision with enough detail that a human can tell the real product from the import artifact," and only take a corrective action, a handle rename, behind an explicit dry run guard.
The fix, as a flow
We do not touch products, prices, or inventory by default. We add a reconciler that lists every product's id and handle, groups them client-side, and reports any group with more than one member, including status and variant SKUs so you can tell which duplicate is actually live. Only if you opt in, and only with dry run off, does it rename the newer duplicate's handle to a disambiguated slug.
Build it step by step
Get an admin token
Exchange an admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, change to false to write
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, change to false to write
List every product, paging through all of them
Ask for id, handle, title, status, and created_at on every product, and page with limit and offset until you have fetched the full count. A single page is not enough once a catalog has more than a couple hundred products.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_all_products(token):
products, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BACKEND_URL}/admin/products",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,handle,title,status,created_at", "limit": limit, "offset": offset},
timeout=30,
)
r.raise_for_status()
body = r.json()
products.extend(body["products"])
offset += limit
if offset >= body["count"]:
return products
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
return sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function listAllProducts() {
const products = [];
let offset = 0;
const limit = 200;
while (true) {
const body = await sdk.admin.product.list({
fields: "id,handle,title,status,created_at",
limit,
offset,
});
products.push(...body.products);
offset += limit;
if (offset >= body.count) return products;
}
}
Look closer at each conflicting group
Once you know which handles collide, fetch those specific products again with *variants.sku and *sales_channels.id expanded. That tells you which duplicate is actually published and live on a sales channel versus an orphaned import artifact nobody has touched.
def get_product_detail(token, product_id):
r = requests.get(
f"{BACKEND_URL}/admin/products/{product_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,handle,title,status,*variants.sku,*sales_channels.id"},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
async function getProductDetail(productId) {
const { product } = await sdk.admin.product.retrieve(productId, {
fields: "id,handle,title,status,*variants.sku,*sales_channels.id",
});
return product;
}
Group and sort, with one pure function
Keep the grouping logic in its own function that takes the plain list of products and returns only the handles with a collision. Sort each group's members by created_at ascending, so the first entry is the likely original and the rest are the likely import duplicates. This function does no I/O, so it is easy to test with synthetic data.
def find_duplicate_handles(products):
by_handle = {}
for p in products:
by_handle.setdefault(p.get("handle"), []).append(p)
groups = []
for handle, members in by_handle.items():
if len(members) > 1:
ordered = sorted(members, key=lambda p: p.get("created_at") or "")
groups.append({"handle": handle, "products": ordered})
return groups
export function findDuplicateHandles(products) {
const byHandle = new Map();
for (const p of products) {
const key = p.handle;
if (!byHandle.has(key)) byHandle.set(key, []);
byHandle.get(key).push(p);
}
const groups = [];
for (const [handle, members] of byHandle) {
if (members.length > 1) {
const ordered = [...members].sort(
(a, b) => (a.created_at || "").localeCompare(b.created_at || "")
);
groups.push({ handle, products: ordered });
}
}
return groups;
}
Rename, only when asked, and never by deleting
If you opt into an automated repair, the only safe write is renaming the newer duplicate's handle to a disambiguated slug, for example original-handle-2, with POST /admin/products/{id}. Never call delete, since a duplicate may already hold real inventory or orders. This write only runs when DRY_RUN is explicitly set to false, otherwise it only logs the intended call.
def rename_handle(token, product_id, new_handle):
r = requests.post(
f"{BACKEND_URL}/admin/products/{product_id}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"handle": new_handle},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
async function renameHandle(productId, newHandle) {
const { product } = await sdk.admin.product.update(productId, {
handle: newHandle,
});
return product;
}
Wire it together with a dry run guard
The run loop lists every product, finds the duplicate handle groups, and logs a full report: handle, product ids, titles, status, and variant SKUs for every member. On the first run, and every run by default, DRY_RUN stays on and nothing is written. Only with DRY_RUN=false does it rename the newer duplicates in each group to a disambiguated handle.
This is a diagnostic pass first. Always read the report before you consider a repair, since one of the duplicates may need its inventory merged into the other, or its old handle preserved as a redirect, rather than simply renamed. Only set DRY_RUN=false once you have decided that a rename is the right call for that group.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs a full report of every duplicate handle group it finds, respects the dry run flag, and only renames a handle when you explicitly turn writing on.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa products that share a duplicate handle after an import, safely.
Medusa v2 auto-generates a product handle from its title whenever a create
payload omits one, but that default is applied per row inside the create or
batch product workflow. It never checks the rest of the store for a
collision, and the handle column has no enforced unique database constraint.
A CSV import with duplicate or blank titles, or one that gets re-run after a
partial failure, can therefore leave several products sharing one handle.
This lists every product, groups them by handle with a pure function, and
reports every group that has more than one member, including status and
variant SKUs, so a human can tell the real product from the import artifact.
The only write this script can make is renaming the newer duplicate's handle
to a disambiguated slug, and it only does that when DRY_RUN is explicitly set
to false. It never deletes a product. Run once, or on a schedule. Safe to run
again and again, since a resolved handle group simply stops appearing.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_duplicate_handles")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTO_REPAIR = os.environ.get("AUTO_REPAIR", "false").lower() == "true"
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_all_products(token):
products, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BACKEND_URL}/admin/products",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,handle,title,status,created_at", "limit": limit, "offset": offset},
timeout=30,
)
r.raise_for_status()
body = r.json()
products.extend(body["products"])
offset += limit
if offset >= body["count"]:
return products
def get_product_detail(token, product_id):
r = requests.get(
f"{BACKEND_URL}/admin/products/{product_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,handle,title,status,*variants.sku,*sales_channels.id"},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
def find_duplicate_handles(products):
"""Pure function. No I/O.
products: [{"id": str, "handle": str, "title": str, "created_at": str}, ...]
Returns [{"handle": str, "products": [...]}, ...] for every handle shared
by more than one product, with each group's members sorted by created_at
ascending, oldest first.
"""
by_handle = {}
for p in products:
by_handle.setdefault(p.get("handle"), []).append(p)
groups = []
for handle, members in by_handle.items():
if len(members) > 1:
ordered = sorted(members, key=lambda p: p.get("created_at") or "")
groups.append({"handle": handle, "products": ordered})
return groups
def rename_handle(token, product_id, new_handle):
r = requests.post(
f"{BACKEND_URL}/admin/products/{product_id}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"handle": new_handle},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
def run():
token = get_admin_token()
products = list_all_products(token)
groups = find_duplicate_handles(products)
if not groups:
log.info("Done. No duplicate product handles found across %d product(s).", len(products))
return
log.info("Found %d duplicate handle group(s).", len(groups))
for group in groups:
handle = group["handle"]
log.info("Handle %r has %d products:", handle, len(group["products"]))
for p in group["products"]:
detail = get_product_detail(token, p["id"])
skus = [v.get("sku") for v in (detail.get("variants") or [])]
channels = [sc["id"] for sc in (detail.get("sales_channels") or [])]
log.info(
" id=%s title=%r status=%s created_at=%s skus=%s sales_channels=%s",
p["id"], p.get("title"), detail.get("status"), p.get("created_at"), skus, channels,
)
if not AUTO_REPAIR:
continue
oldest, *newer_duplicates = group["products"]
for i, dup in enumerate(newer_duplicates, start=2):
new_handle = f"{handle}-{i}"
log.info(
"%s product %s handle to %r",
"Would rename" if DRY_RUN else "Renaming", dup["id"], new_handle,
)
if not DRY_RUN:
rename_handle(token, dup["id"], new_handle)
log.info("Done. %d duplicate handle group(s) reported.", len(groups))
if __name__ == "__main__":
run()
/**
* Find Medusa products that share a duplicate handle after an import, safely.
*
* Medusa v2 auto-generates a product handle from its title whenever a create
* payload omits one, but that default is applied per row inside the create or
* batch product workflow. It never checks the rest of the store for a
* collision, and the handle column has no enforced unique database constraint.
* A CSV import with duplicate or blank titles, or one that gets re-run after a
* partial failure, can therefore leave several products sharing one handle.
*
* This lists every product, groups them by handle with a pure function, and
* reports every group that has more than one member, including status and
* variant SKUs, so a human can tell the real product from the import artifact.
* The only write this script can make is renaming the newer duplicate's handle
* to a disambiguated slug, and it only does that when DRY_RUN is explicitly set
* to false. It never deletes a product. Run once, or on a schedule.
*
* Guide: https://www.allanninal.dev/medusa/duplicate-product-handles-from-import/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const AUTO_REPAIR = (process.env.AUTO_REPAIR || "false").toLowerCase() === "true";
export function findDuplicateHandles(products) {
const byHandle = new Map();
for (const p of products) {
const key = p.handle;
if (!byHandle.has(key)) byHandle.set(key, []);
byHandle.get(key).push(p);
}
const groups = [];
for (const [handle, members] of byHandle) {
if (members.length > 1) {
const ordered = [...members].sort(
(a, b) => (a.created_at || "").localeCompare(b.created_at || "")
);
groups.push({ handle, products: ordered });
}
}
return groups;
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
return sdk;
}
async function listAllProducts(sdk) {
const products = [];
let offset = 0;
const limit = 200;
while (true) {
const body = await sdk.admin.product.list({
fields: "id,handle,title,status,created_at",
limit,
offset,
});
products.push(...body.products);
offset += limit;
if (offset >= body.count) return products;
}
}
async function getProductDetail(sdk, productId) {
const { product } = await sdk.admin.product.retrieve(productId, {
fields: "id,handle,title,status,*variants.sku,*sales_channels.id",
});
return product;
}
async function renameHandle(sdk, productId, newHandle) {
const { product } = await sdk.admin.product.update(productId, { handle: newHandle });
return product;
}
export async function run() {
const sdk = await getSdk();
const products = await listAllProducts(sdk);
const groups = findDuplicateHandles(products);
if (groups.length === 0) {
console.log(`Done. No duplicate product handles found across ${products.length} product(s).`);
return;
}
console.log(`Found ${groups.length} duplicate handle group(s).`);
for (const group of groups) {
console.log(`Handle "${group.handle}" has ${group.products.length} products:`);
for (const p of group.products) {
const detail = await getProductDetail(sdk, p.id);
const skus = (detail.variants || []).map((v) => v.sku);
const channels = (detail.sales_channels || []).map((sc) => sc.id);
console.log(
` id=${p.id} title="${p.title}" status=${detail.status} created_at=${p.created_at} skus=${JSON.stringify(skus)} sales_channels=${JSON.stringify(channels)}`
);
}
if (!AUTO_REPAIR) continue;
const [, ...newerDuplicates] = group.products;
for (let i = 0; i < newerDuplicates.length; i++) {
const dup = newerDuplicates[i];
const newHandle = `${group.handle}-${i + 2}`;
console.log(`${DRY_RUN ? "Would rename" : "Renaming"} product ${dup.id} handle to "${newHandle}"`);
if (!DRY_RUN) await renameHandle(sdk, dup.id, newHandle);
}
}
console.log(`Done. ${groups.length} duplicate handle group(s) reported.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The grouping rule is the part most worth testing, because it decides which products get reported as duplicates and, in each group, which member counts as the likely original. Because we kept find_duplicate_handles pure, the test needs no network and no Medusa backend. It just feeds in plain objects and checks the answer.
from find_duplicate_handles import find_duplicate_handles
def product(**over):
base = {"id": "prod_1", "handle": "a-shirt", "title": "A Shirt", "created_at": "2026-01-01T00:00:00Z"}
base.update(over)
return base
def test_no_duplicates_returns_empty_list():
products = [product(id="prod_1", handle="a-shirt"), product(id="prod_2", handle="b-shirt")]
assert find_duplicate_handles(products) == []
def test_two_products_sharing_a_handle_are_grouped():
products = [
product(id="prod_1", handle="a-shirt", created_at="2026-01-02T00:00:00Z"),
product(id="prod_2", handle="a-shirt", created_at="2026-01-01T00:00:00Z"),
]
groups = find_duplicate_handles(products)
assert len(groups) == 1
assert groups[0]["handle"] == "a-shirt"
def test_group_is_sorted_oldest_first():
products = [
product(id="prod_new", handle="a-shirt", created_at="2026-01-05T00:00:00Z"),
product(id="prod_old", handle="a-shirt", created_at="2026-01-01T00:00:00Z"),
]
groups = find_duplicate_handles(products)
ids_in_order = [p["id"] for p in groups[0]["products"]]
assert ids_in_order == ["prod_old", "prod_new"]
def test_three_way_collision_is_one_group_of_three():
products = [
product(id="prod_1", handle="a-shirt", created_at="2026-01-01T00:00:00Z"),
product(id="prod_2", handle="a-shirt", created_at="2026-01-02T00:00:00Z"),
product(id="prod_3", handle="a-shirt", created_at="2026-01-03T00:00:00Z"),
]
groups = find_duplicate_handles(products)
assert len(groups) == 1
assert len(groups[0]["products"]) == 3
def test_unrelated_products_do_not_appear_in_any_group():
products = [
product(id="prod_1", handle="a-shirt", created_at="2026-01-01T00:00:00Z"),
product(id="prod_2", handle="a-shirt", created_at="2026-01-02T00:00:00Z"),
product(id="prod_3", handle="unique-hat", created_at="2026-01-03T00:00:00Z"),
]
groups = find_duplicate_handles(products)
assert len(groups) == 1
all_ids = [p["id"] for p in groups[0]["products"]]
assert "prod_3" not in all_ids
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateHandles } from "./find-duplicate-handles.js";
const product = (over = {}) => ({
id: "prod_1",
handle: "a-shirt",
title: "A Shirt",
created_at: "2026-01-01T00:00:00Z",
...over,
});
test("no duplicates returns empty list", () => {
const products = [product({ id: "prod_1", handle: "a-shirt" }), product({ id: "prod_2", handle: "b-shirt" })];
assert.deepEqual(findDuplicateHandles(products), []);
});
test("two products sharing a handle are grouped", () => {
const products = [
product({ id: "prod_1", handle: "a-shirt", created_at: "2026-01-02T00:00:00Z" }),
product({ id: "prod_2", handle: "a-shirt", created_at: "2026-01-01T00:00:00Z" }),
];
const groups = findDuplicateHandles(products);
assert.equal(groups.length, 1);
assert.equal(groups[0].handle, "a-shirt");
});
test("group is sorted oldest first", () => {
const products = [
product({ id: "prod_new", handle: "a-shirt", created_at: "2026-01-05T00:00:00Z" }),
product({ id: "prod_old", handle: "a-shirt", created_at: "2026-01-01T00:00:00Z" }),
];
const groups = findDuplicateHandles(products);
const idsInOrder = groups[0].products.map((p) => p.id);
assert.deepEqual(idsInOrder, ["prod_old", "prod_new"]);
});
test("three way collision is one group of three", () => {
const products = [
product({ id: "prod_1", handle: "a-shirt", created_at: "2026-01-01T00:00:00Z" }),
product({ id: "prod_2", handle: "a-shirt", created_at: "2026-01-02T00:00:00Z" }),
product({ id: "prod_3", handle: "a-shirt", created_at: "2026-01-03T00:00:00Z" }),
];
const groups = findDuplicateHandles(products);
assert.equal(groups.length, 1);
assert.equal(groups[0].products.length, 3);
});
test("unrelated products do not appear in any group", () => {
const products = [
product({ id: "prod_1", handle: "a-shirt", created_at: "2026-01-01T00:00:00Z" }),
product({ id: "prod_2", handle: "a-shirt", created_at: "2026-01-02T00:00:00Z" }),
product({ id: "prod_3", handle: "unique-hat", created_at: "2026-01-03T00:00:00Z" }),
];
const groups = findDuplicateHandles(products);
assert.equal(groups.length, 1);
const allIds = groups[0].products.map((p) => p.id);
assert.equal(allIds.includes("prod_3"), false);
});
Case studies
The migration that ran twice by accident
A team migrated a few thousand SKUs from a legacy platform with a CSV importer. The first run stalled on a network blip about two thirds of the way through, and rather than resuming from where it stopped, someone kicked off the whole file again from row one. The second run finished clean, but roughly six hundred products now had a twin with an identical handle.
Running the reconciler produced a clear report: six hundred handle groups, each with two products, one created in the first run and one in the second. Because the older product in each pair already had inventory levels set and the newer one did not, the team chose to keep the older product's handle and merge inventory into it by hand, rather than trust an automatic rename.
The spreadsheet with a few empty title cells
A merchandiser exported a product sheet from a spreadsheet tool, and a handful of rows near the bottom had their title column accidentally cleared during a copy paste. The import tool substituted a generic default title for each blank row, and every one of those rows generated the same fallback handle.
The reconciler flagged one handle group with four members, all created within the same minute, none of them linked to a sales channel yet. Since none of the four were live, the team deleted the bad rows from the source sheet, re-imported just those products with correct titles, and left the reconciler's rename feature off entirely for this case.
After this runs, every duplicate handle is a known, reported fact instead of a silent routing bug waiting to be found by a confused customer. Nothing gets deleted or merged automatically, since that decision belongs to a human who can see which duplicate is actually live. When you are ready, the rename step gives you a safe, reversible way to disambiguate the newer copies without ever risking a product that turns out to hold real orders.
FAQ
Why do I have two Medusa products with the same handle?
Medusa v2 auto-generates a handle from the title whenever a create payload omits one, but that generation is a per-row default applied inside the create or batch product workflow. It never checks the rest of the store for a collision, and the handle column has no enforced unique database constraint. A CSV import with duplicate or blank titles across rows, or a re-run after a partial failure, can therefore create several products that all resolve to the same handle.
Is it safe to automatically fix duplicate Medusa product handles?
Not automatically. Deciding which duplicate is the real product and which is the import artifact needs business judgement, since one of them may already hold real inventory, prices, or orders. The safe approach is to report every duplicate handle group with product ids, status, and variant SKUs first, and only rename the newer duplicate's handle behind a dry run guard once a human has confirmed which record should keep the original handle.
How do I find duplicate product handles in Medusa?
Call GET /admin/products with fields=id,handle,title,status,created_at and page through every product with limit and offset. Group the results by handle in your own code. Any handle that maps to more than one product id is a collision, and you can sort each group by created_at to see which record came first.
Related field notes
Citations
On the problem:
- GitHub Issue: Product handle is not required during creation but causes issues if missing (medusajs/medusa #12972). github.com/medusajs/medusa/issues/12972
- GitHub Issue: Product import bugs (medusajs/medusa #4131). github.com/medusajs/medusa/issues/4131
- Medusa Admin User Guide: Import Products in Medusa Admin. docs.medusajs.com/user-guide/products/import
On the solution:
- Medusa Core Workflows Reference: batchProductsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/batchProductsWorkflow
- Medusa V2 Admin API Reference: products. docs.medusajs.com/api/admin#products
- Medusa Documentation: Product Module. docs.medusajs.com/resources/commerce-modules/product
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, or workflows 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 untangle your catalog?
If this saved you from a confusing wrong-product page or a messy SEO report, 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