Skip to content

Diagnostic Customer & Auth

Guest checkout creates a duplicate customer on registration

A shopper checks out as a guest, gets their order, and later comes back to make an account with the same email so they can track things properly. Instead of picking up where the guest checkout left off, Medusa quietly creates a second customer. Their old order is still there in the database, but it is tied to a customer row the new account cannot see. To the shopper it looks like their order history vanished. Here is why Medusa v2 leaves these rows split apart and a small script that finds every pair so a human can decide what to do about them.

Python and Node.js Medusa Admin API Safe by default (dry run)
A support agent with headphones
Photo by Vagaro on Unsplash
The short answer

Medusa v2 stores guest and registered customers as separate Customer rows keyed by email, and it does not deduplicate across account states. A guest checkout creates a row with has_account: false. When that same email later registers through POST /auth/customer/emailpass/register, the validateCustomerAccountCreation step inside createCustomerAccountWorkflow only blocks the registration if a row for that email already has has_account: true. It never looks up and reuses the existing guest row, so the workflow creates a brand new Customer row with has_account: true and links it to a fresh AuthIdentity. The store ends up with two rows sharing one email, and the guest's prior orders stay foreign-keyed to the now-orphaned guest cus_ id, invisible to the account the shopper just registered. Run a read-only script that pages through customers, groups them by normalized email, and flags every guest-plus-registered pair for a human to review, with orphaned order counts attached. Full code and tests are below.

The problem in plain words

When someone checks out as a guest, Medusa still needs somewhere to put their name, email, and shipping details, so it creates a Customer row for them. That row is marked has_account: false because nobody set a password or signed in. It is a real row in the database, not a throwaway, and the order the guest just placed is linked to its cus_ id.

Later, the same person decides they want an account. They go to register with the exact email they used as a guest. Medusa's registration workflow checks whether that email is already taken, but the check it runs only cares about accounts that already have a password, rows where has_account is already true. A guest row does not count as taken by that check, so registration sails through and creates a second, brand new Customer row, this one with has_account: true, linked to a new AuthIdentity. The guest row is left exactly where it was, still holding the old order, and now orphaned because nothing points the new account back to it.

Guest checkout has_account: false Order attached customer_id = guest cus_ weeks later Register same email validateCustomerAccountCreation checks has_account: true only guest row never reused Second Customer row has_account: true, new cus_ fresh AuthIdentity Old order invisible still on the orphaned guest id not on the new registered id
Both rows share one email, but nothing tells the registration workflow the guest row already exists, so the shopper ends up with two customers and an order history split across them.

Why it happens

The Customer table has no built-in constraint that treats one email as one shopper across account states. A few things line up to produce this:

This is documented Medusa behavior, not a one-off glitch. It shows up across GitHub issues #11147, #9999, and #11827, in each case as the same underlying gap: nothing reconciles a guest row with the account that registers over the same email. See the citations at the end for the exact threads and docs.

The key insight

Silently auto-merging these two rows is not safe. Repointing an order's customer_id, or relinking an AuthIdentity, touches order history and payment records that need to stay consistent, and Medusa v2 does not expose a documented admin route for reassigning an order to a different customer. So the correct action here is not a blind write. It is a read-only detection pass that reports every duplicate pair, with the orphaned order count attached, so a person can decide the right way to reconcile each one, and separately, a preventive fix to the registration flow itself so new duplicates stop forming.

The fix, as a flow

We do not touch any customer or order records. The script authenticates against the Admin API, pages through every customer, groups them by normalized email, and runs a pure decision function that flags the exact guest-plus-registered pattern. For every flagged pair it pulls the guest customer's orders so you can see how many are hidden. Everything is a report by default; nothing is written unless an operator turns off dry run and merges a specific pair by hand.

List customers id, email, has_account Group by email normalized, trimmed Pure decision fn findDuplicateCustomerGroups Guest plus registered? yes no, single row Not flagged left alone Count orphaned orders, report DRY_RUN by default
Detection is entirely read-only. Only a flagged pair with its orphaned order count reaches the report, and nothing is merged until a human confirms it.

Build it step by step

1

Authenticate against the Admin API

Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.

setup (shell)
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 only to write the report to a file
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

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 only to write the report to a file
2

Page through every customer

Ask for id, email, has_account, and created_at on every customer, paginated with limit and offset. This detection step never writes anything, it only reads.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def list_all_customers(token):
    customers = []
    offset = 0
    limit = 200
    fields = "id,email,has_account,created_at"
    while True:
        data = admin_get(token, "/admin/customers", {
            "fields": fields,
            "limit": limit,
            "offset": offset,
        })
        customers.extend(data["customers"])
        offset += limit
        if offset >= data["count"]:
            return customers
step2.js
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function listAllCustomers(token) {
  const customers = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/customers", {
      fields: "id,email,has_account,created_at",
      limit,
      offset,
    });
    customers.push(...data.customers);
    offset += limit;
    if (offset >= data.count) return customers;
  }
}
3

Decide, with one pure function

Keep the grouping and the decision in a function that takes only the list of customer rows, never touches the network, and returns a plain result. Group by normalized email, trimmed and lowercased so casing or stray whitespace does not hide a match, and flag a group only when it has exactly one has_account: false row alongside at least one has_account: true row. That is the specific guest-never-merged pattern. Two registered rows sharing an email is a different problem and is not flagged as this pattern.

decide.py
def find_duplicate_customer_groups(customers):
    """Pure decision function. No I/O.

    customers: [{"id": str, "email": str, "has_account": bool}, ...]

    Returns [{"email", "guestId", "registeredId", "isDuplicate"}, ...] with one
    entry per distinct normalized email, where isDuplicate is True only for the
    exact guest-plus-registered pattern: exactly one has_account False row and
    at least one has_account True row sharing that email.
    """
    groups = {}
    for customer in customers:
        email = (customer.get("email") or "").strip().lower()
        groups.setdefault(email, []).append(customer)

    results = []
    for email, rows in groups.items():
        guest_rows = [c for c in rows if c.get("has_account") is False]
        registered_rows = [c for c in rows if c.get("has_account") is True]
        is_duplicate = len(guest_rows) == 1 and len(registered_rows) >= 1
        results.append({
            "email": email,
            "guestId": guest_rows[0]["id"] if guest_rows else None,
            "registeredId": registered_rows[0]["id"] if registered_rows else None,
            "isDuplicate": is_duplicate,
        })
    return results
decide.js
/**
 * Pure decision function. No I/O.
 *
 * @param {Array<{id: string, email: string, has_account: boolean}>} customers
 * @returns {Array<{email: string, guestId: string | null, registeredId: string | null, isDuplicate: boolean}>}
 */
export function findDuplicateCustomerGroups(customers) {
  const groups = new Map();
  for (const customer of customers) {
    const email = (customer.email || "").trim().toLowerCase();
    if (!groups.has(email)) groups.set(email, []);
    groups.get(email).push(customer);
  }

  const results = [];
  for (const [email, rows] of groups) {
    const guestRows = rows.filter((c) => c.has_account === false);
    const registeredRows = rows.filter((c) => c.has_account === true);
    const isDuplicate = guestRows.length === 1 && registeredRows.length >= 1;
    results.push({
      email,
      guestId: guestRows[0]?.id ?? null,
      registeredId: registeredRows[0]?.id ?? null,
      isDuplicate,
    });
  }
  return results;
}
4

Count the orphaned orders behind the guest row

For every flagged pair, pull the orders still linked to the guest cus_ id. That count is what makes the bug customer-visible: it is exactly how many orders vanish from the shopper's view after they register, since they stay attached to a customer row the new account cannot see.

step4.py
def orphaned_order_count(token, guest_customer_id):
    data = admin_get(token, "/admin/orders", {
        "customer_id": guest_customer_id,
        "fields": "id,customer_id,email,display_id",
        "limit": 1,
    })
    return data["count"]
step4.js
async function orphanedOrderCount(token, guestCustomerId) {
  const data = await adminGet(token, "/admin/orders", {
    customer_id: guestCustomerId,
    fields: "id,customer_id,email,display_id",
    limit: 1,
  });
  return data.count;
}
5

Wire it together as a dry run report

The loop ties every piece together. It never writes to a customer or order record. With DRY_RUN on, which is the default, it only logs each flagged pair as {email, guest_customer_id, registered_customer_id, orphaned_order_count}. Merging a specific pair after a human confirms it is a deliberate, separate action, not something this script does automatically, because Medusa v2 has no documented admin route for reassigning an order's customer. Run this on a schedule so new duplicates surface quickly, for example once a day.

Run it safe

This script only ever reads. It never calls a write endpoint, never touches an order, and never merges a customer row. DRY_RUN exists so the report step is explicit about that boundary, and stays on by default. Any merge of a confirmed pair is a manual, human decision, handled outside this script, since retroactively repointing order.customer_id has no first-class Admin API route.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through every customer, flags the guest-plus-registered pattern with a pure function, counts orphaned orders per pair, and logs a report. It is safe to run again and again because it never writes.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
detect_duplicate_customers.py
"""Detect Medusa customers duplicated by a guest checkout followed by registration.

Medusa v2 stores guest and registered customers as separate Customer rows keyed
by email, without deduplicating across account states. A guest checkout creates
a row with has_account false. When that email later registers,
createCustomerAccountWorkflow's validateCustomerAccountCreation step only
rejects the registration if a row already has has_account true, so it does not
look up and reuse the guest row. It creates a brand new Customer row instead,
leaving the guest's prior orders foreign-keyed to the now-orphaned guest cus_
id, invisible to the newly registered account.

This is read-only. It pages through every customer, groups them by normalized
email, flags the exact guest-plus-registered pattern, and for each flagged pair
counts the orders still stuck on the orphaned guest id. Nothing is merged or
written. DRY_RUN stays on by default; a confirmed merge is a separate, manual
step, since Medusa v2 has no documented admin route for reassigning an order's
customer.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_duplicate_customers")

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"

CUSTOMER_FIELDS = "id,email,has_account,created_at"


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 admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def find_duplicate_customer_groups(customers):
    """Pure decision function. No I/O.

    customers: [{"id": str, "email": str, "has_account": bool}, ...]

    Returns [{"email", "guestId", "registeredId", "isDuplicate"}, ...] with one
    entry per distinct normalized email, where isDuplicate is True only for the
    exact guest-plus-registered pattern: exactly one has_account False row and
    at least one has_account True row sharing that email.
    """
    groups = {}
    for customer in customers:
        email = (customer.get("email") or "").strip().lower()
        groups.setdefault(email, []).append(customer)

    results = []
    for email, rows in groups.items():
        guest_rows = [c for c in rows if c.get("has_account") is False]
        registered_rows = [c for c in rows if c.get("has_account") is True]
        is_duplicate = len(guest_rows) == 1 and len(registered_rows) >= 1
        results.append({
            "email": email,
            "guestId": guest_rows[0]["id"] if guest_rows else None,
            "registeredId": registered_rows[0]["id"] if registered_rows else None,
            "isDuplicate": is_duplicate,
        })
    return results


def list_all_customers(token):
    customers = []
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/customers", {
            "fields": CUSTOMER_FIELDS,
            "limit": limit,
            "offset": offset,
        })
        customers.extend(data["customers"])
        offset += limit
        if offset >= data["count"]:
            return customers


def orphaned_order_count(token, guest_customer_id):
    data = admin_get(token, "/admin/orders", {
        "customer_id": guest_customer_id,
        "fields": "id,customer_id,email,display_id",
        "limit": 1,
    })
    return data["count"]


def run():
    token = get_admin_token()
    customers = list_all_customers(token)
    groups = find_duplicate_customer_groups(customers)
    duplicates = [g for g in groups if g["isDuplicate"]]

    report = []
    for group in duplicates:
        count = orphaned_order_count(token, group["guestId"])
        row = {
            "email": group["email"],
            "guest_customer_id": group["guestId"],
            "registered_customer_id": group["registeredId"],
            "orphaned_order_count": count,
        }
        report.append(row)
        log.warning(
            "Duplicate pair: %s guest=%s registered=%s orphaned_orders=%d. %s",
            row["email"], row["guest_customer_id"], row["registered_customer_id"],
            row["orphaned_order_count"],
            "reported only, DRY_RUN on" if DRY_RUN else "reported, no write performed",
        )

    log.info("Done. %d duplicate pair(s) found across %d customer row(s).", len(report), len(customers))
    return report


if __name__ == "__main__":
    run()
detect-duplicate-customers.js
/**
 * Detect Medusa customers duplicated by a guest checkout followed by registration.
 *
 * Medusa v2 stores guest and registered customers as separate Customer rows keyed
 * by email, without deduplicating across account states. A guest checkout creates
 * a row with has_account false. When that email later registers,
 * createCustomerAccountWorkflow's validateCustomerAccountCreation step only
 * rejects the registration if a row already has has_account true, so it does not
 * look up and reuse the guest row. It creates a brand new Customer row instead,
 * leaving the guest's prior orders foreign-keyed to the now-orphaned guest cus_
 * id, invisible to the newly registered account.
 *
 * This is read-only. It pages through every customer, groups them by normalized
 * email, flags the exact guest-plus-registered pattern, and for each flagged pair
 * counts the orders still stuck on the orphaned guest id. Nothing is merged or
 * written. DRY_RUN stays on by default; a confirmed merge is a separate, manual
 * step, since Medusa v2 has no documented admin route for reassigning an order's
 * customer.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/guest-registration-duplicate-customer/
 */
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 CUSTOMER_FIELDS = "id,email,has_account,created_at";

/**
 * Pure decision function. No I/O.
 *
 * @param {Array<{id: string, email: string, has_account: boolean}>} customers
 * @returns {Array<{email: string, guestId: string | null, registeredId: string | null, isDuplicate: boolean}>}
 */
export function findDuplicateCustomerGroups(customers) {
  const groups = new Map();
  for (const customer of customers) {
    const email = (customer.email || "").trim().toLowerCase();
    if (!groups.has(email)) groups.set(email, []);
    groups.get(email).push(customer);
  }

  const results = [];
  for (const [email, rows] of groups) {
    const guestRows = rows.filter((c) => c.has_account === false);
    const registeredRows = rows.filter((c) => c.has_account === true);
    const isDuplicate = guestRows.length === 1 && registeredRows.length >= 1;
    results.push({
      email,
      guestId: guestRows[0]?.id ?? null,
      registeredId: registeredRows[0]?.id ?? null,
      isDuplicate,
    });
  }
  return results;
}

async function getAdminToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function listAllCustomers(token) {
  const customers = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/customers", {
      fields: CUSTOMER_FIELDS,
      limit,
      offset,
    });
    customers.push(...data.customers);
    offset += limit;
    if (offset >= data.count) return customers;
  }
}

async function orphanedOrderCount(token, guestCustomerId) {
  const data = await adminGet(token, "/admin/orders", {
    customer_id: guestCustomerId,
    fields: "id,customer_id,email,display_id",
    limit: 1,
  });
  return data.count;
}

export async function run() {
  const token = await getAdminToken();
  const customers = await listAllCustomers(token);
  const groups = findDuplicateCustomerGroups(customers);
  const duplicates = groups.filter((g) => g.isDuplicate);

  const report = [];
  for (const group of duplicates) {
    const count = await orphanedOrderCount(token, group.guestId);
    const row = {
      email: group.email,
      guest_customer_id: group.guestId,
      registered_customer_id: group.registeredId,
      orphaned_order_count: count,
    };
    report.push(row);
    console.warn(
      `Duplicate pair: ${row.email} guest=${row.guest_customer_id} registered=${row.registered_customer_id} orphaned_orders=${row.orphaned_order_count}. ${DRY_RUN ? "reported only, DRY_RUN on" : "reported, no write performed"}`
    );
  }

  console.log(`Done. ${report.length} duplicate pair(s) found across ${customers.length} customer row(s).`);
  return report;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

find_duplicate_customer_groups is the part most worth testing, because it decides which customer rows are the guest-plus-registered pattern versus a single guest, a single registered account, or some other shape entirely. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture rows and checks the answer.

test_guest_duplicate_customer.py
from detect_duplicate_customers import find_duplicate_customer_groups


def customer(cid, email, has_account):
    return {"id": cid, "email": email, "has_account": has_account}


def test_single_guest_is_not_a_duplicate():
    rows = [customer("cus_1", "a@example.com", False)]
    result = find_duplicate_customer_groups(rows)
    assert len(result) == 1
    assert result[0]["isDuplicate"] is False
    assert result[0]["guestId"] == "cus_1"
    assert result[0]["registeredId"] is None


def test_single_registered_is_not_a_duplicate():
    rows = [customer("cus_1", "a@example.com", True)]
    result = find_duplicate_customer_groups(rows)
    assert result[0]["isDuplicate"] is False
    assert result[0]["registeredId"] == "cus_1"
    assert result[0]["guestId"] is None


def test_guest_plus_registered_is_flagged_as_duplicate():
    rows = [
        customer("cus_guest", "a@example.com", False),
        customer("cus_reg", "a@example.com", True),
    ]
    result = find_duplicate_customer_groups(rows)
    assert len(result) == 1
    assert result[0]["isDuplicate"] is True
    assert result[0]["guestId"] == "cus_guest"
    assert result[0]["registeredId"] == "cus_reg"


def test_email_is_normalized_before_grouping():
    rows = [
        customer("cus_guest", "  A@Example.com ", False),
        customer("cus_reg", "a@example.com", True),
    ]
    result = find_duplicate_customer_groups(rows)
    assert len(result) == 1
    assert result[0]["email"] == "a@example.com"
    assert result[0]["isDuplicate"] is True


def test_two_registered_rows_are_not_this_pattern():
    rows = [
        customer("cus_reg1", "a@example.com", True),
        customer("cus_reg2", "a@example.com", True),
    ]
    result = find_duplicate_customer_groups(rows)
    assert result[0]["isDuplicate"] is False


def test_different_emails_are_separate_groups():
    rows = [
        customer("cus_1", "a@example.com", False),
        customer("cus_2", "b@example.com", True),
    ]
    result = find_duplicate_customer_groups(rows)
    assert len(result) == 2
    assert all(r["isDuplicate"] is False for r in result)


def test_empty_input_returns_empty_list():
    assert find_duplicate_customer_groups([]) == []
guest-duplicate-customer.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateCustomerGroups } from "./detect-duplicate-customers.js";

const customer = (id, email, hasAccount) => ({ id, email, has_account: hasAccount });

test("single guest is not a duplicate", () => {
  const result = findDuplicateCustomerGroups([customer("cus_1", "a@example.com", false)]);
  assert.equal(result.length, 1);
  assert.equal(result[0].isDuplicate, false);
  assert.equal(result[0].guestId, "cus_1");
  assert.equal(result[0].registeredId, null);
});

test("single registered is not a duplicate", () => {
  const result = findDuplicateCustomerGroups([customer("cus_1", "a@example.com", true)]);
  assert.equal(result[0].isDuplicate, false);
  assert.equal(result[0].registeredId, "cus_1");
  assert.equal(result[0].guestId, null);
});

test("guest plus registered is flagged as duplicate", () => {
  const rows = [
    customer("cus_guest", "a@example.com", false),
    customer("cus_reg", "a@example.com", true),
  ];
  const result = findDuplicateCustomerGroups(rows);
  assert.equal(result.length, 1);
  assert.equal(result[0].isDuplicate, true);
  assert.equal(result[0].guestId, "cus_guest");
  assert.equal(result[0].registeredId, "cus_reg");
});

test("email is normalized before grouping", () => {
  const rows = [
    customer("cus_guest", "  A@Example.com ", false),
    customer("cus_reg", "a@example.com", true),
  ];
  const result = findDuplicateCustomerGroups(rows);
  assert.equal(result.length, 1);
  assert.equal(result[0].email, "a@example.com");
  assert.equal(result[0].isDuplicate, true);
});

test("two registered rows are not this pattern", () => {
  const rows = [
    customer("cus_reg1", "a@example.com", true),
    customer("cus_reg2", "a@example.com", true),
  ];
  const result = findDuplicateCustomerGroups(rows);
  assert.equal(result[0].isDuplicate, false);
});

test("different emails are separate groups", () => {
  const rows = [
    customer("cus_1", "a@example.com", false),
    customer("cus_2", "b@example.com", true),
  ];
  const result = findDuplicateCustomerGroups(rows);
  assert.equal(result.length, 2);
  assert.equal(result.every((r) => r.isDuplicate === false), true);
});

test("empty input returns empty list", () => {
  assert.deepEqual(findDuplicateCustomerGroups([]), []);
});

Case studies

Support escalation

The subscriber who lost their order history

A subscription-box brand let people check out as a guest for the first box, then invited them to register for a portal to manage upcoming shipments. Customers registering with the same email opened their new account and found it empty, no past boxes, no order they had just paid for. Support fielded the same "where did my order go" ticket dozens of times a month, and each time had to manually look up the guest row by hand.

Running the detection script in dry run surfaced every guest-plus-registered pair at once, each with its orphaned order count attached. Support could pull up the exact list instead of searching customer by customer, and the store used the report to prioritize a one-time manual reconciliation for the highest-order customers first.

Data quality

A wholesale storefront cleaning up before a migration

A B2B storefront had accumulated years of guest checkouts from buyers who only registered much later, if ever. Before migrating customer data to a new CRM, the team needed to know exactly how many email addresses had this split-row problem so the migration would not import duplicate contacts.

The script's report, grouped and counted with the pure decision function, gave them a clean list of every duplicate pair and its email, which they fed directly into the migration's dedupe step. Nothing in Medusa was touched. The report alone was the deliverable.

What good looks like

After this runs on a schedule, every guest-plus-registered pair is visible in one report instead of hiding until a customer complains. Support and data teams get the exact email, both customer ids, and the orphaned order count, without anyone hand-querying the database. Nothing is merged automatically, so order history and payment records stay exactly as consistent as they were before the script ran. The longer-term fix, patching validateCustomerAccountCreation or adding a customer.created subscriber that looks up the guest row first, is what stops new duplicates from forming, and this script is what surfaces the ones that already exist.

FAQ

Why does registering after a guest checkout create a second customer in Medusa?

Medusa v2 stores guest and registered customers as separate Customer rows keyed by email, without deduplicating across account states. A guest checkout creates a row with has_account false. When that same email registers, createCustomerAccountWorkflow only rejects the registration if a row with has_account true already exists for that email, so it does not look up and reuse the guest row. Instead it creates a brand new Customer row with has_account true, leaving two rows for one shopper.

Is it safe to merge the duplicate customer rows automatically?

No, not with a blind automated write. Repointing orders and auth identities is destructive, and Medusa v2 has no documented admin route for reassigning an order to a different customer, so an automated merge risks breaking order history and payment records. The safe approach is a dry run flag and report step for a human to review, with any retroactive merge handled as a deliberate, manual action.

How do you detect these duplicate guest and registered customer pairs?

Page through GET /admin/customers with fields for id, email, has_account, and orders, group the rows by normalized email, and flag any group that has exactly one row with has_account false alongside at least one row with has_account true. For each flagged pair, list orders on the guest customer id to see how many orders are hidden behind the stale guest row.

Related field notes

Citations

On the problem:

  1. Bug: Duplicate customer entries in database when promoting guest customer to registered user. Medusa GitHub Issue #11147. github.com/medusajs/medusa/issues/11147
  2. Bug: Duplicate Customer Records Affecting Order Visibility for Registered Users. Medusa GitHub Issue #9999. github.com/medusajs/medusa/issues/9999
  3. Bug: Orders incorrectly associated with guest accounts instead of registered customers sharing the same email. Medusa GitHub Issue #11827. github.com/medusajs/medusa/issues/11827

On the solution:

  1. Medusa Core Workflows Reference: createCustomerAccountWorkflow. docs.medusajs.com/resources/references/medusa-workflows/createCustomerAccountWorkflow
  2. Medusa Core Workflows Steps Reference: validateCustomerAccountCreation. docs.medusajs.com/resources/references/medusa-workflows/steps/validateCustomerAccountCreation
  3. Medusa Documentation: Customer Accounts, Customer commerce module. docs.medusajs.com/resources/commerce-modules/customer/customer-accounts

Stuck on a tricky one?

If you have a problem in Medusa storefront access, 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.

Contact me on LinkedIn

Did this untangle a duplicate customer?

If this saved you a support escalation or a confusing "my orders disappeared" ticket, 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

Back to all Medusa field notes