Diagnostic
Duplicate customer accounts created from the same email during guest checkout
A shopper checks out as a guest, comes back a week later, checks out as a guest again with the same email, and support ends up staring at two customer records that both claim to be the same person. Or a returning guest finally creates a password, and PrestaShop quietly adds a brand new row instead of turning the old one into their account. Nothing crashes. Nothing warns you. Here is why PrestaShop lets this happen and a script that finds every email with more than one live customer row so a human can decide what to do about it.
PrestaShop only checks email uniqueness in the front-office registration form's validation layer, and guest orders are exempt from that check entirely. There is no database constraint and no webservice-level check. So a repeat guest checkout, a guest-to-account conversion through transformGuestToCustomer, or a webservice POST or PUT to the customers resource can each insert a second ps_customer row for an email that already exists, because none of that code queries for an existing email before inserting. Run a Python or Node.js script that pulls every customer, groups the rows by normalized email in the client, and reports any email with more than one active row, including which one has real order history. Full code, tests, and citations are below.
The problem in plain words
Most software treats an email address as the thing that identifies a customer. PrestaShop mostly agrees, but only in one place: the registration form a shopper fills in when they tick "Create an account." That form calls a validation rule that checks whether the email is already taken, and if it is, it stops the submission.
Everywhere else, nothing stops it. Guest checkout was built to let someone buy without registering, so it creates a ps_customer row with is_guest=1 and never asks whether that email has been used before, on purpose, because asking would defeat the point of a guest flow. The trouble starts when that same email shows up again later: another guest order, a decision to finally register, or an API call from a script or a marketing tool. None of those code paths re-checks the email against what is already in the database. PrestaShop just inserts another row.
Why it happens
The root cause is that PrestaShop enforces email uniqueness only in the front-office registration form's validation layer, not as a database constraint or a webservice-level check, and guest orders are exempt from that check even where it does run. Documented ways it shows up:
- A guest checks out, PrestaShop creates a
ps_customerrow withis_guest=1for that email. Weeks later the same visitor checks out as guest again with the same email, and a second guest row is created, since guest checkout was never meant to look anything up. - A returning guest decides to set a password and become a registered account.
CustomerCore's guest-to-account transform (transformGuestToCustomer) does not query for an existing row with that email first, so instead of upgrading the original guest row in place it can leave two rows behind, one still marked guest and one now registered. - An admin, an integration, or a script calls
POSTorPUTon thecustomerswebservice resource with an email that already exists on a guest row, or even a non-guest row. The webservice resource performs no existing-email lookup before insert, so it happily creates another customer. - This is a long-standing core issue rather than a one-off bug in a single version, tracked upstream as guest accounts stacking indefinitely and as
transformGuestToCustomerproducing two rows for one email.
None of these paths are rare edge cases. Guest checkout exists specifically to avoid asking for an account, repeat guest visits are common for anyone who shops occasionally, and the webservice is a normal integration point for marketing tools and migrations. See the citations at the end for the exact issues and docs.
A duplicate customer row is not something a script should just merge away. Merging identities means reassigning addresses, orders, cart rules, and order history to one surviving id_customer, and that is destructive and order-affecting if the script guesses wrong about which row is the real one. So the safe pattern is not "collapse every duplicate automatically." It is "report every duplicate for a human," using order history and account age to suggest which row looks like the primary, and reserving any actual write for a reversible soft-delete of a row that has zero orders.
The fix, as a flow
We do not touch ps_customer directly. We add a job that pulls every customer, groups them by a normalized email since the API offers no GROUP BY, flags any email with more than one active row, and for each flagged email fetches order counts to work out which row looks like the keeper. Everything becomes a report row for a human to review, and the only write path is an optional, guarded soft-delete of a zero-order duplicate.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with read access to customers and orders. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, only reports by default
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, only reports by default
Pull every customer
Call GET /api/customers?output_format=JSON&display=[id,email,is_guest,deleted,date_add]&limit=0 to list every row with the fields the decision needs. The webservice has no GROUP BY or HAVING, so if the shop has many customers, page through with limit=offset,count instead of trying to fetch everything in one call when the count is large.
import os, requests
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def all_customers(page_size=200):
offset = 0
rows = []
while True:
data = api_get("customers", params={
"display": "[id,email,is_guest,deleted,date_add]",
"limit": f"{offset},{page_size}",
})
page = data.get("customers") or []
rows.extend(page)
if len(page) < page_size:
return rows
offset += page_size
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function allCustomers(pageSize = 200) {
let offset = 0;
const rows = [];
while (true) {
const data = await apiGet("customers", {
display: "[id,email,is_guest,deleted,date_add]",
limit: `${offset},${pageSize}`,
});
const page = data.customers || [];
rows.push(...page);
if (page.length < pageSize) return rows;
offset += pageSize;
}
}
Group by normalized email and add order counts
Group the rows in the client by lowercased, trimmed email, since the API cannot do this for you. For any email with more than one row where deleted=0, fetch GET /api/orders?filter[id_customer]={id}&display=full for each candidate id so the decision step knows which row actually has purchase history.
from collections import defaultdict
def normalize_email(email):
return str(email or "").strip().lower()
def group_by_email(customers):
groups = defaultdict(list)
for c in customers:
groups[normalize_email(c.get("email"))].append(c)
return {email: rows for email, rows in groups.items() if len(rows) > 1}
def order_count_for(id_customer):
data = api_get("orders", params={"filter[id_customer]": id_customer, "display": "full"})
return len(data.get("orders") or [])
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
function groupByEmail(customers) {
const groups = new Map();
for (const c of customers) {
const key = normalizeEmail(c.email);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(c);
}
const duplicates = {};
for (const [email, rows] of groups) {
if (rows.length > 1) duplicates[email] = rows;
}
return duplicates;
}
async function orderCountFor(idCustomer) {
const data = await apiGet("orders", { "filter[id_customer]": idCustomer, display: "full" });
return (data.orders || []).length;
}
Decide, with one pure function
Keep the decision in its own function that takes every row sharing one normalized email, each carrying an order_count, and returns either None or a merge-candidate report. It keeps the row with the highest order count as keep_id, breaking ties in favor of a non-guest row and then the earliest date_add, and lists every other active row as a duplicate. Rows already marked deleted=1 are ignored, since they are not live duplicates anymore.
def pick_merge_action(customer_rows):
active = [r for r in customer_rows if str(r.get("deleted", "0")) != "1"]
if len(active) <= 1:
return None
def sort_key(row):
order_count = row.get("order_count", 0) or 0
is_guest = str(row.get("is_guest", "0")) == "1"
is_registered = 0 if is_guest else 1 # non-guest sorts first (0)
date_add = str(row.get("date_add") or "9999-99-99 99:99:99")
return (-order_count, -is_registered, date_add)
ranked = sorted(active, key=sort_key)
keep = ranked[0]
duplicates = ranked[1:]
return {
"email": customer_rows[0].get("email"),
"keep_id": keep.get("id"),
"duplicate_ids": [r.get("id") for r in duplicates],
"reason": "highest order_count, then registered over guest, then earliest date_add",
}
export function pickMergeAction(customerRows) {
const active = customerRows.filter((r) => String(r.deleted ?? "0") !== "1");
if (active.length <= 1) return null;
const ranked = [...active].sort((a, b) => {
const oa = a.order_count || 0;
const ob = b.order_count || 0;
if (oa !== ob) return ob - oa;
const ra = String(a.is_guest ?? "0") === "1" ? 0 : 1;
const rb = String(b.is_guest ?? "0") === "1" ? 0 : 1;
if (ra !== rb) return rb - ra;
const da = a.date_add || "9999-99-99 99:99:99";
const db = b.date_add || "9999-99-99 99:99:99";
return da < db ? -1 : da > db ? 1 : 0;
});
const keep = ranked[0];
const duplicates = ranked.slice(1);
return {
email: customerRows[0].email,
keep_id: keep.id,
duplicate_ids: duplicates.map((r) => r.id),
reason: "highest order_count, then registered over guest, then earliest date_add",
};
}
Only ever soft-delete a zero-order duplicate
If a write is wanted at all, the only safe one is marking a duplicate's deleted field to 1 with PUT /api/customers/{id}, a reversible soft-delete that does not touch orders or addresses, and only when that duplicate row has zero orders and DRY_RUN is explicitly set to false. Anything with order history stays report-only, full stop.
def soft_delete_customer(id_customer):
data = api_get(f"customers/{id_customer}", params={"display": "full"})
customer = data["customer"]
customer["deleted"] = "1"
body = {"customer": customer}
r = requests.put(
f"{PRESTASHOP_URL}/api/customers/{id_customer}",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
async function softDeleteCustomer(idCustomer) {
const data = await apiGet(`customers/${idCustomer}`, { display: "full" });
const customer = data.customer;
customer.deleted = "1";
const res = await fetch(`${PRESTASHOP_URL}/api/customers/${idCustomer}?output_format=JSON`, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ customer }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT customers/${idCustomer}`);
return res.json();
}
Wire it together with a dry run guard
The loop ties every piece together: pull every customer, group by normalized email, add order counts for any flagged group, run pick_merge_action, and log a report row for every merge candidate. With DRY_RUN true (the default), it only reports. With DRY_RUN false, it additionally soft-deletes any duplicate id that has zero orders, leaving anything with order history untouched no matter what. Run it on a schedule that matches how often guest checkout happens, for example once a day.
Never merge addresses, orders, cart rules, or order history between two customer ids from an unattended script. That reassignment is destructive and order-affecting if the script guesses the wrong primary. The soft-delete this script performs only flips deleted to 1 on a row with zero orders, which is reversible and touches nothing else. Everything with order history on more than one row stays a report line for a human.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pulls and groups every customer, reports every merge candidate with order counts, and only ever writes a reversible soft-delete on a zero-order duplicate when DRY_RUN is explicitly false.
"""Detect PrestaShop customer accounts duplicated across the same email.
PrestaShop enforces email uniqueness only in the front-office registration form's
validation layer, not as a database constraint or a webservice-level check, and guest
orders are exempt from that check entirely. Guest checkout creates a ps_customer row
with is_guest=1 for a given email. If the same visitor later checks out as guest again,
converts that guest to a registered account (CustomerCore's transformGuestToCustomer),
or an admin or webservice call creates a customer with an email that already exists on
a guest or non-guest row, PrestaShop inserts a second ps_customer row instead of merging,
because none of those code paths query for an existing email before inserting.
This script only reads and reports by default. Merging addresses, orders, cart rules,
and order history into one surviving id_customer is destructive and order-affecting, so
it is unsafe for an unattended script to do automatically. The only write this script
ever performs is a reversible soft-delete (deleted=1) of a duplicate row that has zero
associated orders, and only when DRY_RUN is explicitly set to false.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
from collections import defaultdict
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_duplicate_customers")
PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "200"))
AUTH = (PRESTASHOP_WS_KEY, "")
def normalize_email(email):
return str(email or "").strip().lower()
def pick_merge_action(customer_rows):
"""Pure decision function, no I/O.
customer_rows is a list of customer dicts (keys: id, email, is_guest, deleted,
date_add, order_count) that all share one normalized email. Returns None if one or
zero active (deleted=0) rows remain. Otherwise returns a dict with email, keep_id
(the row with the highest order_count, ties broken by is_guest==False then earliest
date_add), duplicate_ids (every other active row), and a human-readable reason.
"""
active = [r for r in customer_rows if str(r.get("deleted", "0")) != "1"]
if len(active) <= 1:
return None
def sort_key(row):
order_count = row.get("order_count", 0) or 0
is_guest = str(row.get("is_guest", "0")) == "1"
is_registered = 0 if is_guest else 1
date_add = str(row.get("date_add") or "9999-99-99 99:99:99")
return (-order_count, -is_registered, date_add)
ranked = sorted(active, key=sort_key)
keep = ranked[0]
duplicates = ranked[1:]
return {
"email": customer_rows[0].get("email"),
"keep_id": keep.get("id"),
"duplicate_ids": [r.get("id") for r in duplicates],
"reason": "highest order_count, then registered over guest, then earliest date_add",
}
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def all_customers(page_size=PAGE_SIZE):
offset = 0
rows = []
while True:
data = api_get("customers", params={
"display": "[id,email,is_guest,deleted,date_add]",
"limit": f"{offset},{page_size}",
})
page = data.get("customers") or []
rows.extend(page)
if len(page) < page_size:
return rows
offset += page_size
def group_by_email(customers):
groups = defaultdict(list)
for c in customers:
groups[normalize_email(c.get("email"))].append(c)
return {email: rows for email, rows in groups.items() if len(rows) > 1}
def order_count_for(id_customer):
data = api_get("orders", params={"filter[id_customer]": id_customer, "display": "full"})
return len(data.get("orders") or [])
def soft_delete_customer(id_customer):
data = api_get(f"customers/{id_customer}", params={"display": "full"})
customer = data["customer"]
customer["deleted"] = "1"
r = requests.put(
f"{PRESTASHOP_URL}/api/customers/{id_customer}",
params={"output_format": "JSON"},
json={"customer": customer},
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
customers = all_customers()
duplicate_groups = group_by_email(customers)
flagged = 0
for email, rows in duplicate_groups.items():
for row in rows:
row["order_count"] = order_count_for(row["id"])
action = pick_merge_action(rows)
if action is None:
continue
flagged += 1
log.warning(
"Merge candidate found. email=%s keep_id=%s duplicate_ids=%s reason=%s",
action["email"], action["keep_id"], action["duplicate_ids"], action["reason"],
)
if DRY_RUN:
continue
by_id = {r["id"]: r for r in rows}
for dup_id in action["duplicate_ids"]:
if by_id[dup_id].get("order_count", 0) == 0:
log.warning("Soft-deleting zero-order duplicate id_customer=%s", dup_id)
soft_delete_customer(dup_id)
else:
log.warning(
"Skipping soft-delete for id_customer=%s, it has order history. Manual merge required.",
dup_id,
)
log.info(
"Done. %d email(s) with duplicate customer accounts flagged. DRY_RUN=%s "
"(only zero-order duplicates are ever soft-deleted, never merged automatically).",
flagged, DRY_RUN,
)
if __name__ == "__main__":
run()
/**
* Detect PrestaShop customer accounts duplicated across the same email.
*
* PrestaShop enforces email uniqueness only in the front-office registration form's
* validation layer, not as a database constraint or a webservice-level check, and guest
* orders are exempt from that check entirely. Guest checkout creates a ps_customer row
* with is_guest=1 for a given email. If the same visitor later checks out as guest
* again, converts that guest to a registered account (CustomerCore's
* transformGuestToCustomer), or an admin or webservice call creates a customer with an
* email that already exists on a guest or non-guest row, PrestaShop inserts a second
* ps_customer row instead of merging, because none of those code paths query for an
* existing email before inserting.
*
* This script only reads and reports by default. Merging addresses, orders, cart rules,
* and order history into one surviving id_customer is destructive and order-affecting,
* so it is unsafe for an unattended script to do automatically. The only write this
* script ever performs is a reversible soft-delete (deleted=1) of a duplicate row that
* has zero associated orders, and only when DRY_RUN is explicitly set to false.
*
* Guide: https://www.allanninal.dev/prestashop/duplicate-customer-accounts-same-email/
*/
import { pathToFileURL } from "node:url";
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 200);
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
export function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
/**
* Pure decision function, no I/O.
*
* customerRows is an array of customer objects (keys: id, email, is_guest, deleted,
* date_add, order_count) that all share one normalized email. Returns null if one or
* zero active (deleted=0) rows remain. Otherwise returns an object with email, keep_id
* (the row with the highest order_count, ties broken by is_guest===false then earliest
* date_add), duplicate_ids (every other active row), and a human-readable reason.
*/
export function pickMergeAction(customerRows) {
const active = customerRows.filter((r) => String(r.deleted ?? "0") !== "1");
if (active.length <= 1) return null;
const ranked = [...active].sort((a, b) => {
const oa = a.order_count || 0;
const ob = b.order_count || 0;
if (oa !== ob) return ob - oa;
const ra = String(a.is_guest ?? "0") === "1" ? 0 : 1;
const rb = String(b.is_guest ?? "0") === "1" ? 0 : 1;
if (ra !== rb) return rb - ra;
const da = a.date_add || "9999-99-99 99:99:99";
const db = b.date_add || "9999-99-99 99:99:99";
return da < db ? -1 : da > db ? 1 : 0;
});
const keep = ranked[0];
const duplicates = ranked.slice(1);
return {
email: customerRows[0].email,
keep_id: keep.id,
duplicate_ids: duplicates.map((r) => r.id),
reason: "highest order_count, then registered over guest, then earliest date_add",
};
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function allCustomers(pageSize = PAGE_SIZE) {
let offset = 0;
const rows = [];
while (true) {
const data = await apiGet("customers", {
display: "[id,email,is_guest,deleted,date_add]",
limit: `${offset},${pageSize}`,
});
const page = data.customers || [];
rows.push(...page);
if (page.length < pageSize) return rows;
offset += pageSize;
}
}
function groupByEmail(customers) {
const groups = new Map();
for (const c of customers) {
const key = normalizeEmail(c.email);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(c);
}
const duplicates = {};
for (const [email, rows] of groups) {
if (rows.length > 1) duplicates[email] = rows;
}
return duplicates;
}
async function orderCountFor(idCustomer) {
const data = await apiGet("orders", { "filter[id_customer]": idCustomer, display: "full" });
return (data.orders || []).length;
}
async function softDeleteCustomer(idCustomer) {
const data = await apiGet(`customers/${idCustomer}`, { display: "full" });
const customer = data.customer;
customer.deleted = "1";
const res = await fetch(`${PRESTASHOP_URL}/api/customers/${idCustomer}?output_format=JSON`, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ customer }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT customers/${idCustomer}`);
return res.json();
}
export async function run() {
const customers = await allCustomers();
const duplicateGroups = groupByEmail(customers);
let flagged = 0;
for (const [email, rows] of Object.entries(duplicateGroups)) {
for (const row of rows) {
row.order_count = await orderCountFor(row.id);
}
const action = pickMergeAction(rows);
if (!action) continue;
flagged++;
console.warn(
`Merge candidate found. email=${action.email} keep_id=${action.keep_id} ` +
`duplicate_ids=${JSON.stringify(action.duplicate_ids)} reason=${action.reason}`
);
if (DRY_RUN) continue;
const byId = new Map(rows.map((r) => [r.id, r]));
for (const dupId of action.duplicate_ids) {
const row = byId.get(dupId);
if ((row.order_count || 0) === 0) {
console.warn(`Soft-deleting zero-order duplicate id_customer=${dupId}`);
await softDeleteCustomer(dupId);
} else {
console.warn(
`Skipping soft-delete for id_customer=${dupId}, it has order history. Manual merge required.`
);
}
}
}
console.log(
`Done. ${flagged} email(s) with duplicate customer accounts flagged. DRY_RUN=${DRY_RUN} ` +
`(only zero-order duplicates are ever soft-deleted, never merged automatically).`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The ranking rule is the part most worth testing, because it decides which row a human reviewer sees suggested as the keeper. Because we kept pick_merge_action pure, the test needs no network and no PrestaShop store. It just feeds in plain customer rows and checks the answer.
from check_duplicate_customers import pick_merge_action, normalize_email
def customer(**over):
base = {
"id": 1,
"email": "jane@example.com",
"is_guest": "0",
"deleted": "0",
"date_add": "2026-01-01 10:00:00",
"order_count": 0,
}
base.update(over)
return base
def test_no_action_for_single_row():
assert pick_merge_action([customer()]) is None
def test_no_action_when_only_one_active_row():
rows = [customer(id=1), customer(id=2, deleted="1")]
assert pick_merge_action(rows) is None
def test_keeps_row_with_more_orders():
rows = [
customer(id=1, order_count=0),
customer(id=2, order_count=5),
]
action = pick_merge_action(rows)
assert action["keep_id"] == 2
assert action["duplicate_ids"] == [1]
def test_ties_broken_by_registered_over_guest():
rows = [
customer(id=1, is_guest="1", order_count=0),
customer(id=2, is_guest="0", order_count=0),
]
action = pick_merge_action(rows)
assert action["keep_id"] == 2
assert action["duplicate_ids"] == [1]
def test_ties_broken_by_earliest_date_add():
rows = [
customer(id=1, date_add="2026-03-01 00:00:00", order_count=0),
customer(id=2, date_add="2026-01-01 00:00:00", order_count=0),
]
action = pick_merge_action(rows)
assert action["keep_id"] == 2
assert action["duplicate_ids"] == [1]
def test_deleted_rows_are_ignored():
rows = [
customer(id=1, order_count=3),
customer(id=2, deleted="1", order_count=9),
customer(id=3, order_count=1),
]
action = pick_merge_action(rows)
assert action["keep_id"] == 1
assert action["duplicate_ids"] == [3]
def test_email_carried_through_from_first_row():
rows = [customer(id=1, email="Jane@Example.com "), customer(id=2, order_count=1)]
action = pick_merge_action(rows)
assert action["email"] == "Jane@Example.com "
def test_normalize_email_lowers_and_trims():
assert normalize_email(" Jane@Example.COM ") == "jane@example.com"
def test_normalize_email_handles_none():
assert normalize_email(None) == ""
import { test } from "node:test";
import assert from "node:assert/strict";
import { pickMergeAction, normalizeEmail } from "./check-duplicate-customers.js";
const customer = (over = {}) => ({
id: 1,
email: "jane@example.com",
is_guest: "0",
deleted: "0",
date_add: "2026-01-01 10:00:00",
order_count: 0,
...over,
});
test("no action for single row", () => {
assert.equal(pickMergeAction([customer()]), null);
});
test("no action when only one active row", () => {
const rows = [customer({ id: 1 }), customer({ id: 2, deleted: "1" })];
assert.equal(pickMergeAction(rows), null);
});
test("keeps row with more orders", () => {
const rows = [
customer({ id: 1, order_count: 0 }),
customer({ id: 2, order_count: 5 }),
];
const action = pickMergeAction(rows);
assert.equal(action.keep_id, 2);
assert.deepEqual(action.duplicate_ids, [1]);
});
test("ties broken by registered over guest", () => {
const rows = [
customer({ id: 1, is_guest: "1", order_count: 0 }),
customer({ id: 2, is_guest: "0", order_count: 0 }),
];
const action = pickMergeAction(rows);
assert.equal(action.keep_id, 2);
assert.deepEqual(action.duplicate_ids, [1]);
});
test("ties broken by earliest date_add", () => {
const rows = [
customer({ id: 1, date_add: "2026-03-01 00:00:00", order_count: 0 }),
customer({ id: 2, date_add: "2026-01-01 00:00:00", order_count: 0 }),
];
const action = pickMergeAction(rows);
assert.equal(action.keep_id, 2);
assert.deepEqual(action.duplicate_ids, [1]);
});
test("deleted rows are ignored", () => {
const rows = [
customer({ id: 1, order_count: 3 }),
customer({ id: 2, deleted: "1", order_count: 9 }),
customer({ id: 3, order_count: 1 }),
];
const action = pickMergeAction(rows);
assert.equal(action.keep_id, 1);
assert.deepEqual(action.duplicate_ids, [3]);
});
test("email carried through from first row", () => {
const rows = [customer({ id: 1, email: "Jane@Example.com " }), customer({ id: 2, order_count: 1 })];
const action = pickMergeAction(rows);
assert.equal(action.email, "Jane@Example.com ");
});
test("normalizeEmail lowers and trims", () => {
assert.equal(normalizeEmail(" Jane@Example.COM "), "jane@example.com");
});
test("normalizeEmail handles missing input", () => {
assert.equal(normalizeEmail(undefined), "");
});
Case studies
The subscription box brand with a growing guest pile
A subscription box store let most shoppers check out as guests to keep friction low, and a fair number came back every few months to reorder, always choosing guest checkout again out of habit. Support started noticing that address updates and loyalty perks applied to one order but not another from the same person, since the two orders sat on two different customer ids.
Running the scan across the full customer list turned up dozens of emails with two or three guest rows apiece, none of them with any registered account at all. The team used the report to see which id actually held the most recent, highest-value order history and manually merged addresses and notes into that one, then encouraged repeat guests toward creating a real account to stop the pile from growing.
The store where "create my account" quietly forked a customer
A homeware shop encouraged guests to set a password after their order shipped, using the standard guest-to-account flow. A few customers who did this ended up emailing support confused that their order history had vanished, because the registered account they just created was a brand new row, not an upgrade of the guest row that actually held their order.
The diagnostic flagged these emails immediately, since one row plainly had orders and the other, newer row had none. Because the duplicate carried zero orders, the team's own review confirmed it was safe to soft-delete that empty row and point the customer back at the original guest-turned-primary account, without touching any address or order data.
After this runs on a schedule, every email quietly sitting on two or more customer rows surfaces as a clear report line with a suggested keeper, instead of confusing support tickets about missing order history or a loyalty perk that did not apply. Nothing gets merged automatically, since guessing wrong about which row is primary would be destructive. A human reviews the short list, decides the real merges by hand in the back office, and the only thing the script ever writes on its own is a reversible soft-delete of a duplicate that never had an order to begin with.
FAQ
Why does PrestaShop let two customers share the same email?
Email uniqueness in PrestaShop is only enforced in the front-office registration form's validation layer, not as a database constraint, and guest orders are exempt from that check entirely. Guest checkout creates a customer row with is_guest=1 for an email, and if that visitor checks out as guest again, converts the guest row to a registered account, or an admin or webservice call creates a customer with an email that already exists, PrestaShop inserts a second ps_customer row instead of merging into the existing one.
Is it safe to auto-merge duplicate customer accounts?
No. Merging customer identities means reassigning addresses, orders, cart rules, and order history to one surviving id_customer, which is destructive and order-affecting, so it is unsafe for an unattended script to do. The safe pattern is to report merge candidates for human review, and only automate a reversible soft-delete of a duplicate that has zero orders, guarded by a DRY_RUN flag.
How do I detect duplicate customer accounts through the API?
Pull every customer with GET /api/customers?display=[id,email,is_guest,deleted,date_add]&limit=0, since the webservice has no GROUP BY or HAVING, then group the results by lowercased and trimmed email yourself. Any email where more than one active row (deleted=0) exists is a merge candidate, and fetching each id's order history tells you which row is the real primary account.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: It's possible to create two customers with the same email address while transforming from guest. Issue #36421. github.com/PrestaShop/PrestaShop/issues/36421
- PrestaShop GitHub: Duplicated Customer Accounts 1.7.x.x. Issue #14980. github.com/PrestaShop/PrestaShop/issues/14980
- PrestaShop GitHub: Webservice customer api dont check if account with email already exist. Issue #14946. github.com/PrestaShop/PrestaShop/issues/14946
On the solution:
- PrestaShop Developer Documentation: Customers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/customers/
- PrestaShop Developer Documentation: Listing resources. devdocs.prestashop-project.org/1.7/webservice/tutorials/prestashop-webservice-lib/listing-resources/
- PrestaShop Developer Documentation: Getting started with the webservice. devdocs.prestashop-project.org/9/webservice/getting-started/
Stuck on a tricky one?
If you have a problem in PrestaShop customers, orders, or the webservice API 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 customer list?
If this saved you a confusing support ticket about missing order history or a customer who swears they only signed up once, 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