Skip to content

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.

Python and Node.js PrestaShop Webservice API Safe by default (report only)
A team at computers
Photo by RUT MIIT on Unsplash
The short answer

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.

First guest checkout jane@example.com, is_guest=1 ps_customer row #1 no uniqueness check ever ran Second guest checkout or transformGuestToCustomer Webservice POST/PUT customers resource, same email ps_customer row #2 same email, new id_customer No merge, no error never queried first
None of these paths look up the email before inserting, so the same person quietly ends up with two customer rows instead of one.

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:

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.

The key insight

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.

List all customers GET customers, paginated Group by normalized email lowercase, trim, done in client pick_merge_action order_count, is_guest, date_add More than 1 active? yes no, move on Report DRY_RUN No auto-merge: only a guarded soft-delete of a zero-order duplicate is ever written.
The job only ever reads and reports. Merging addresses, orders, and history into one surviving customer stays a manual, back-office step.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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
step2.js
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;
  }
}
3

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.

step3.py
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 [])
step3.js
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;
}
4

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.

decide.py
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",
    }
decide.js
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",
  };
}
5

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.

softdelete.py
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()
softdelete.js
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();
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
check_duplicate_customers.py
"""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()
check-duplicate-customers.js
/**
 * 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.

test_duplicate_customers.py
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) == ""
duplicate-customers.test.js
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

Repeat guest checkout

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.

Guest-to-account conversion

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.

What good looks like

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:

  1. 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
  2. PrestaShop GitHub: Duplicated Customer Accounts 1.7.x.x. Issue #14980. github.com/PrestaShop/PrestaShop/issues/14980
  3. PrestaShop GitHub: Webservice customer api dont check if account with email already exist. Issue #14946. github.com/PrestaShop/PrestaShop/issues/14946

On the solution:

  1. PrestaShop Developer Documentation: Customers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/customers/
  2. PrestaShop Developer Documentation: Listing resources. devdocs.prestashop-project.org/1.7/webservice/tutorials/prestashop-webservice-lib/listing-resources/
  3. 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.

Contact me on LinkedIn

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

Back to all PrestaShop field notes