Diagnostic Customers and Promotions

Migrated customers end up with multiple accounts across sales channels

A store migrates from Shopware 5 or a third party platform like Magento into a Shopware 6 setup with several sales channels, and support starts hearing the same complaint: a returning customer logs in and their old orders are gone, or they cannot log in at all because Shopware is not sure which account is theirs. The email is right there in the system, more than once. Here is why the migration split one customer into several rows and a script that finds every split account so a human can decide what to do with it.

Python and Node.js Admin API Report only (no auto-merge)
The short answer

Whether a customer email has to be unique across the whole store or only within one sales channel is controlled by core.systemWideLoginRegistration.isCustomerBoundToSalesChannel, and it is enforced through CustomerEmailUniqueChecker against the customer.boundSalesChannelId column. When data is migrated into several sales channels while that setting is off or applied inconsistently, each channel's import runs on its own and creates a separate customer row for the same email, with boundSalesChannelId ending up null or different across the rows. A small Python or Node.js script searches customer across all channels, groups rows by lowercased email, and flags any email where the boundSalesChannelId values disagree. It never merges anything, it only produces a dry run report naming a suggested primary account and the duplicates for a human to review. Full code, tests, and sources are below.

The problem in plain words

In Shopware 6, a customer account is just a row in the customer table with an email address. Whether that email must be unique across the entire store, or only unique inside one sales channel, is a single setting: Bind customers to Sales Channel, under Login and Registration in the Shopware Administration. Turn it on, and Shopware enforces one account per email per channel. Leave it off, and Shopware enforces one account per email everywhere.

A migration does not usually respect that distinction. Data coming from Shopware 5 or from Magento gets imported sales channel by sales channel, and each import run only knows about its own channel. If the same customer bought from what became two different Shopware 6 sales channels, and the binding setting was off or was toggled partway through the project, the importer has no reason to notice the email already exists in another channel's data. It creates a new row. The result is one email address, several customer ids, and a boundSalesChannelId column that is null on some rows and pointing at a specific channel on others, an inconsistency documented in Shopware's own issue tracker.

Same customer one email address Import: Channel A runs on its own Import: Channel B runs on its own customer row 1 boundSalesChannelId null customer row 2 boundSalesChannelId = B Login cannot pick one
Two independent per-channel imports each create their own customer row for the same email, with boundSalesChannelId mismatched between them, so Shopware has no single account to log the customer into.

Why it happens

The setting and the column exist for a legitimate reason, they let a marketplace-style store keep the same email as separate customers per brand. Migrations end up on the wrong side of that feature for a few common reasons:

This is a known rough edge, not a one-off bug. Shopware's own issue tracker has reports of contacts staying bound to every sales channel despite the binding option, and of the commercial Bind to Sales Channel extension throwing an unexpected email already in use error once the data underneath it is inconsistent. See the citations at the end for the exact threads.

The key insight

Finding a duplicate and fixing a duplicate are two different levels of risk. Detecting the split is a read-only grouping problem: pull every customer row, group by email, and compare boundSalesChannelId. Actually merging two accounts means moving orders, addresses, newsletter recipients, and wishlists from one customer id onto another, and getting that wrong loses order history permanently. So the script only ever reports. A human decides, and the only automated write it offers is re-scoping a single stray row's boundSalesChannelId, one id at a time, behind an explicit confirmation.

The fix, as a flow

The script pages through every customer, keeps the real accounts, groups them by lowercased email, and hands each group to a pure decision function. When that function sees mixed or missing boundSalesChannelId values in a group, it names a suggested primary, based on which row actually has order history, and lists the rest as duplicates with a reason. The script logs that as a dry run report. Nothing is merged automatically.

Scheduled job runs on a timer Search customer all sales channels, group by email classifyCustomerDuplicates pure decision function no I/O, just grouping Bound ids disagree? yes no, not a duplicate Log dry run report primary + duplicate ids
The script only ever reads customer data and reasons about the groups. When it finds a real split, it logs a report naming the suggested primary and the duplicate ids. It never merges anything on its own.

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 PAGE_SIZE="500"    # customers pulled per search page
export DRY_RUN="true"     # start safe, change to false only for one guarded PATCH
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 PAGE_SIZE="500"    // customers pulled per search page
export DRY_RUN="true"     // start safe, change to false only for one guarded PATCH
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 the customer search and for the single guarded PATCH.

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, payload):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=payload,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
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, payload) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  return res.json();
}
3

Pull every customer across all sales channels

Search the customer entity with the boundSalesChannel association, paging with page and a fixed limit. Read id, email, salesChannelId, boundSalesChannelId, active, guest, and createdAt. We need order counts too, so for any email that turns out to have more than one row, a second call fetches each customer with its orders association to count real order history.

step3.py
def all_customers(token, page_size=500):
    page = 1
    while True:
        body = api_post(
            "/api/search/customer",
            token,
            {
                "page": page,
                "limit": page_size,
                "filter": [],
                "sort": [{"field": "email", "order": "ASC"}],
                "associations": {"boundSalesChannel": {}},
                "total-count-mode": 1,
            },
        )
        rows = body["data"]
        for row in rows:
            yield row
        if len(rows) < page_size:
            return
        page += 1

def order_count_for(token, customer_id):
    r = requests.get(
        f"{SHOPWARE_URL}/api/customer/{customer_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        params={"associations[orders][limit]": 1, "associations[orders][total-count-mode]": 1},
        timeout=30,
    )
    r.raise_for_status()
    meta = r.json().get("data", {}).get("orders", {}).get("meta") or {}
    return int(meta.get("totalCountMode") == 1 and meta.get("total", 0) or meta.get("total", 0) or 0)
step3.js
async function* allCustomers(token, pageSize = 500) {
  let page = 1;
  while (true) {
    const body = await apiPost("/api/search/customer", token, {
      page,
      limit: pageSize,
      filter: [],
      sort: [{ field: "email", order: "ASC" }],
      associations: { boundSalesChannel: {} },
      "total-count-mode": 1,
    });
    for (const row of body.data) yield row;
    if (body.data.length < pageSize) return;
    page += 1;
  }
}

async function orderCountFor(token, customerId) {
  const url = new URL(`${SHOPWARE_URL}/api/customer/${customerId}`);
  url.searchParams.set("associations[orders][limit]", "1");
  url.searchParams.set("associations[orders][total-count-mode]", "1");
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on customer ${customerId}`);
  const body = await res.json();
  const meta = body?.data?.orders?.meta || {};
  return Number(meta.total || 0);
}
4

Decide, with one pure function

Keep the decision separate from any network call. Given plain customer row objects, one function groups them by lowercased, trimmed email, ignores guest rows, and for every group of more than one it checks whether boundSalesChannelId disagrees across the rows. When it does, it names the row with the most orders as the primary, breaking ties by the earliest createdAt, and returns the rest as duplicates with a reason string.

decide.py
def classify_customer_duplicates(customer_rows):
    groups = {}
    for row in customer_rows:
        if row.get("guest"):
            continue
        email = (row.get("email") or "").strip().lower()
        if not email:
            continue
        groups.setdefault(email, []).append(row)

    results = []
    for email, rows in groups.items():
        if len(rows) < 2:
            continue
        bound_ids = {row.get("boundSalesChannelId") for row in rows}
        if len(bound_ids) <= 1:
            continue

        ranked = sorted(
            rows,
            key=lambda r: (-int(r.get("orderCount") or 0), r.get("createdAt") or ""),
        )
        primary = ranked[0]
        duplicates = [r["id"] for r in ranked[1:]]

        if None in bound_ids and len(bound_ids) > 1:
            reason = "unbound-plus-bound-conflict"
        else:
            reason = "cross-channel-split"

        results.append({
            "primaryId": primary["id"],
            "duplicateIds": duplicates,
            "reason": reason,
        })

    return results
decide.js
export function classifyCustomerDuplicates(customerRows) {
  const groups = new Map();
  for (const row of customerRows) {
    if (row.guest) continue;
    const email = (row.email || "").trim().toLowerCase();
    if (!email) continue;
    if (!groups.has(email)) groups.set(email, []);
    groups.get(email).push(row);
  }

  const results = [];
  for (const rows of groups.values()) {
    if (rows.length < 2) continue;
    const boundIds = new Set(rows.map((r) => r.boundSalesChannelId ?? null));
    if (boundIds.size <= 1) continue;

    const ranked = [...rows].sort((a, b) => {
      const byOrders = (b.orderCount || 0) - (a.orderCount || 0);
      if (byOrders !== 0) return byOrders;
      return (a.createdAt || "").localeCompare(b.createdAt || "");
    });
    const primary = ranked[0];
    const duplicateIds = ranked.slice(1).map((r) => r.id);

    const reason = boundIds.has(null) && boundIds.size > 1
      ? "unbound-plus-bound-conflict"
      : "cross-channel-split";

    results.push({ primaryId: primary.id, duplicateIds, reason });
  }

  return results;
}
5

Log a dry run report, never merge automatically

For every group the pure function flags, log the suggested primary id, the duplicate ids, the reason, and each row's order count so a support workflow can review it. The only write this script ever offers is re-scoping a single approved duplicate's boundSalesChannelId with PATCH /api/customer/{duplicateId}, one id at a time, and only when DRY_RUN is explicitly set to false. Actually moving orders and addresses between customer ids is out of scope for a generic script and belongs in Shopware's migration or merge tooling.

apply.py
def rebind_duplicate(token, duplicate_id, canonical_sales_channel_id, dry_run=True):
    body = {"boundSalesChannelId": canonical_sales_channel_id}
    if dry_run:
        log.info("DRY_RUN: would PATCH /api/customer/%s with %s", duplicate_id, body)
        return
    r = requests.patch(
        f"{SHOPWARE_URL}/api/customer/{duplicate_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
apply.js
async function rebindDuplicate(token, duplicateId, canonicalSalesChannelId, dryRun = true) {
  const body = { boundSalesChannelId: canonicalSalesChannelId };
  if (dryRun) {
    console.log(`DRY_RUN: would PATCH /api/customer/${duplicateId} with ${JSON.stringify(body)}`);
    return;
  }
  const res = await fetch(`${SHOPWARE_URL}/api/customer/${duplicateId}`, {
    method: "PATCH",
    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 ${duplicateId} PATCH`);
}
Run it safe

Keep DRY_RUN=true for every normal run. The script's real job is the report: primary id, duplicate ids, the reason, and order counts, so a human decides what happens next. Only flip DRY_RUN off for the narrow, explicitly approved case of re-scoping one stray unbound duplicate's boundSalesChannelId, never for a bulk pass, and never as a way to merge orders or addresses.

The full code

Here is the complete script in one file for each language. It authenticates, pages through every customer with the boundSalesChannel association, fills in order counts for any email seen more than once, classifies duplicate clusters with the pure function, and logs a dry run report. The single guarded PATCH is included but never called automatically.

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

report_customer_duplicates.py
"""Report Shopware 6 customers migrated into multiple accounts across sales channels.

Searches the customer entity across all sales channels, groups non-guest rows by
lowercased email, and flags any email where boundSalesChannelId disagrees between
rows. This is the signature of a per-channel migration import that never checked
whether the email already existed elsewhere. The script only logs a dry run report
naming a suggested primary and the duplicate ids, it never merges anything. The only
write it offers is re-scoping one approved duplicate's boundSalesChannelId, guarded
behind DRY_RUN and run one id at a time. Run on a schedule after any migration.
"""
import os
import logging
import requests

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

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


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, payload):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=payload,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def all_customers(token, page_size=PAGE_SIZE):
    page = 1
    while True:
        body = api_post(
            "/api/search/customer",
            token,
            {
                "page": page,
                "limit": page_size,
                "filter": [],
                "sort": [{"field": "email", "order": "ASC"}],
                "associations": {"boundSalesChannel": {}},
                "total-count-mode": 1,
            },
        )
        rows = body["data"]
        for row in rows:
            yield row
        if len(rows) < page_size:
            return
        page += 1


def order_count_for(token, customer_id):
    r = requests.get(
        f"{SHOPWARE_URL}/api/customer/{customer_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        params={"associations[orders][limit]": 1, "associations[orders][total-count-mode]": 1},
        timeout=30,
    )
    r.raise_for_status()
    meta = r.json().get("data", {}).get("orders", {}).get("meta") or {}
    return int(meta.get("total", 0) or 0)


def classify_customer_duplicates(customer_rows):
    groups = {}
    for row in customer_rows:
        if row.get("guest"):
            continue
        email = (row.get("email") or "").strip().lower()
        if not email:
            continue
        groups.setdefault(email, []).append(row)

    results = []
    for email, rows in groups.items():
        if len(rows) < 2:
            continue
        bound_ids = {row.get("boundSalesChannelId") for row in rows}
        if len(bound_ids) <= 1:
            continue

        ranked = sorted(
            rows,
            key=lambda r: (-int(r.get("orderCount") or 0), r.get("createdAt") or ""),
        )
        primary = ranked[0]
        duplicates = [r["id"] for r in ranked[1:]]

        if None in bound_ids and len(bound_ids) > 1:
            reason = "unbound-plus-bound-conflict"
        else:
            reason = "cross-channel-split"

        results.append({
            "primaryId": primary["id"],
            "duplicateIds": duplicates,
            "reason": reason,
        })

    return results


def rebind_duplicate(token, duplicate_id, canonical_sales_channel_id, dry_run=True):
    body = {"boundSalesChannelId": canonical_sales_channel_id}
    if dry_run:
        log.info("DRY_RUN: would PATCH /api/customer/%s with %s", duplicate_id, body)
        return
    r = requests.patch(
        f"{SHOPWARE_URL}/api/customer/{duplicate_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()


def run():
    token = get_token()

    rows = list(all_customers(token))
    log.info("Pulled %d customer row(s) across all sales channels", len(rows))

    by_email = {}
    for row in rows:
        if row.get("guest"):
            continue
        email = (row.get("email") or "").strip().lower()
        if not email:
            continue
        by_email.setdefault(email, []).append(row)

    for email, group in by_email.items():
        if len(group) < 2:
            continue
        for row in group:
            row["orderCount"] = order_count_for(token, row["id"])

    duplicates = classify_customer_duplicates(rows)

    for entry in duplicates:
        log.warning(
            "duplicate_customer_cluster: reason=%s primary=%s duplicates=%s",
            entry["reason"], entry["primaryId"], entry["duplicateIds"],
        )

    log.info("Done. %d duplicate cluster(s) found. DRY_RUN=%s", len(duplicates), DRY_RUN)
    return duplicates


if __name__ == "__main__":
    run()
report-customer-duplicates.js
/**
 * Report Shopware 6 customers migrated into multiple accounts across sales channels.
 *
 * Searches the customer entity across all sales channels, groups non-guest rows by
 * lowercased email, and flags any email where boundSalesChannelId disagrees between
 * rows. This is the signature of a per-channel migration import that never checked
 * whether the email already existed elsewhere. The script only logs a dry run report
 * naming a suggested primary and the duplicate ids, it never merges anything. The only
 * write it offers is re-scoping one approved duplicate's boundSalesChannelId, guarded
 * behind DRY_RUN and run one id at a time. Run on a schedule after any migration.
 *
 * Guide: https://www.allanninal.dev/shopware/migrated-customer-duplicate-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 PAGE_SIZE = Number(process.env.PAGE_SIZE || 500);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function classifyCustomerDuplicates(customerRows) {
  const groups = new Map();
  for (const row of customerRows) {
    if (row.guest) continue;
    const email = (row.email || "").trim().toLowerCase();
    if (!email) continue;
    if (!groups.has(email)) groups.set(email, []);
    groups.get(email).push(row);
  }

  const results = [];
  for (const rows of groups.values()) {
    if (rows.length < 2) continue;
    const boundIds = new Set(rows.map((r) => r.boundSalesChannelId ?? null));
    if (boundIds.size <= 1) continue;

    const ranked = [...rows].sort((a, b) => {
      const byOrders = (b.orderCount || 0) - (a.orderCount || 0);
      if (byOrders !== 0) return byOrders;
      return (a.createdAt || "").localeCompare(b.createdAt || "");
    });
    const primary = ranked[0];
    const duplicateIds = ranked.slice(1).map((r) => r.id);

    const reason = boundIds.has(null) && boundIds.size > 1
      ? "unbound-plus-bound-conflict"
      : "cross-channel-split";

    results.push({ primaryId: primary.id, duplicateIds, reason });
  }

  return results;
}

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, payload) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  return res.json();
}

async function* allCustomers(token, pageSize = PAGE_SIZE) {
  let page = 1;
  while (true) {
    const body = await apiPost("/api/search/customer", token, {
      page,
      limit: pageSize,
      filter: [],
      sort: [{ field: "email", order: "ASC" }],
      associations: { boundSalesChannel: {} },
      "total-count-mode": 1,
    });
    for (const row of body.data) yield row;
    if (body.data.length < pageSize) return;
    page += 1;
  }
}

async function orderCountFor(token, customerId) {
  const url = new URL(`${SHOPWARE_URL}/api/customer/${customerId}`);
  url.searchParams.set("associations[orders][limit]", "1");
  url.searchParams.set("associations[orders][total-count-mode]", "1");
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on customer ${customerId}`);
  const body = await res.json();
  const meta = body?.data?.orders?.meta || {};
  return Number(meta.total || 0);
}

async function rebindDuplicate(token, duplicateId, canonicalSalesChannelId, dryRun = true) {
  const body = { boundSalesChannelId: canonicalSalesChannelId };
  if (dryRun) {
    console.log(`DRY_RUN: would PATCH /api/customer/${duplicateId} with ${JSON.stringify(body)}`);
    return;
  }
  const res = await fetch(`${SHOPWARE_URL}/api/customer/${duplicateId}`, {
    method: "PATCH",
    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 ${duplicateId} PATCH`);
}

export async function run() {
  const token = await getToken();

  const rows = [];
  for await (const row of allCustomers(token)) rows.push(row);
  console.log(`Pulled ${rows.length} customer row(s) across all sales channels`);

  const byEmail = new Map();
  for (const row of rows) {
    if (row.guest) continue;
    const email = (row.email || "").trim().toLowerCase();
    if (!email) continue;
    if (!byEmail.has(email)) byEmail.set(email, []);
    byEmail.get(email).push(row);
  }

  for (const group of byEmail.values()) {
    if (group.length < 2) continue;
    for (const row of group) {
      row.orderCount = await orderCountFor(token, row.id);
    }
  }

  const duplicates = classifyCustomerDuplicates(rows);

  for (const entry of duplicates) {
    console.warn(
      `duplicate_customer_cluster: reason=${entry.reason} primary=${entry.primaryId} duplicates=${JSON.stringify(entry.duplicateIds)}`
    );
  }

  console.log(`Done. ${duplicates.length} duplicate cluster(s) found. DRY_RUN=${DRY_RUN}`);
  return duplicates;
}

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

Add a test

The grouping rule is the part most worth testing, because it decides which customers get reported as duplicates at all. Because classifyCustomerDuplicates is pure and takes plain arrays, the test needs no network and no Shopware store. It just feeds in fixture rows and checks the answer.

test_migrated_duplicates.py
from report_customer_duplicates import classify_customer_duplicates

CHANNEL_A = "a" * 32
CHANNEL_B = "b" * 32


def row(**over):
    base = {
        "id": "1" * 32,
        "email": "jane@example.com",
        "boundSalesChannelId": None,
        "salesChannelId": CHANNEL_A,
        "guest": False,
        "orderCount": 0,
        "createdAt": "2026-01-01T00:00:00Z",
    }
    base.update(over)
    return base


def test_no_duplicate_for_single_row():
    result = classify_customer_duplicates([row()])
    assert result == []


def test_no_duplicate_when_bound_ids_match():
    rows = [
        row(id="1" * 32, boundSalesChannelId=CHANNEL_A),
        row(id="2" * 32, boundSalesChannelId=CHANNEL_A),
    ]
    assert classify_customer_duplicates(rows) == []


def test_flags_cross_channel_split():
    rows = [
        row(id="1" * 32, boundSalesChannelId=CHANNEL_A, orderCount=5),
        row(id="2" * 32, boundSalesChannelId=CHANNEL_B, orderCount=1),
    ]
    result = classify_customer_duplicates(rows)
    assert len(result) == 1
    assert result[0]["primaryId"] == "1" * 32
    assert result[0]["duplicateIds"] == ["2" * 32]
    assert result[0]["reason"] == "cross-channel-split"


def test_flags_unbound_plus_bound_conflict():
    rows = [
        row(id="1" * 32, boundSalesChannelId=None, orderCount=0),
        row(id="2" * 32, boundSalesChannelId=CHANNEL_A, orderCount=3),
    ]
    result = classify_customer_duplicates(rows)
    assert len(result) == 1
    assert result[0]["primaryId"] == "2" * 32
    assert result[0]["duplicateIds"] == ["1" * 32]
    assert result[0]["reason"] == "unbound-plus-bound-conflict"


def test_ignores_guest_rows():
    rows = [
        row(id="1" * 32, boundSalesChannelId=CHANNEL_A, guest=True),
        row(id="2" * 32, boundSalesChannelId=CHANNEL_B, guest=True),
    ]
    assert classify_customer_duplicates(rows) == []


def test_ties_broken_by_earliest_created_at():
    rows = [
        row(id="1" * 32, boundSalesChannelId=CHANNEL_A, orderCount=2, createdAt="2026-02-01T00:00:00Z"),
        row(id="2" * 32, boundSalesChannelId=CHANNEL_B, orderCount=2, createdAt="2026-01-01T00:00:00Z"),
    ]
    result = classify_customer_duplicates(rows)
    assert result[0]["primaryId"] == "2" * 32
    assert result[0]["duplicateIds"] == ["1" * 32]


def test_email_matching_is_case_and_space_insensitive():
    rows = [
        row(id="1" * 32, email=" Jane@Example.com ", boundSalesChannelId=CHANNEL_A),
        row(id="2" * 32, email="jane@example.com", boundSalesChannelId=CHANNEL_B),
    ]
    result = classify_customer_duplicates(rows)
    assert len(result) == 1
    assert sorted([result[0]["primaryId"]] + result[0]["duplicateIds"]) == sorted(["1" * 32, "2" * 32])
migrated.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCustomerDuplicates } from "./report-customer-duplicates.js";

const CHANNEL_A = "a".repeat(32);
const CHANNEL_B = "b".repeat(32);

const row = (over = {}) => ({
  id: "1".repeat(32),
  email: "jane@example.com",
  boundSalesChannelId: null,
  salesChannelId: CHANNEL_A,
  guest: false,
  orderCount: 0,
  createdAt: "2026-01-01T00:00:00Z",
  ...over,
});

test("no duplicate for single row", () => {
  assert.deepEqual(classifyCustomerDuplicates([row()]), []);
});

test("no duplicate when bound ids match", () => {
  const rows = [
    row({ id: "1".repeat(32), boundSalesChannelId: CHANNEL_A }),
    row({ id: "2".repeat(32), boundSalesChannelId: CHANNEL_A }),
  ];
  assert.deepEqual(classifyCustomerDuplicates(rows), []);
});

test("flags cross channel split", () => {
  const rows = [
    row({ id: "1".repeat(32), boundSalesChannelId: CHANNEL_A, orderCount: 5 }),
    row({ id: "2".repeat(32), boundSalesChannelId: CHANNEL_B, orderCount: 1 }),
  ];
  const result = classifyCustomerDuplicates(rows);
  assert.equal(result.length, 1);
  assert.equal(result[0].primaryId, "1".repeat(32));
  assert.deepEqual(result[0].duplicateIds, ["2".repeat(32)]);
  assert.equal(result[0].reason, "cross-channel-split");
});

test("flags unbound plus bound conflict", () => {
  const rows = [
    row({ id: "1".repeat(32), boundSalesChannelId: null, orderCount: 0 }),
    row({ id: "2".repeat(32), boundSalesChannelId: CHANNEL_A, orderCount: 3 }),
  ];
  const result = classifyCustomerDuplicates(rows);
  assert.equal(result.length, 1);
  assert.equal(result[0].primaryId, "2".repeat(32));
  assert.deepEqual(result[0].duplicateIds, ["1".repeat(32)]);
  assert.equal(result[0].reason, "unbound-plus-bound-conflict");
});

test("ignores guest rows", () => {
  const rows = [
    row({ id: "1".repeat(32), boundSalesChannelId: CHANNEL_A, guest: true }),
    row({ id: "2".repeat(32), boundSalesChannelId: CHANNEL_B, guest: true }),
  ];
  assert.deepEqual(classifyCustomerDuplicates(rows), []);
});

test("ties broken by earliest createdAt", () => {
  const rows = [
    row({ id: "1".repeat(32), boundSalesChannelId: CHANNEL_A, orderCount: 2, createdAt: "2026-02-01T00:00:00Z" }),
    row({ id: "2".repeat(32), boundSalesChannelId: CHANNEL_B, orderCount: 2, createdAt: "2026-01-01T00:00:00Z" }),
  ];
  const result = classifyCustomerDuplicates(rows);
  assert.equal(result[0].primaryId, "2".repeat(32));
  assert.deepEqual(result[0].duplicateIds, ["1".repeat(32)]);
});

test("email matching is case and space insensitive", () => {
  const rows = [
    row({ id: "1".repeat(32), email: " Jane@Example.com ", boundSalesChannelId: CHANNEL_A }),
    row({ id: "2".repeat(32), email: "jane@example.com", boundSalesChannelId: CHANNEL_B }),
  ];
  const result = classifyCustomerDuplicates(rows);
  assert.equal(result.length, 1);
  assert.deepEqual(
    [result[0].primaryId, ...result[0].duplicateIds].sort(),
    ["1".repeat(32), "2".repeat(32)].sort()
  );
});

Case studies

Shopware 5 migration

The retailer whose loyal customers lost their order history

A home goods retailer moved from Shopware 5 to Shopware 6 and split its catalog into a retail sales channel and a wholesale one. The migration tool imported each channel separately. Regular customers who had bought from both sides over the years ended up with two customer rows, one per channel, and whichever one they logged into first became the account they were stuck with.

Running the report script surfaced 340 emails with mismatched boundSalesChannelId values in the first pass. Support used the primary and duplicate ids in the report to manually consolidate the highest-value accounts first, instead of guessing which of two rows was the real one.

Magento import

The store where the binding setting was flipped mid-project

A fashion brand migrating from Magento launched with Bind customers to Sales Channel turned off, then turned it on a few weeks later once a second storefront went live. Rows imported before the change had boundSalesChannelId null, rows created after had it set, and the two conventions collided for any customer active across both periods.

The dry run report grouped by email caught the exact pattern, the reason string read unbound-plus-bound-conflict for every affected account, which made it obvious this was a settings-timing issue rather than random data corruption, and pointed the team straight at when the setting had changed.

What good looks like

After this runs following a migration, support gets a clear list of exactly which emails were split, which row looks like the real account based on order history, and which ids are the stray duplicates, instead of a vague complaint about a customer who cannot log in. Nothing gets merged automatically, so no order or address history is ever at risk from a bad guess. The report becomes the starting point for a deliberate, one-by-one cleanup, using Shopware's own migration or merge tooling for anything beyond a stray unbound row.

FAQ

Why does a migrated Shopware 6 customer end up with more than one account?

Shopware 6 only enforces one account per email store-wide when core.systemWideLoginRegistration.isCustomerBoundToSalesChannel is off. When a migration imports the same customer into several sales channels independently, each channel's import can create its own customer row for that email, and the boundSalesChannelId column ends up null or different on each row instead of one shared account.

How do I find duplicate customer accounts after a Shopware 6 migration?

Search the customer entity across all sales channels, group the non-guest rows by lowercased email, and flag any email with more than one row where boundSalesChannelId differs or is inconsistently null. For each flagged email, pull the full set of rows plus their order counts so you can see which row is the real account with order history.

Can I merge duplicate Shopware 6 customer accounts automatically?

Not safely with a generic script. Merging identities means re-pointing orders, addresses, newsletter recipients, and wishlists onto one customer id, which is destructive if done wrong. The safe approach is a dry run report naming the primary and duplicate ids for a human to review, with PATCH /api/customer/{id} on boundSalesChannelId as the only supported one-at-a-time repair for a stray unbound duplicate.

Related field notes

Citations

On the problem:

  1. Winkelwagen: Shopware 6, fixing migrated customers with multiple accounts across sales channels. winkelwagen.de/2024/01/30/shopware-6-fix-migrated-customers-with-multiple-accounts-across-sales-channels
  2. GitHub shopware/shopware issue 4648: Commercial extension, Bind to Sales Channel, throwing an email already in use error. github.com/shopware/shopware/issues/4648
  3. GitHub shopware/shopware issue 6143: contacts within the debitor bound to all sales channels despite the binding option. github.com/shopware/shopware/issues/6143

On the solution:

  1. Shopware Stoplight reference: the Customer entity in the Admin API. shopware.stoplight.io/docs/admin-api/5879e5c7d656b-customer
  2. Shopware Developer Documentation: the Admin API, authentication and request shape. developer.shopware.com/docs/concepts/api/admin-api.html
  3. Shopware Documentation: Sales Channels, including customer binding concepts. developer.shopware.com/docs/concepts/commerce/catalog/sales-channels.html

Stuck on a tricky one?

If you have a problem in Shopware orders, customers, the state machine, or a migration 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 help you untangle a migration?

If this saved you a confusing support ticket or a risky manual merge, 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