Diagnostic Promotions and campaigns
Duplicate promotion codes
A customer types SAVE10 at checkout and gets a discount, but it is not the discount marketing set up for this campaign. It is an old one nobody deactivated, or a copy that slipped in from a seed script months ago. Both promotions are active, both match the code the customer typed, and Medusa never complained because, to Postgres, the two codes were never quite the same string. Here is why the database's own uniqueness check can miss a live duplicate, and a script that finds every colliding pair without touching a single one of them for you.
Medusa v2 enforces promotion code uniqueness with exactly one thing: a partial unique database index, IDX_unique_promotion_code on code, scoped to rows where deleted_at IS NULL. There is no application-level check in createPromotionsWorkflow or createPromotionsStep before the insert, the workflow just lets Postgres reject a clash. That index is case-sensitive and only looks at non-deleted rows, so SAVE10 and save10, or SAVE10 and SAVE10 with a trailing space, are different values to the database and can both exist as separate, live promotions. Run a small Python or Node.js script that pages through every promotion, normalizes each code with code.trim().toUpperCase(), and groups them with one pure function. Any group with more than one promo_ id is a duplicate. The script only reports, it never merges, deletes, or auto-picks a winner, since that is a business decision. A human reviews the report and, only when told to, the script deactivates the named loser by setting status to inactive.
The problem in plain words
A promotion code feels like it should be unique the same way a username is unique. Type it once, taken forever. Medusa gets close to that with a database constraint, but the constraint only looks at the literal bytes in the code column, not at what a person actually typed or meant.
SAVE10 and save10 are the same code to every customer who reads it on a banner or a receipt. To Postgres they are two completely different strings, so the unique index has nothing to reject. The same is true for a trailing space that snuck in from a copy and paste in a spreadsheet import. Two promotions end up alive at once, both matching what the customer types, and whichever one the lookup query returns first is what gets applied. Nobody sees an error. The discount is just quietly wrong.
Why it happens
This is not corrupted data, it is a gap between what the constraint checks and what a human means by "the same code." A few concrete ways stores end up seeing it in production:
- A promotion is created in the Admin UI with
SAVE10, and later a seed script, migration, or bulk import createssave10for a similar campaign, unaware the first one already exists. - Multiple environments, staging, a client's spreadsheet, an old backup, get merged into one database later, each carrying its own version of what was meant to be one code.
- A request to create a promotion races a soft-delete of an older one with the same code. Because the unique index only applies
WHERE deleted_at IS NULL, the brief window where the old row is not yet deleted, or is deleted but a duplicate slips in right after, can leave two live rows. - A bulk-import path bypasses the normal, validated Admin API create route entirely, calling the module or service directly, or restoring from a backup, so the same code-collision protections that exist around the standard workflow never run.
- Someone copies a code from a spreadsheet or email and it carries a trailing or leading space, which reads identically to a customer but is a distinct value in the column.
Medusa's own concepts documentation describes the promotion code as the thing a customer enters, but the enforcement underneath is a single database index, not a normalized, case-insensitive check. See the citations at the end for the exact docs and the open issue tracking the soft-delete gap.
Finding a duplicate is not the same as fixing it. Two promotions that share a code can have entirely different discount rules, different campaigns, or different usage counters already racked up. Picking which one survives is a business decision, not something a script should guess at. So the right shape for this fix is a pure grouping function that only reports, plus a separate, explicit, human-triggered step to deactivate the one a person names, never an automatic merge and never a DELETE.
The fix, as a flow
We page through every promotion, normalize each code the way a person would read it, trimmed and upper-cased, and group them. Any group with more than one promotion id is flagged, including the case and whitespace variants the database index itself does not catch. For context, we also look up the campaign behind each flagged promotion. Nothing gets merged or deleted automatically. Only when a human names a specific loser, and only outside dry run, does the script set that one promotion's status to inactive.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read promotions and campaigns, and to update a promotion's status. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
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 deactivate a named promotion
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 deactivate a named promotion
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
async function login() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
List every promotion, with campaign context
Ask for every promotion's id, code, status, and the fields that will matter when you review a duplicate: is_automatic, campaign_id, and the application_method. Page through with limit and offset, since a store can have hundreds of promotions across old and current campaigns.
PROMOTION_FIELDS = (
"id,code,status,is_automatic,campaign_id,"
"application_method.value,application_method.type,created_at"
)
def list_promotions(token):
headers = {"Authorization": f"Bearer {token}"}
offset = 0
while True:
r = requests.get(
f"{BASE_URL}/admin/promotions",
params={"fields": PROMOTION_FIELDS, "limit": 200, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for promotion in body["promotions"]:
yield promotion
offset += 200
if offset >= body["count"]:
return
const PROMOTION_FIELDS =
"id,code,status,is_automatic,campaign_id," +
"application_method.value,application_method.type,created_at";
async function* listPromotions(sdk) {
let offset = 0;
while (true) {
const body = await sdk.admin.promotion.list({
fields: PROMOTION_FIELDS,
limit: 200,
offset,
});
for (const promotion of body.promotions) yield promotion;
offset += 200;
if (offset >= body.count) return;
}
}
Group by the normalized code, with one pure function
This is the check Medusa's own unique index does not run. Trim whitespace and upper-case every code, then group promotions by that normalized key. Any group that ends up with more than one distinct promo_ id is a collision, whether the raw codes were an exact match or only a case or whitespace variant of each other. The function takes plain arrays in and returns a plain map out, no database, no side effects, so it is trivial to unit test with fixtures.
def find_duplicate_promotion_codes(promotions):
groups = {}
for promotion in promotions:
key = promotion["code"].strip().upper()
groups.setdefault(key, []).append(promotion)
return {key: entries for key, entries in groups.items() if len(entries) > 1}
export function findDuplicatePromotionCodes(promotions) {
const groups = new Map();
for (const promotion of promotions) {
const key = promotion.code.trim().toUpperCase();
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(promotion);
}
const duplicates = new Map();
for (const [key, entries] of groups) {
if (entries.length > 1) duplicates.set(key, entries);
}
return duplicates;
}
Pull campaign context for each duplicate group
A duplicate group is more useful to a human when it comes with the campaign each promotion belongs to. Fetch the campaign's name and dates so a reviewer can see which promotion looks live and in-window versus stale, without needing to look each one up by hand.
CAMPAIGN_FIELDS = "id,name,starts_at,ends_at"
def get_campaign(token, campaign_id):
if not campaign_id:
return None
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/campaigns/{campaign_id}",
params={"fields": CAMPAIGN_FIELDS},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["campaign"]
const CAMPAIGN_FIELDS = "id,name,starts_at,ends_at";
async function getCampaign(sdk, campaignId) {
if (!campaignId) return null;
const body = await sdk.admin.campaign.retrieve(campaignId, { fields: CAMPAIGN_FIELDS });
return body.campaign;
}
Report every group, then wait for a human
Log each duplicate group with its normalized code, every raw code variant seen, and each promotion's id, status, and campaign. That is the entire automated part. The only write this script can perform is deactivating one specific promotion, and only when an operator sets DEACTIVATE_PROMOTION_ID to the id they picked after reviewing the report, and only outside dry run. Nothing is ever deleted, since delete only soft-deletes a promotion and leaves its code eligible for silent reuse later.
This script never merges, deletes, or auto-picks a winner between two colliding promotions. It only reports. Deactivating a promotion is a deliberate, one-at-a-time action a human triggers by naming the exact promo_ id, always with DRY_RUN=true first to confirm what would happen.
The full code
Here is the complete script in one file for each language. It authenticates, lists every promotion, groups them by normalized code, pulls campaign context for anything flagged, and either logs or, when a specific promotion id is named outside dry run, deactivates that one promotion.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa promotion codes that collide once normalized.
Medusa v2 only enforces code uniqueness with a single partial unique index,
IDX_unique_promotion_code on code WHERE deleted_at IS NULL. There is no
application-level uniqueness check before the insert in
createPromotionsWorkflow, so the workflow just relies on Postgres to reject a
clash. Because the index is case-sensitive and only looks at non-deleted
rows, two promotions created through different paths, the Admin UI, a seed
or import script, or a restored backup, can end up with codes that are
byte-different but functionally the same, for example SAVE10 vs save10, or
SAVE10 with a trailing space. This script never merges or deletes anything.
It reports every duplicate group so a human can decide which promotion stays
active, and only outside dry run does it deactivate the promotion an
operator names by setting status to inactive. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_duplicate_promotion_codes")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Optional: a promo_ id to deactivate once a human has picked the loser.
DEACTIVATE_PROMOTION_ID = os.environ.get("DEACTIVATE_PROMOTION_ID", "")
PROMOTION_FIELDS = (
"id,code,status,is_automatic,campaign_id,"
"application_method.value,application_method.type,created_at"
)
CAMPAIGN_FIELDS = "id,name,starts_at,ends_at"
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def find_duplicate_promotion_codes(promotions):
"""Pure: groups promotions by a normalized code and returns only groups
with more than one entry, i.e. two or more distinct promo_ ids that
resolve to the same effective code once whitespace and case are ignored.
No I/O, no mutation of the input list.
"""
groups = {}
for promotion in promotions:
key = promotion["code"].strip().upper()
groups.setdefault(key, []).append(promotion)
return {key: entries for key, entries in groups.items() if len(entries) > 1}
def list_promotions(token):
headers = {"Authorization": f"Bearer {token}"}
offset = 0
while True:
r = requests.get(
f"{BASE_URL}/admin/promotions",
params={"fields": PROMOTION_FIELDS, "limit": 200, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for promotion in body["promotions"]:
yield promotion
offset += 200
if offset >= body["count"]:
return
def get_campaign(token, campaign_id):
if not campaign_id:
return None
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/campaigns/{campaign_id}",
params={"fields": CAMPAIGN_FIELDS},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["campaign"]
def deactivate_promotion(token, promotion_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/promotions/{promotion_id}",
json={"status": "inactive"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotion"]
def build_report(normalized_code, entries, campaigns_by_id):
"""Pure: shapes one duplicate group into a human-facing report row."""
raw_codes = sorted({entry["code"] for entry in entries})
return {
"normalized_code": normalized_code,
"is_case_or_whitespace_variant": len(raw_codes) > 1,
"raw_codes": raw_codes,
"promotions": [
{
"id": entry["id"],
"code": entry["code"],
"status": entry["status"],
"campaign_id": entry.get("campaign_id"),
"application_method": entry.get("application_method"),
"campaign_name": (campaigns_by_id.get(entry.get("campaign_id")) or {}).get("name"),
}
for entry in entries
],
}
def run():
token = get_token()
promotions = list(list_promotions(token))
duplicates = find_duplicate_promotion_codes(promotions)
campaigns_by_id = {}
for entries in duplicates.values():
for entry in entries:
campaign_id = entry.get("campaign_id")
if campaign_id and campaign_id not in campaigns_by_id:
campaigns_by_id[campaign_id] = get_campaign(token, campaign_id)
reports = []
for normalized_code, entries in duplicates.items():
report = build_report(normalized_code, entries, campaigns_by_id)
reports.append(report)
log.warning(
"Duplicate code %s: %d promotion(s) %s. Raw codes seen: %s",
report["normalized_code"],
len(report["promotions"]),
[p["id"] for p in report["promotions"]],
report["raw_codes"],
)
if DEACTIVATE_PROMOTION_ID:
log.info(
"Promotion %s. %s",
DEACTIVATE_PROMOTION_ID,
"would deactivate" if DRY_RUN else "deactivating",
)
if not DRY_RUN:
deactivate_promotion(token, DEACTIVATE_PROMOTION_ID)
log.info("Done. %d duplicate code group(s) found.", len(reports))
return reports
if __name__ == "__main__":
run()
/**
* Find Medusa promotion codes that collide once normalized.
*
* Medusa v2 only enforces code uniqueness with a single partial unique index,
* IDX_unique_promotion_code on code WHERE deleted_at IS NULL. There is no
* application-level uniqueness check before the insert in
* createPromotionsWorkflow, so the workflow just relies on Postgres to
* reject a clash. Because the index is case-sensitive and only looks at
* non-deleted rows, two promotions created through different paths, the
* Admin UI, a seed or import script, or a restored backup, can end up with
* codes that are byte-different but functionally the same, for example
* SAVE10 vs save10, or SAVE10 with a trailing space. This script never
* merges or deletes anything. It reports every duplicate group so a human
* can decide which promotion stays active, and only outside dry run does it
* deactivate the promotion an operator names by setting status to inactive.
* Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Optional: a promo_ id to deactivate once a human has picked the loser.
const DEACTIVATE_PROMOTION_ID = process.env.DEACTIVATE_PROMOTION_ID || "";
const PROMOTION_FIELDS =
"id,code,status,is_automatic,campaign_id," +
"application_method.value,application_method.type,created_at";
const CAMPAIGN_FIELDS = "id,name,starts_at,ends_at";
/**
* Pure: groups promotions by a normalized code and returns only groups with
* more than one entry, i.e. two or more distinct promo_ ids that resolve to
* the same effective code once whitespace and case are ignored. No I/O, no
* mutation of the input array.
*/
export function findDuplicatePromotionCodes(promotions) {
const groups = new Map();
for (const promotion of promotions) {
const key = promotion.code.trim().toUpperCase();
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(promotion);
}
const duplicates = new Map();
for (const [key, entries] of groups) {
if (entries.length > 1) duplicates.set(key, entries);
}
return duplicates;
}
/** Pure: shapes one duplicate group into a human-facing report row. */
export function buildReport(normalizedCode, entries, campaignsById) {
const rawCodes = [...new Set(entries.map((entry) => entry.code))].sort();
return {
normalizedCode,
isCaseOrWhitespaceVariant: rawCodes.length > 1,
rawCodes,
promotions: entries.map((entry) => ({
id: entry.id,
code: entry.code,
status: entry.status,
campaignId: entry.campaign_id || null,
applicationMethod: entry.application_method,
campaignName: (campaignsById.get(entry.campaign_id) || {}).name,
})),
};
}
async function login() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function* listPromotions(sdk) {
let offset = 0;
while (true) {
const body = await sdk.admin.promotion.list({
fields: PROMOTION_FIELDS,
limit: 200,
offset,
});
for (const promotion of body.promotions) yield promotion;
offset += 200;
if (offset >= body.count) return;
}
}
async function getCampaign(sdk, campaignId) {
if (!campaignId) return null;
const body = await sdk.admin.campaign.retrieve(campaignId, { fields: CAMPAIGN_FIELDS });
return body.campaign;
}
async function deactivatePromotion(sdk, promotionId) {
const body = await sdk.admin.promotion.update(promotionId, { status: "inactive" });
return body.promotion;
}
export async function run() {
const sdk = await login();
const promotions = [];
for await (const promotion of listPromotions(sdk)) promotions.push(promotion);
const duplicates = findDuplicatePromotionCodes(promotions);
const campaignsById = new Map();
for (const entries of duplicates.values()) {
for (const entry of entries) {
const campaignId = entry.campaign_id;
if (campaignId && !campaignsById.has(campaignId)) {
campaignsById.set(campaignId, await getCampaign(sdk, campaignId));
}
}
}
const reports = [];
for (const [normalizedCode, entries] of duplicates) {
const report = buildReport(normalizedCode, entries, campaignsById);
reports.push(report);
console.warn(
`Duplicate code ${report.normalizedCode}: ${report.promotions.length} promotion(s) ${JSON.stringify(
report.promotions.map((p) => p.id)
)}. Raw codes seen: ${JSON.stringify(report.rawCodes)}`
);
}
if (DEACTIVATE_PROMOTION_ID) {
console.log(
`Promotion ${DEACTIVATE_PROMOTION_ID}. ${DRY_RUN ? "would deactivate" : "deactivating"}`
);
if (!DRY_RUN) await deactivatePromotion(sdk, DEACTIVATE_PROMOTION_ID);
}
console.log(`Done. ${reports.length} duplicate code group(s) found.`);
return reports;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => {
console.error(err);
process.exit(1);
});
}
Add a test
The grouping function is the part most worth testing, since it decides what counts as a duplicate at all. Because find_duplicate_promotion_codes is pure, the tests feed in plain arrays of promotion fixtures, no Medusa backend required, and cover exact duplicates, case variants, and whitespace variants.
from find_duplicate_promotion_codes import find_duplicate_promotion_codes
def promo(id, code, status="active", campaign_id=None):
return {"id": id, "code": code, "status": status, "campaign_id": campaign_id}
def test_no_duplicates_returns_empty_map():
promotions = [promo("promo_1", "SAVE10"), promo("promo_2", "WELCOME5")]
assert find_duplicate_promotion_codes(promotions) == {}
def test_exact_duplicate_codes_are_grouped():
promotions = [
promo("promo_1", "SAVE10", campaign_id="camp_1"),
promo("promo_2", "SAVE10", campaign_id="camp_2"),
]
result = find_duplicate_promotion_codes(promotions)
assert list(result.keys()) == ["SAVE10"]
assert {p["id"] for p in result["SAVE10"]} == {"promo_1", "promo_2"}
def test_case_variant_duplicates_are_grouped():
promotions = [
promo("promo_1", "SAVE10"),
promo("promo_2", "save10"),
]
result = find_duplicate_promotion_codes(promotions)
assert list(result.keys()) == ["SAVE10"]
assert len(result["SAVE10"]) == 2
def test_whitespace_variant_duplicates_are_grouped():
promotions = [
promo("promo_1", "SAVE10"),
promo("promo_2", "SAVE10 "),
promo("promo_3", " SAVE10"),
]
result = find_duplicate_promotion_codes(promotions)
assert len(result) == 1
assert len(result["SAVE10"]) == 3
def test_unrelated_codes_never_collide():
promotions = [promo("promo_1", "SAVE10"), promo("promo_2", "SAVE20")]
assert find_duplicate_promotion_codes(promotions) == {}
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicatePromotionCodes } from "./find-duplicate-promotion-codes.js";
const promo = (id, code, over = {}) => ({ id, code, status: "active", campaign_id: null, ...over });
test("no duplicates returns an empty map", () => {
const promotions = [promo("promo_1", "SAVE10"), promo("promo_2", "WELCOME5")];
assert.equal(findDuplicatePromotionCodes(promotions).size, 0);
});
test("exact duplicate codes are grouped", () => {
const promotions = [
promo("promo_1", "SAVE10", { campaign_id: "camp_1" }),
promo("promo_2", "SAVE10", { campaign_id: "camp_2" }),
];
const result = findDuplicatePromotionCodes(promotions);
assert.deepEqual([...result.keys()], ["SAVE10"]);
});
test("case variant duplicates are grouped", () => {
const promotions = [promo("promo_1", "SAVE10"), promo("promo_2", "save10")];
const result = findDuplicatePromotionCodes(promotions);
assert.deepEqual([...result.keys()], ["SAVE10"]);
assert.equal(result.get("SAVE10").length, 2);
});
test("whitespace variant duplicates are grouped", () => {
const promotions = [
promo("promo_1", "SAVE10"),
promo("promo_2", "SAVE10 "),
promo("promo_3", " SAVE10"),
];
const result = findDuplicatePromotionCodes(promotions);
assert.equal(result.size, 1);
assert.equal(result.get("SAVE10").length, 3);
});
test("unrelated codes never collide", () => {
const promotions = [promo("promo_1", "SAVE10"), promo("promo_2", "SAVE20")];
assert.equal(findDuplicatePromotionCodes(promotions).size, 0);
});
Case studies
The import that quietly cloned a live code
A store migrated its promotions from a spreadsheet using a bulk import that called the promotion module directly instead of the normal validated create route. One row carried save10, lower case, from a column that had been auto-formatted somewhere along the way. Medusa's unique index never blinked, because SAVE10 was already live from a hand-created campaign months earlier, and the two strings were not equal as bytes.
Support started getting tickets that the wrong discount amount was applying to a currently running campaign. Running the audit script surfaced the group immediately, with both promotion ids, statuses, and campaigns listed side by side. Marketing reviewed it in minutes, confirmed the seeded one was the stale leftover, and named its id to deactivate. The live campaign's code has applied correctly ever since.
The copy and paste that added an invisible character
An operator copied a promotion code out of an email to recreate it after a campaign was extended, and the source text carried a trailing space nobody could see in the admin form. The database happily accepted WELCOME5 as a brand new, distinct code sitting right alongside the original WELCOME5.
Nothing looked wrong in the Admin UI, since both rendered identically. The audit script's normalization step, trim then upper-case, caught it on the very first run and flagged the two ids as a whitespace variant duplicate, which the raw byte comparison in Postgres could never have seen. The team deactivated the accidental duplicate the same day, before it confused a single customer.
Run this on a schedule that matches how often your team creates promotions by hand, imports them in bulk, or merges data between environments, weekly is enough for most stores. Every duplicate group surfaces with full context before a customer ever notices the wrong discount applying. Nothing is ever auto-merged or auto-deleted, so the business decision about which promotion survives always stays with a human, and the audit trail of what changed and why stays clean.
FAQ
Why does Medusa let two promotions share the same code?
Medusa v2 enforces code uniqueness with a single partial unique database index, IDX_unique_promotion_code on code, scoped to rows where deleted_at is null. There is no application-level uniqueness check in createPromotionsWorkflow before the insert, so Postgres is the only thing rejecting a clash. Because the index is case-sensitive, SAVE10 and save10 are different values to the database, so both can exist as separate, active promotions at once.
Why did the customer's code apply the wrong discount?
When two active promotions both normalize to the same code, the storefront and cart logic that looks up a promotion by code returns whichever record the query happens to return first. Nothing in Medusa detects the collision at read time, so the customer can silently get the wrong discount rule, the wrong campaign, or a promotion that was meant to be retired.
Is it safe to auto-delete or auto-merge a duplicate promotion code?
No. Two promotions sharing a code can have different discount rules, campaigns, or usage counters, so picking a winner is a business decision, not something a script should guess at. It is also not safe to call DELETE on the loser, because Medusa's promotion delete is a soft delete, and a known gap means the code can become eligible for silent reuse later. The safe repair is to report both ids for a human to review, then explicitly set the loser's status to inactive through the update route.
Related field notes
Citations
On the problem:
- Medusa Documentation: Promotion concepts, including the code field and how promotions are looked up. docs.medusajs.com/resources/commerce-modules/promotion/concepts
- medusajs/medusa GitHub issue #11606: promotions unique code soft delete error, tracking the known gap in the partial unique index. github.com/medusajs/medusa/issues/11606
- Medusa Documentation: Campaign concepts, for the campaign context attached to a duplicate promotion. docs.medusajs.com/resources/commerce-modules/promotion/campaign
On the solution:
- Medusa v2 Admin API reference, for the promotions and campaigns routes used here. docs.medusajs.com/api/admin
- Medusa Documentation: Promotion actions, including how a promotion's status is updated. docs.medusajs.com/resources/commerce-modules/promotion/actions
- Medusa Admin User Guide: managing promotions, including activating and deactivating one. docs.medusajs.com/user-guide/promotions/manage
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, 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 catch a duplicate code?
If this saved you a confused customer or a wrong discount, 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