Reconciler Customers and Promotions

Duplicate guest customer accounts accumulate per sales channel

The same shopper checks out as a guest five times over six months, and Shopware quietly creates five separate customer records for the same email. No error, no warning, just a customer table that keeps growing with rows that all belong to the same person. Here is why Shopware never deduplicates guest checkouts and a script that finds every duplicate group without touching a single order.

Python and Node.js Admin API Report only (dry run)
The short answer

Shopware 6 does not treat a guest checkout email as a unique key. Every anonymous order creates a new customer row with guest set to true, regardless of whether that same email already placed a prior guest order. The only email uniqueness check Shopware ships, core.loginRegistration.isCustomerBoundToSalesChannel, applies only to registered customers, and even then it is scoped per sales channel through boundSalesChannelId, so it never stops guest duplicates. Authenticate with POST /api/oauth/token, then POST /api/search/customer filtered to guest: true with a terms aggregation on email, and group the results by email and salesChannelId with a pure function. Any group with more than one customer id is a duplicate. Never auto-merge or delete: report the groups, and only flag the surplus records with active: false once a human confirms. Full code, tests, and sources are below.

The problem in plain words

When a shopper checks out without an account, Shopware still needs somewhere to attach the order, the address, and the invoice. It solves that by creating a customer record on the spot and marking it guest: true. That part works fine for a single order.

The trouble starts the second time the same person checks out as a guest. Shopware has no rule that says "an email that already has a guest customer record should reuse it." It just runs the same checkout logic again and creates another customer row, another guest: true, tied to the same email, in the same sales channel. Do that five times and you have five customer records for one shopper, each with exactly one order, and nothing links them together except an email address a human would have to notice.

Same shopper same email, guest checkout no email check customer #1 guest: true, order #1001 customer #2 guest: true, order #1042 customer #3 guest: true, order #1098 3 customer rows 1 shopper, same email, same channel
Nothing tells Shopware these three checkouts belong to the same person. Each guest order gets its own fresh customer record.

Why it happens

The gap is not a bug in one function, it is a deliberate scoping decision that Shopware only half applied. A few things line up to produce it:

See the citations at the end for the GitHub issue and community threads where store owners describe the exact same accumulation.

The key insight

A duplicate guest customer almost always has exactly one linked order, because that is the whole reason it was created. That signature makes detection reliable: bucket guest customers by email within a sales channel, and any bucket with more than one row is a duplicate group. Confirming each candidate has exactly one order tells you it is a genuine guest-per-checkout duplicate rather than something else entirely, and the earliest record with an order is the natural one to treat as canonical.

The fix, as a flow

We never merge or delete anything automatically. The script searches guest customers, groups them by email and sales channel with a pure function, and reports every duplicate group with a recommended keeper. Only when a human confirms and flips DRY_RUN off does it write anything, and even then the write is a safe flag, never a merge of order history.

Search customers guest: true, with order count groupDuplicateGuestCustomers group by email + salesChannelId Group size > 1? yes, duplicate Report keepId + duplicateIds no, unique Nothing to do human confirms, DRY_RUN=false PATCH surplus customers active: false, never DELETE
Duplicate groups are always reported first. The only write, gated behind a human confirmation, deactivates the surplus records without touching a single order.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for searching customers and for patching the surplus records later.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Search guest customers with their order count

Search customer filtered to guest: true, asking for id, email, salesChannelId, boundSalesChannelId, and createdAt. Page through with page and a limit of 500. For each customer, a small follow-up search on order-customer filtered by customerId gives the order count the decision function needs to confirm the guest-per-order signature.

step3.py
def page_guest_customers(token, page):
    body = {
        "page": page,
        "limit": 500,
        "filter": [{"type": "equals", "field": "guest", "value": True}],
        "sort": [{"field": "email", "order": "ASC"}],
        "total-count-mode": 1,
        "associations": {},
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/customer",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]

def order_count_for_customer(token, customer_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "customerId", "value": customer_id}],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order-customer",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["total"]
step3.js
async function pageGuestCustomers(token, page) {
  const body = {
    page,
    limit: 500,
    filter: [{ type: "equals", field: "guest", value: true }],
    sort: [{ field: "email", order: "ASC" }],
    "total-count-mode": 1,
    associations: {},
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/customer`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on customer search`);
  const data = await res.json();
  return data.data;
}

async function orderCountForCustomer(token, customerId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "customerId", value: customerId }],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order-customer`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order-customer search`);
  const data = await res.json();
  return data.total;
}
4

Group and decide, with one pure function

Keep the grouping in its own function that takes the full list of guest customers, each with an order count already attached, and returns only the duplicate groups. It filters to guest === true, groups by lowercase email plus salesChannelId, and within any group larger than one picks the customer with an order and the earliest createdAt as the keeper, falling back to the plain earliest record if none has an order yet.

decide.py
def group_duplicate_guest_customers(customers):
    groups = {}
    for c in customers:
        if not c.get("guest"):
            continue
        key = (c["email"].lower(), c["salesChannelId"])
        groups.setdefault(key, []).append(c)

    duplicates = []
    for (email, sales_channel_id), members in groups.items():
        if len(members) < 2:
            continue
        with_orders = [m for m in members if m.get("orderCount", 0) > 0]
        pool = with_orders if with_orders else members
        keeper = min(pool, key=lambda m: m["createdAt"])
        duplicate_ids = [m["id"] for m in members if m["id"] != keeper["id"]]
        duplicates.append({
            "email": email,
            "salesChannelId": sales_channel_id,
            "keepId": keeper["id"],
            "duplicateIds": duplicate_ids,
        })
    return duplicates
decide.js
export function groupDuplicateGuestCustomers(customers) {
  const groups = new Map();
  for (const c of customers) {
    if (!c.guest) continue;
    const key = `${c.email.toLowerCase()}|${c.salesChannelId}`;
    const members = groups.get(key) || [];
    members.push(c);
    groups.set(key, members);
  }

  const duplicates = [];
  for (const [key, members] of groups) {
    if (members.length < 2) continue;
    const [email, salesChannelId] = key.split("|");
    const withOrders = members.filter((m) => (m.orderCount || 0) > 0);
    const pool = withOrders.length ? withOrders : members;
    const keeper = pool.reduce((a, b) => (a.createdAt <= b.createdAt ? a : b));
    const duplicateIds = members.filter((m) => m.id !== keeper.id).map((m) => m.id);
    duplicates.push({ email, salesChannelId, keepId: keeper.id, duplicateIds });
  }
  return duplicates;
}
5

Flag the surplus records, never delete or merge

There is no single Admin API action that safely merges a customer's orders, addresses, and newsletter recipient rows into another customer, because orderCustomer is an immutable snapshot per order. So the only write is PATCH /api/customer/{id} to set active: false and a custom field pointing at the keeper, gated behind DRY_RUN and a human decision.

apply.py
def flag_duplicate_customer(token, customer_id, keep_id):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/customer/{customer_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json={"active": False, "customFields": {"duplicateOfCustomerId": keep_id}},
        timeout=30,
    )
    r.raise_for_status()
apply.js
async function flagDuplicateCustomer(token, customerId, keepId) {
  const res = await fetch(`${SHOPWARE_URL}/api/customer/${customerId}`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({ active: false, customFields: { duplicateOfCustomerId: keepId } }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on customer patch`);
}
6

Wire it together with a dry run guard

The loop ties every piece together. Page every guest customer, attach its order count, run the pure grouping function, and log every duplicate group with its recommended keeper for manual review. Only when DRY_RUN is off does it patch the surplus records inactive with the audit trail custom field. Not one order is touched at any point.

Run it safe

Never merge or delete a customer record while orders reference it. Deleting would either be blocked by the foreign key or cascade-null real order history. Start with DRY_RUN=true, review every reported group and its recommended keeper, and only let the script flag the surplus records inactive once a human has confirmed the grouping is right.

The full code

Here is the complete script in one file for each language. It authenticates, pages every guest customer with its order count, groups duplicates with the pure function, reports every group, and flags the surplus records inactive with an audit trail, respecting DRY_RUN.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

group_duplicate_guest_customers.py
"""Detect and report duplicate Shopware 6 guest customer accounts, without ever
auto-merging or deleting a customer record.

Shopware 6 checkout does not treat a guest email as a unique key. Every anonymous
order creates a new customer row with guest set to true, regardless of whether that
email already placed a prior guest order, because the only email uniqueness check
Shopware ships, core.loginRegistration.isCustomerBoundToSalesChannel, applies only to
registered customers and is scoped per sales channel through boundSalesChannelId. When
that setting is off, the same email can spawn unbounded guest customers across every
sales channel.

This pages every guest customer with its order count, groups them by lowercase email
plus salesChannelId with a pure function, and reports every group with more than one
customer id, along with a recommended keeper: the customer with an order and the
earliest createdAt. Existing duplicates are only ever reported for manual review. The
one safe automated write, gated by DRY_RUN, PATCHes each surplus customer to
active: false with a customFields.duplicateOfCustomerId audit trail. It never DELETEs
a customer or rewrites order history, since orderCustomer is an immutable per-order
snapshot with no supported merge action. Run on demand. Safe to run again and again.
"""
import os
import logging
import requests

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

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAGE_LIMIT = 500


def group_duplicate_guest_customers(customers):
    """Pure decision. No I/O. Filters to guest customers, groups by lowercase
    email plus salesChannelId, and returns only groups with more than one
    customer id, each with a recommended keepId and the remaining duplicateIds.
    """
    groups = {}
    for c in customers:
        if not c.get("guest"):
            continue
        key = (c["email"].lower(), c["salesChannelId"])
        groups.setdefault(key, []).append(c)

    duplicates = []
    for (email, sales_channel_id), members in groups.items():
        if len(members) < 2:
            continue
        with_orders = [m for m in members if m.get("orderCount", 0) > 0]
        pool = with_orders if with_orders else members
        keeper = min(pool, key=lambda m: m["createdAt"])
        duplicate_ids = [m["id"] for m in members if m["id"] != keeper["id"]]
        duplicates.append({
            "email": email,
            "salesChannelId": sales_channel_id,
            "keepId": keeper["id"],
            "duplicateIds": duplicate_ids,
        })
    return duplicates


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def page_guest_customers(token, page):
    body = {
        "page": page,
        "limit": PAGE_LIMIT,
        "filter": [{"type": "equals", "field": "guest", "value": True}],
        "sort": [{"field": "email", "order": "ASC"}],
        "total-count-mode": 1,
        "associations": {},
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/customer",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def order_count_for_customer(token, customer_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "customerId", "value": customer_id}],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order-customer",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["total"]


def all_guest_customers(token):
    page = 1
    while True:
        rows = page_guest_customers(token, page)
        for row in rows:
            yield {
                "id": row["id"],
                "email": row["email"],
                "guest": row["guest"],
                "salesChannelId": row["salesChannelId"],
                "boundSalesChannelId": row.get("boundSalesChannelId"),
                "createdAt": row["createdAt"],
                "orderCount": order_count_for_customer(token, row["id"]),
            }
        if len(rows) < PAGE_LIMIT:
            return
        page += 1


def flag_duplicate_customer(token, customer_id, keep_id):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/customer/{customer_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json={"active": False, "customFields": {"duplicateOfCustomerId": keep_id}},
        timeout=30,
    )
    r.raise_for_status()


def run():
    token = get_token()
    customers = list(all_guest_customers(token))
    duplicates = group_duplicate_guest_customers(customers)

    if not duplicates:
        log.info("Done. 0 duplicate guest customer group(s) found across %d guest(s).", len(customers))
        return

    flagged = 0
    for group in duplicates:
        log.warning(
            "Duplicate guest email %s in sales channel %s: keep %s, %d surplus record(s) %s",
            group["email"], group["salesChannelId"], group["keepId"], len(group["duplicateIds"]),
            group["duplicateIds"],
        )
        for dup_id in group["duplicateIds"]:
            log.warning("%s customer %s inactive (duplicate of %s)",
                        "Would flag" if DRY_RUN else "Flagging", dup_id, group["keepId"])
            if not DRY_RUN:
                flag_duplicate_customer(token, dup_id, group["keepId"])
            flagged += 1

    log.info("Done. %d duplicate group(s) found, %d surplus record(s) %s.",
              len(duplicates), flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
group-duplicate-guest-customers.js
/**
 * Detect and report duplicate Shopware 6 guest customer accounts, without ever
 * auto-merging or deleting a customer record.
 *
 * Shopware 6 checkout does not treat a guest email as a unique key. Every anonymous
 * order creates a new customer row with guest set to true, regardless of whether that
 * email already placed a prior guest order, because the only email uniqueness check
 * Shopware ships, core.loginRegistration.isCustomerBoundToSalesChannel, applies only to
 * registered customers and is scoped per sales channel through boundSalesChannelId.
 * When that setting is off, the same email can spawn unbounded guest customers across
 * every sales channel.
 *
 * This pages every guest customer with its order count, groups them by lowercase
 * email plus salesChannelId with a pure function, and reports every group with more
 * than one customer id, along with a recommended keeper: the customer with an order
 * and the earliest createdAt. Existing duplicates are only ever reported for manual
 * review. The one safe automated write, gated by DRY_RUN, PATCHes each surplus
 * customer to active: false with a customFields.duplicateOfCustomerId audit trail. It
 * never DELETEs a customer or rewrites order history, since orderCustomer is an
 * immutable per-order snapshot with no supported merge action. Run on demand.
 *
 * Guide: https://www.allanninal.dev/shopware/duplicate-guest-customer-accounts/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAGE_LIMIT = 500;

export function groupDuplicateGuestCustomers(customers) {
  const groups = new Map();
  for (const c of customers) {
    if (!c.guest) continue;
    const key = `${c.email.toLowerCase()}|${c.salesChannelId}`;
    const members = groups.get(key) || [];
    members.push(c);
    groups.set(key, members);
  }

  const duplicates = [];
  for (const [key, members] of groups) {
    if (members.length < 2) continue;
    const [email, salesChannelId] = key.split("|");
    const withOrders = members.filter((m) => (m.orderCount || 0) > 0);
    const pool = withOrders.length ? withOrders : members;
    const keeper = pool.reduce((a, b) => (a.createdAt <= b.createdAt ? a : b));
    const duplicateIds = members.filter((m) => m.id !== keeper.id).map((m) => m.id);
    duplicates.push({ email, salesChannelId, keepId: keeper.id, duplicateIds });
  }
  return duplicates;
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function pageGuestCustomers(token, page) {
  const body = {
    page,
    limit: PAGE_LIMIT,
    filter: [{ type: "equals", field: "guest", value: true }],
    sort: [{ field: "email", order: "ASC" }],
    "total-count-mode": 1,
    associations: {},
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/customer`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on customer search`);
  const data = await res.json();
  return data.data;
}

async function orderCountForCustomer(token, customerId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "customerId", value: customerId }],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order-customer`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order-customer search`);
  const data = await res.json();
  return data.total;
}

async function* allGuestCustomers(token) {
  let page = 1;
  while (true) {
    const rows = await pageGuestCustomers(token, page);
    for (const row of rows) {
      yield {
        id: row.id,
        email: row.email,
        guest: row.guest,
        salesChannelId: row.salesChannelId,
        boundSalesChannelId: row.boundSalesChannelId ?? null,
        createdAt: row.createdAt,
        orderCount: await orderCountForCustomer(token, row.id),
      };
    }
    if (rows.length < PAGE_LIMIT) return;
    page++;
  }
}

async function flagDuplicateCustomer(token, customerId, keepId) {
  const res = await fetch(`${SHOPWARE_URL}/api/customer/${customerId}`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({ active: false, customFields: { duplicateOfCustomerId: keepId } }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on customer patch`);
}

export async function run() {
  const token = await getToken();
  const customers = [];
  for await (const c of allGuestCustomers(token)) customers.push(c);

  const duplicates = groupDuplicateGuestCustomers(customers);

  if (!duplicates.length) {
    console.log(`Done. 0 duplicate guest customer group(s) found across ${customers.length} guest(s).`);
    return;
  }

  let flagged = 0;
  for (const group of duplicates) {
    console.warn(`Duplicate guest email ${group.email} in sales channel ${group.salesChannelId}: keep ${group.keepId}, ${group.duplicateIds.length} surplus record(s) ${JSON.stringify(group.duplicateIds)}`);
    for (const dupId of group.duplicateIds) {
      console.warn(`${DRY_RUN ? "Would flag" : "Flagging"} customer ${dupId} inactive (duplicate of ${group.keepId})`);
      if (!DRY_RUN) await flagDuplicateCustomer(token, dupId, group.keepId);
      flagged++;
    }
  }

  console.log(`Done. ${duplicates.length} duplicate group(s) found, ${flagged} surplus record(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

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

Add a test

The grouping function is the part most worth testing, because it decides which customer records get reported as duplicates and which one gets recommended as the keeper. Because group_duplicate_guest_customers is pure, the test needs no network and no live Shopware store, just plain fixture arrays.

test_duplicate_guest_customers.py
from group_duplicate_guest_customers import group_duplicate_guest_customers


def customer(**over):
    base = {
        "id": "cust-1",
        "email": "shopper@example.com",
        "guest": True,
        "salesChannelId": "channel-a",
        "createdAt": "2026-01-01T10:00:00Z",
        "orderCount": 1,
    }
    base.update(over)
    return base


def test_no_duplicates_returns_empty_list():
    customers = [
        customer(id="cust-1", email="a@example.com"),
        customer(id="cust-2", email="b@example.com"),
    ]
    assert group_duplicate_guest_customers(customers) == []


def test_same_email_same_channel_is_a_duplicate_group():
    customers = [
        customer(id="cust-1", createdAt="2026-01-01T10:00:00Z"),
        customer(id="cust-2", createdAt="2026-03-01T10:00:00Z"),
    ]
    result = group_duplicate_guest_customers(customers)
    assert result == [{
        "email": "shopper@example.com",
        "salesChannelId": "channel-a",
        "keepId": "cust-1",
        "duplicateIds": ["cust-2"],
    }]


def test_email_comparison_is_case_insensitive():
    customers = [
        customer(id="cust-1", email="Shopper@Example.com", createdAt="2026-01-01T10:00:00Z"),
        customer(id="cust-2", email="shopper@example.com", createdAt="2026-02-01T10:00:00Z"),
    ]
    result = group_duplicate_guest_customers(customers)
    assert result[0]["duplicateIds"] == ["cust-2"]


def test_same_email_different_sales_channel_is_not_grouped_together():
    customers = [
        customer(id="cust-1", salesChannelId="channel-a"),
        customer(id="cust-2", salesChannelId="channel-b"),
    ]
    assert group_duplicate_guest_customers(customers) == []


def test_non_guest_customers_are_excluded():
    customers = [
        customer(id="cust-1", guest=False),
        customer(id="cust-2", guest=True),
    ]
    assert group_duplicate_guest_customers(customers) == []


def test_keeper_prefers_earliest_record_with_an_order():
    customers = [
        customer(id="cust-1", createdAt="2026-01-01T10:00:00Z", orderCount=0),
        customer(id="cust-2", createdAt="2026-02-01T10:00:00Z", orderCount=1),
        customer(id="cust-3", createdAt="2026-03-01T10:00:00Z", orderCount=1),
    ]
    result = group_duplicate_guest_customers(customers)
    assert result[0]["keepId"] == "cust-2"
    assert sorted(result[0]["duplicateIds"]) == ["cust-1", "cust-3"]


def test_falls_back_to_earliest_created_at_when_none_have_orders():
    customers = [
        customer(id="cust-1", createdAt="2026-02-01T10:00:00Z", orderCount=0),
        customer(id="cust-2", createdAt="2026-01-01T10:00:00Z", orderCount=0),
    ]
    result = group_duplicate_guest_customers(customers)
    assert result[0]["keepId"] == "cust-2"
duplicate-guest-customers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { groupDuplicateGuestCustomers } from "./group-duplicate-guest-customers.js";

const customer = (over = {}) => ({
  id: "cust-1",
  email: "shopper@example.com",
  guest: true,
  salesChannelId: "channel-a",
  createdAt: "2026-01-01T10:00:00Z",
  orderCount: 1,
  ...over,
});

test("no duplicates returns empty list", () => {
  const customers = [
    customer({ id: "cust-1", email: "a@example.com" }),
    customer({ id: "cust-2", email: "b@example.com" }),
  ];
  assert.deepEqual(groupDuplicateGuestCustomers(customers), []);
});

test("same email same channel is a duplicate group", () => {
  const customers = [
    customer({ id: "cust-1", createdAt: "2026-01-01T10:00:00Z" }),
    customer({ id: "cust-2", createdAt: "2026-03-01T10:00:00Z" }),
  ];
  const result = groupDuplicateGuestCustomers(customers);
  assert.deepEqual(result, [{
    email: "shopper@example.com",
    salesChannelId: "channel-a",
    keepId: "cust-1",
    duplicateIds: ["cust-2"],
  }]);
});

test("email comparison is case insensitive", () => {
  const customers = [
    customer({ id: "cust-1", email: "Shopper@Example.com", createdAt: "2026-01-01T10:00:00Z" }),
    customer({ id: "cust-2", email: "shopper@example.com", createdAt: "2026-02-01T10:00:00Z" }),
  ];
  const result = groupDuplicateGuestCustomers(customers);
  assert.deepEqual(result[0].duplicateIds, ["cust-2"]);
});

test("same email different sales channel is not grouped together", () => {
  const customers = [
    customer({ id: "cust-1", salesChannelId: "channel-a" }),
    customer({ id: "cust-2", salesChannelId: "channel-b" }),
  ];
  assert.deepEqual(groupDuplicateGuestCustomers(customers), []);
});

test("non guest customers are excluded", () => {
  const customers = [
    customer({ id: "cust-1", guest: false }),
    customer({ id: "cust-2", guest: true }),
  ];
  assert.deepEqual(groupDuplicateGuestCustomers(customers), []);
});

test("keeper prefers earliest record with an order", () => {
  const customers = [
    customer({ id: "cust-1", createdAt: "2026-01-01T10:00:00Z", orderCount: 0 }),
    customer({ id: "cust-2", createdAt: "2026-02-01T10:00:00Z", orderCount: 1 }),
    customer({ id: "cust-3", createdAt: "2026-03-01T10:00:00Z", orderCount: 1 }),
  ];
  const result = groupDuplicateGuestCustomers(customers);
  assert.equal(result[0].keepId, "cust-2");
  assert.deepEqual(result[0].duplicateIds.sort(), ["cust-1", "cust-3"]);
});

test("falls back to earliest createdAt when none have orders", () => {
  const customers = [
    customer({ id: "cust-1", createdAt: "2026-02-01T10:00:00Z", orderCount: 0 }),
    customer({ id: "cust-2", createdAt: "2026-01-01T10:00:00Z", orderCount: 0 }),
  ];
  const result = groupDuplicateGuestCustomers(customers);
  assert.equal(result[0].keepId, "cust-2");
});

Case studies

Single channel

The repeat shopper who never made an account

A homeware store noticed its customer count kept climbing far faster than its actual audience. Digging into the data, the same handful of email addresses appeared over and over, each with its own customer row and exactly one order, because a segment of loyal buyers simply preferred not to register and kept checking out as guests.

Running the detection script surfaced dozens of groups, some with six or seven duplicate rows for a single shopper across a year of orders. Marketing had been under-counting repeat purchase rate the whole time, since every guest order looked like a new customer. The report gave them the real picture, and the surplus records were flagged inactive with the audit trail field once someone confirmed each group by hand.

Multi channel, bound off

The B2B and B2C storefronts sharing an email pool

A distributor ran a B2C storefront and a separate B2B sales channel from the same Shopware instance, with isCustomerBoundToSalesChannel turned off so staff could use one login everywhere. Guest checkouts on both channels for the same procurement email produced customer rows in both, and the grouping only ever matched within one salesChannelId, so nobody had noticed the cross-channel spread until the report was run per channel and then compared by hand.

The team used the per-channel groups to confirm the pattern, matched the email across channels manually since that comparison is a genuine business decision, not a mechanical one, and left every order untouched. The distributor changed its onboarding flow to nudge repeat B2B buyers toward a real account instead of guest checkout, which stopped new duplicates from forming going forward.

What good looks like

After this runs, a pile of look-alike guest customers becomes a clear list of groups with a recommended keeper, ready for a human to confirm. Nothing is merged or deleted automatically, no order loses its history, and the surplus records that do get flagged carry a custom field that says exactly which customer they duplicate. Reporting is the deliverable here, since real deduplication is a business decision about which record and which orders matter, not something a script should decide alone.

FAQ

Why does Shopware create a new guest customer for the same email every time?

Shopware 6 checkout never checks whether a guest email already placed an order before creating the customer record. The uniqueness check that exists, core.loginRegistration.isCustomerBoundToSalesChannel, only applies to registered accounts, and even then it is scoped per sales channel through boundSalesChannelId. Guest checkout skips that check entirely, so the same shopper checking out as a guest gets a brand new customer row with guest set to true on every order, in the same channel or across every channel if the bound setting is off.

Is it safe to automatically merge duplicate guest customers in Shopware?

No. Merging guest customers safely means re-pointing every order's customer association, its address book entries, and any newsletter recipient records, and Shopware does not expose that as a single idempotent Admin API action. An order's orderCustomer is an immutable snapshot taken at checkout, so there is no supported PATCH that reassigns an order to a different merged customer. The safe response is to detect and report duplicate groups for a human to review, not to auto-merge or delete.

What is the safe remediation once duplicate guest customers are confirmed?

Never DELETE a customer record that orders still reference, since the foreign key will either block the delete or cascade-null the order history. The safe pattern is to keep the oldest customer record that has an order as the canonical one, then PATCH each surplus customer to set active to false and add a custom field such as customFields.duplicateOfCustomerId pointing at the keeper, so there is an audit trail without breaking a single existing order-to-customer link.

Related field notes

Citations

On the problem:

  1. E-mail from customer account can be used again for guest orders, Issue #9398, shopware/shopware. github.com/shopware/shopware/issues/9398
  2. Duplicate guest accounts, Shopware Community Forum. forum.shopware.com/t/duplicate-guest-accounts/96565
  3. Clean up guest accounts after order is proceeded, Shopware Feedback. feedback.shopware.com clean-up-guest-accounts-after-order-is-proceeded

On the solution:

  1. Search Criteria, filters and aggregations, Shopware Developer Docs. developer.shopware.com/docs/guides/integrations-api/general-concepts/search-criteria.html
  2. Aggregations Reference, Shopware Developer Docs. developer.shopware.com/docs/resources/references/core-reference/dal-reference/aggregations-reference.html
  3. Entity Reference, Admin API, Stoplight. shopware.stoplight.io/docs/admin-api/df3c78c1fcd07-entity-reference

Stuck on a tricky one?

If you have a problem in Shopware orders, customers, promotions, or data hygiene 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 clean up your customer list?

If this saved you from a bloated customer table or a wrong repeat purchase number, 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 Shopware field notes