Reconciler Customers
Duplicate customer accounts share one email across websites
The same shopper registers on two websites in the same Magento instance, using the exact same email address both times, and Magento lets it happen without a single error. Two separate customer records exist now, with two separate entity_ids, two order histories, and one email address that your ERP or CRM thinks belongs to a single person. Here is why Magento allows this, and a script that finds every email address split across more than one website so a human can decide which account is canonical.
When Customer Configuration, Account Sharing Options is set to Per Website instead of Global (Stores, Settings, Configuration, Customers, Customer Configuration), Magento enforces the unique-email constraint only inside a website's shared customer group, not across the whole store. The same email can legitimately register a distinct customer entity_id on every website, each with its own website_id in core_customer. CustomerRepositoryInterface::get() reflects this directly, since it takes the email plus an optional website id, and the "A customer with the same email already exists in an associated website" error only fires within a share group, never across separate ones. Run a small Python or Node.js script that pages through GET /V1/customers/search, groups every customer by normalized email, and reports any email tied to more than one website_id. Full code, tests, and a safe, tag only repair path are below.
The problem in plain words
Most stores assume email is the one thing a customer record can be keyed on. One inbox, one identity, one account. Magento's default install agrees, because the out of the box Account Sharing Options is Global, and Global means exactly one customer per email across the entire installation, no matter how many websites you run under it.
But a lot of real Magento and Adobe Commerce deployments are multi-website on purpose. A B2C storefront and a B2B storefront under one instance. A US site and a UK site sharing a catalog but not a customer base. The moment Account Sharing Options is switched to Per Website, the unique-email rule shrinks down to "unique within this website's shared customer group." The same shopper can type the same email into both storefronts and end up with two completely separate customer entities, each with its own entity_id, its own order history, its own address book, and no link between them anywhere in Magento's own data model.
Why it happens
- Customer Configuration, Account Sharing Options under Stores, Settings, Configuration, Customers, Customer Configuration controls the scope of the unique-email rule. Global means one email per installation. Per Website means one email per website's shared customer group.
- Under Per Website,
core_customerstores awebsite_idcolumn, and the uniqueness check only compares customers that share a website. Two genuinely separate websites never collide, by design. CustomerRepositoryInterface::get()takes an email plus an optional$websiteIdargument for exactly this reason. Magento's own core API expects that looking up "the customer" by email is only meaningful within a website scope, not across the whole installation.- The "A customer with the same email already exists in an associated website" error, reported in core issue threads such as magento/magento2#13873 and magento/magento2#8494, only fires when the collision happens inside the same share group. Across separate websites the second registration is accepted silently.
- This is entirely fine for storefront browsing, since each website's shoppers only ever see their own account. It only becomes a problem the moment something outside Magento, an ERP, a CRM, or a marketing platform, assumes one email equals one customer and pulls two or more distinct ids for what it treats as a single identity.
This is not corrupted data. Per Website account sharing is a documented, deliberate setting, and the duplication it produces is Magento working as designed. So the fix is not to silently merge or delete anything, since merging entity_ids means re-pointing sales_order, quote, wishlist, and address rows, which is destructive and not reversible through the REST API. The safe move is to detect every email address split across more than one website_id, group those customers into clusters, and hand the list to a human who can pick the canonical account and reconcile it in the ERP.
The fix, as a flow
The script pages through every customer with GET /V1/customers/search, normalizes each email the same way (trimmed, lower cased), and groups customers under that key. Any group whose customers span more than one distinct website_id, or that has more than one customer on the very same website, which is its own data integrity problem, is reported as a cluster. Nothing gets merged or deleted. The only allowed write, behind a dry run guard, is a non-destructive tag on each customer in a cluster, for a human to reconcile later.
Build it step by step
Get an admin token and pick the canonical website
Get an admin token by calling POST {'{'}MAGENTO_URL{'}'}/rest/V1/integration/admin/token with your admin username and password, or use a long lived integration token. Decide which website_id is the canonical one for your business, meaning the account an ERP should treat as the source of truth when a cluster is found. Customers already on that website are skipped when tagging.
pip install requests
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export CANONICAL_WEBSITE_ID="1"
export DRY_RUN="true" # start safe, change to false to write the flag
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export CANONICAL_WEBSITE_ID="1"
export DRY_RUN="true" // start safe, change to false to write the flag
Talk to the Magento REST API
Every call sends the admin token as a bearer header. A small helper wraps GET and PUT requests, raises on a bad status code, and returns the parsed JSON body so the rest of the script only deals with plain data.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def put(path, body):
r = requests.put(
f"{MAGENTO_URL}/rest/V1{path}",
json=body,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function get(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: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function put(path, body) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Page through every customer
Call /V1/customers/search with a wide search criteria and page with pageSize and currentPage so the whole customer base is covered. From each CustomerInterface item, keep only id, email, and website_id, which is all the decision function needs.
PAGE_SIZE = 100
def all_customers():
customers, page = [], 1
while True:
params = {
"searchCriteria[pageSize]": PAGE_SIZE,
"searchCriteria[currentPage]": page,
}
data = get("/customers/search", params)
items = data.get("items", [])
for item in items:
customers.append({
"id": item.get("id"),
"email": item.get("email"),
"website_id": item.get("website_id"),
})
if len(items) < PAGE_SIZE:
return customers
page += 1
const PAGE_SIZE = 100;
async function allCustomers() {
const customers = [];
let page = 1;
while (true) {
const params = {
"searchCriteria[pageSize]": PAGE_SIZE,
"searchCriteria[currentPage]": page,
};
const data = await get("/customers/search", params);
const items = data.items || [];
for (const item of items) {
customers.push({ id: item.id, email: item.email, website_id: item.website_id });
}
if (items.length < PAGE_SIZE) return customers;
page += 1;
}
}
Decide, with one pure function
Keep the grouping logic in its own function so it is easy to read and easy to test. It normalizes each email by trimming whitespace and lower casing it, buckets every customer under that key, and keeps only the buckets where the distinct website_id values number more than one, or where the same email and website pair repeats, which is an invalid state on its own.
def group_duplicate_email_clusters(customers):
buckets = {}
for customer in customers:
key = (customer.get("email") or "").strip().lower()
buckets.setdefault(key, []).append(customer)
clusters = []
for email, bucket in buckets.items():
if not email:
continue
website_ids = sorted({c.get("website_id") for c in bucket})
is_multi_website = len(website_ids) > 1
is_same_website_dupe = len(website_ids) == 1 and len(bucket) > 1
if is_multi_website or is_same_website_dupe:
clusters.append({
"email": email,
"websiteIds": website_ids,
"customerIds": [c.get("id") for c in bucket],
})
return clusters
export function groupDuplicateEmailClusters(customers) {
const buckets = new Map();
for (const customer of customers) {
const key = (customer.email || "").trim().toLowerCase();
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(customer);
}
const clusters = [];
for (const [email, bucket] of buckets.entries()) {
if (!email) continue;
const websiteIds = [...new Set(bucket.map((c) => c.website_id))].sort((a, b) => a - b);
const isMultiWebsite = websiteIds.length > 1;
const isSameWebsiteDupe = websiteIds.length === 1 && bucket.length > 1;
if (isMultiWebsite || isSameWebsiteDupe) {
clusters.push({
email,
websiteIds,
customerIds: bucket.map((c) => c.id),
});
}
}
return clusters;
}
Report every cluster, never auto-merge
Merging entity_ids means re-pointing sales_order, quote, wishlist, and address rows, which is destructive and cannot be undone through the REST API, so this script never does it. Instead it logs one report row per cluster with the email, the customer ids involved, and the website ids, so a human can decide which account is canonical and reconcile it in the ERP or CRM by hand.
def report_cluster(cluster):
log.warning(
"Duplicate identity cluster: email=%s customerIds=%s websiteIds=%s",
cluster["email"], cluster["customerIds"], cluster["websiteIds"],
)
function reportCluster(cluster) {
console.warn(
`Duplicate identity cluster: email=${cluster.email} customerIds=${cluster.customerIds} websiteIds=${cluster.websiteIds}`
);
}
Tag for manual reconciliation, only when DRY_RUN is false
The only allowed write is non-destructive. When DRY_RUN is false, call PUT /V1/customers/{'{'}customerId{'}'} with a custom_attributes entry of code=duplicate_email_flag, value=true, one customer at a time, skipping any customer whose website_id already matches CANONICAL_WEBSITE_ID. Nothing is deleted, nothing is merged, and the canonical account is left untouched.
DRY_RUN defaults to true, and even when it is false the script never merges or deletes a customer. It only adds a duplicate_email_flag custom attribute, one customer at a time, and it skips the customer already on your configured canonical website, so the account you consider primary is never touched.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through every customer, groups duplicate-identity clusters with a pure function, logs a report row per cluster, and only tags the non-canonical customers when DRY_RUN is explicitly false.
"""Find Magento customer accounts that share one email across websites.
When Customer Configuration, Account Sharing Options is set to Per Website,
Magento only enforces the unique-email rule inside a website's shared customer
group. The same email can register a separate customer entity_id on every
website, which is fine for storefront browsing but breaks any external system
that keys customer records by email alone.
This script never merges or deletes anything, since merging entity_ids means
re-pointing sales_order, quote, wishlist, and address rows, which is destructive
and not reversible through the REST API. By default it only reports clusters.
It tags each non-canonical customer with a duplicate_email_flag custom
attribute only when DRY_RUN is false, one customer at a time.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_duplicate_email_clusters")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
CANONICAL_WEBSITE_ID = int(os.environ.get("CANONICAL_WEBSITE_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAGE_SIZE = 100
def get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def put(path, body):
r = requests.put(
f"{MAGENTO_URL}/rest/V1{path}",
json=body,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def all_customers():
customers, page = [], 1
while True:
params = {
"searchCriteria[pageSize]": PAGE_SIZE,
"searchCriteria[currentPage]": page,
}
data = get("/customers/search", params)
items = data.get("items", [])
for item in items:
customers.append({
"id": item.get("id"),
"email": item.get("email"),
"website_id": item.get("website_id"),
})
if len(items) < PAGE_SIZE:
return customers
page += 1
def group_duplicate_email_clusters(customers):
buckets = {}
for customer in customers:
key = (customer.get("email") or "").strip().lower()
buckets.setdefault(key, []).append(customer)
clusters = []
for email, bucket in buckets.items():
if not email:
continue
website_ids = sorted({c.get("website_id") for c in bucket})
is_multi_website = len(website_ids) > 1
is_same_website_dupe = len(website_ids) == 1 and len(bucket) > 1
if is_multi_website or is_same_website_dupe:
clusters.append({
"email": email,
"websiteIds": website_ids,
"customerIds": [c.get("id") for c in bucket],
})
return clusters
def report_cluster(cluster):
log.warning(
"Duplicate identity cluster: email=%s customerIds=%s websiteIds=%s",
cluster["email"], cluster["customerIds"], cluster["websiteIds"],
)
def flag_customer(customer_id):
body = {
"customer": {
"id": customer_id,
"custom_attributes": [{"attribute_code": "duplicate_email_flag", "value": "true"}],
}
}
put(f"/customers/{customer_id}", body)
def customer_website(customer_id, customers_by_id):
customer = customers_by_id.get(customer_id)
return customer.get("website_id") if customer else None
def run():
customers = all_customers()
customers_by_id = {c["id"]: c for c in customers}
clusters = group_duplicate_email_clusters(customers)
if not clusters:
log.info("Done. No duplicate-identity clusters found out of %d customer(s) checked.", len(customers))
return
for cluster in clusters:
report_cluster(cluster)
if DRY_RUN:
log.info(
"Done. %d duplicate-identity cluster(s) found. Set DRY_RUN=false to tag non-canonical "
"customers with duplicate_email_flag for manual reconciliation.", len(clusters),
)
return
tagged = 0
for cluster in clusters:
for customer_id in cluster["customerIds"]:
if customer_website(customer_id, customers_by_id) == CANONICAL_WEBSITE_ID:
continue
flag_customer(customer_id)
tagged += 1
log.info("Done. %d duplicate-identity cluster(s) found, %d customer(s) tagged for reconciliation.", len(clusters), tagged)
if __name__ == "__main__":
run()
/**
* Find Magento customer accounts that share one email across websites.
*
* When Customer Configuration, Account Sharing Options is set to Per Website,
* Magento only enforces the unique-email rule inside a website's shared customer
* group. The same email can register a separate customer entity_id on every
* website, which is fine for storefront browsing but breaks any external system
* that keys customer records by email alone.
*
* This script never merges or deletes anything, since merging entity_ids means
* re-pointing sales_order, quote, wishlist, and address rows, which is destructive
* and not reversible through the REST API. By default it only reports clusters.
* It tags each non-canonical customer with a duplicate_email_flag custom
* attribute only when DRY_RUN is false, one customer at a time.
*
* Guide: https://www.allanninal.dev/magento/duplicate-customer-accounts-same-email/
*/
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 CANONICAL_WEBSITE_ID = Number(process.env.CANONICAL_WEBSITE_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAGE_SIZE = 100;
export function groupDuplicateEmailClusters(customers) {
const buckets = new Map();
for (const customer of customers) {
const key = (customer.email || "").trim().toLowerCase();
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(customer);
}
const clusters = [];
for (const [email, bucket] of buckets.entries()) {
if (!email) continue;
const websiteIds = [...new Set(bucket.map((c) => c.website_id))].sort((a, b) => a - b);
const isMultiWebsite = websiteIds.length > 1;
const isSameWebsiteDupe = websiteIds.length === 1 && bucket.length > 1;
if (isMultiWebsite || isSameWebsiteDupe) {
clusters.push({
email,
websiteIds,
customerIds: bucket.map((c) => c.id),
});
}
}
return clusters;
}
async function get(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: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function put(path, body) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function allCustomers() {
const customers = [];
let page = 1;
while (true) {
const params = {
"searchCriteria[pageSize]": PAGE_SIZE,
"searchCriteria[currentPage]": page,
};
const data = await get("/customers/search", params);
const items = data.items || [];
for (const item of items) {
customers.push({ id: item.id, email: item.email, website_id: item.website_id });
}
if (items.length < PAGE_SIZE) return customers;
page += 1;
}
}
function reportCluster(cluster) {
console.warn(
`Duplicate identity cluster: email=${cluster.email} customerIds=${cluster.customerIds} websiteIds=${cluster.websiteIds}`
);
}
async function flagCustomer(customerId) {
const body = {
customer: {
id: customerId,
custom_attributes: [{ attribute_code: "duplicate_email_flag", value: "true" }],
},
};
await put(`/customers/${customerId}`, body);
}
function customerWebsite(customerId, customersById) {
const customer = customersById.get(customerId);
return customer ? customer.website_id : undefined;
}
export async function run() {
const customers = await allCustomers();
const customersById = new Map(customers.map((c) => [c.id, c]));
const clusters = groupDuplicateEmailClusters(customers);
if (!clusters.length) {
console.log(`Done. No duplicate-identity clusters found out of ${customers.length} customer(s) checked.`);
return;
}
for (const cluster of clusters) reportCluster(cluster);
if (DRY_RUN) {
console.log(
`Done. ${clusters.length} duplicate-identity cluster(s) found. Set DRY_RUN=false to tag non-canonical ` +
`customers with duplicate_email_flag for manual reconciliation.`
);
return;
}
let tagged = 0;
for (const cluster of clusters) {
for (const customerId of cluster.customerIds) {
if (customerWebsite(customerId, customersById) === CANONICAL_WEBSITE_ID) continue;
await flagCustomer(customerId);
tagged++;
}
}
console.log(`Done. ${clusters.length} duplicate-identity cluster(s) found, ${tagged} customer(s) tagged for reconciliation.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
group_duplicate_email_clusters is the part worth testing, because it decides which customers get flagged for reconciliation. It is a pure grouping over data already fetched, so the test needs no network and no Magento store. It just feeds in plain arrays and checks the answer.
from find_duplicate_email_clusters import group_duplicate_email_clusters
def test_single_website_no_cluster():
customers = [
{"id": 1, "email": "a@example.com", "website_id": 1},
{"id": 2, "email": "b@example.com", "website_id": 1},
]
assert group_duplicate_email_clusters(customers) == []
def test_same_email_two_websites_is_a_cluster():
customers = [
{"id": 1, "email": "a@example.com", "website_id": 1},
{"id": 2, "email": "a@example.com", "website_id": 2},
]
result = group_duplicate_email_clusters(customers)
assert len(result) == 1
assert result[0]["email"] == "a@example.com"
assert result[0]["websiteIds"] == [1, 2]
assert sorted(result[0]["customerIds"]) == [1, 2]
def test_same_email_same_website_twice_is_a_data_integrity_cluster():
customers = [
{"id": 1, "email": "a@example.com", "website_id": 1},
{"id": 2, "email": "a@example.com", "website_id": 1},
]
result = group_duplicate_email_clusters(customers)
assert len(result) == 1
assert result[0]["websiteIds"] == [1]
assert sorted(result[0]["customerIds"]) == [1, 2]
def test_mixed_case_and_whitespace_email_still_clusters():
customers = [
{"id": 1, "email": " A@Example.com", "website_id": 1},
{"id": 2, "email": "a@example.com ", "website_id": 2},
]
result = group_duplicate_email_clusters(customers)
assert len(result) == 1
assert result[0]["email"] == "a@example.com"
assert result[0]["websiteIds"] == [1, 2]
def test_no_cluster_when_every_email_is_unique_per_website():
customers = [
{"id": 1, "email": "a@example.com", "website_id": 1},
{"id": 2, "email": "b@example.com", "website_id": 2},
{"id": 3, "email": "c@example.com", "website_id": 1},
]
assert group_duplicate_email_clusters(customers) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { groupDuplicateEmailClusters } from "./find-duplicate-email-clusters.js";
test("single website has no cluster", () => {
const customers = [
{ id: 1, email: "a@example.com", website_id: 1 },
{ id: 2, email: "b@example.com", website_id: 1 },
];
assert.deepEqual(groupDuplicateEmailClusters(customers), []);
});
test("same email two websites is a cluster", () => {
const customers = [
{ id: 1, email: "a@example.com", website_id: 1 },
{ id: 2, email: "a@example.com", website_id: 2 },
];
const result = groupDuplicateEmailClusters(customers);
assert.equal(result.length, 1);
assert.equal(result[0].email, "a@example.com");
assert.deepEqual(result[0].websiteIds, [1, 2]);
assert.deepEqual(result[0].customerIds.sort(), [1, 2]);
});
test("same email same website twice is a data integrity cluster", () => {
const customers = [
{ id: 1, email: "a@example.com", website_id: 1 },
{ id: 2, email: "a@example.com", website_id: 1 },
];
const result = groupDuplicateEmailClusters(customers);
assert.equal(result.length, 1);
assert.deepEqual(result[0].websiteIds, [1]);
assert.deepEqual(result[0].customerIds.sort(), [1, 2]);
});
test("mixed case and whitespace email still clusters", () => {
const customers = [
{ id: 1, email: " A@Example.com", website_id: 1 },
{ id: 2, email: "a@example.com ", website_id: 2 },
];
const result = groupDuplicateEmailClusters(customers);
assert.equal(result.length, 1);
assert.equal(result[0].email, "a@example.com");
assert.deepEqual(result[0].websiteIds, [1, 2]);
});
test("no cluster when every email is unique per website", () => {
const customers = [
{ id: 1, email: "a@example.com", website_id: 1 },
{ id: 2, email: "b@example.com", website_id: 2 },
{ id: 3, email: "c@example.com", website_id: 1 },
];
assert.deepEqual(groupDuplicateEmailClusters(customers), []);
});
Case studies
One shopper, two ERP customer records
A home goods brand ran a B2C website and a wholesale website under one Magento instance, both set to Per Website account sharing on purpose, since the wholesale side needed its own price books and customer groups. A boutique owner who also shopped the B2C site personally used the same email on both.
The nightly ERP sync keyed customers by email and quietly created two ERP records, splitting her order history and her loyalty totals in half. Running the detection script found the cluster in minutes, and finance manually merged the ERP side while keeping both Magento accounts as they were, since Magento itself was never the thing that was broken.
Duplicate welcome emails from the ESP
A multi-region retailer ran a US website and an EU website, each Per Website, and synced new customer signups into an email service provider. A customer who registered on both sites received two separate welcome series and two separate abandoned cart sequences from the same brand, which read as spam rather than personalization.
The team ran the script in dry run first, reviewed the list of clustered emails, then switched it to tag the non-canonical customer record. The ESP integration was updated to skip any contact carrying the duplicate_email_flag until a human reconciled which website record should own the relationship.
After running this on a schedule, every email address split across more than one website shows up as a short, explicit cluster report, not a silent gap discovered by an angry customer or a confused ERP. Nothing in Magento gets merged or deleted, the canonical website's customer is never touched, and the only write is a clearly named flag that tells any downstream system exactly which records need a human's judgment before they are treated as one identity.
FAQ
Why does Magento let the same email register twice?
When Customer Configuration, Account Sharing Options is set to Per Website instead of Global, Magento only enforces the unique-email rule within one website's shared customer group. Across two genuinely separate websites the same email can register a second, distinct customer entity_id, because each website has its own share group and Magento never compares across them.
Is this a bug in Magento or expected behavior?
It is expected behavior for the Per Website scope, documented as the account sharing setting under Stores, Configuration, Customers, Customer Configuration. It becomes a problem only when an external system such as an ERP, CRM, or ESP keys customer records by email alone and receives two or more distinct customer ids for what it treats as one identity.
Can I safely merge the duplicate customer accounts automatically?
No, not through the REST API. Merging entity_ids means re-pointing sales_order, quote, wishlist, and address rows from one customer id to another, which is destructive and not reversible with a script. The safe pattern is to detect and flag the clusters for a human to pick the canonical account, and only perform a non-destructive tag as the write.
Related field notes
Citations
On the problem:
- Adobe Commerce user guide: Customer account scope, Global versus Per Website account sharing. experienceleague.adobe.com/en/docs/commerce-admin/customers/customer-accounts/customer-account-scope
- Magento 2 GitHub issue: "A customer with the same email already exists in an associated website". github.com/magento/magento2/issues/13873
- Magento 2 GitHub issue: the same email-already-exists error reported on Magento 2.1.3 to 2.1.4. github.com/magento/magento2/issues/8494
On the solution:
- Adobe Commerce Web API: searching with REST endpoints and searchCriteria filter groups. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches
- Adobe Commerce user guide: Customers, Customer Configuration, and Account Sharing Options. experienceleague.adobe.com/en/docs/commerce-admin/config/customers/customer-configuration
- Adobe Commerce Web API: REST API overview and authentication. developer.adobe.com/commerce/webapi/rest
Stuck on a tricky one?
If you have a problem in Magento indexing, cron, MSI stock, customer accounts, or order grid sync 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 a duplicate-account mess?
If this saved you an ERP reconciliation headache or a confused marketing sync, 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