Diagnostic Customers and Promotions
Guest checkout reuses an email already tied to a registered account
A shopper has a registered account with an email you have on file, but at checkout they never log in, they just check out as a guest with that same email. Shopware does not stop them. It quietly creates a second customer row, guest true, tied to the exact same address, sitting right next to the real registered account. No error, no warning, just a customer table that now has two rows claiming the same person. Here is why the uniqueness check does not catch this and a script that finds every collision so a human can decide what to do about it.
Shopware 6's customer table enforces email uniqueness only within the scope of email plus sales channel plus the guest flag. The checkout register route only runs its email-already-exists validator for non-guest registrations, so that check never fires when guest is true. A shopper can complete checkout as a guest with an email that already belongs to an active registered account, and Shopware creates a second customer row with guest true tied to the same email. This is a confirmed, currently open defect on Shopware 6.6.10.4, tracked as shopware/shopware#9398, distinct from an older, already fixed double registration bug from 6.4.5.0. Run a small Python or Node.js script that pulls every customer with their email and guest flag, groups by normalized email, and flags any group that has both a registered row and a guest row. The script only reports by default; if you explicitly allow it to act, its one safe write is to soft disable the duplicate guest account. Full code, tests, and sources are below.
The problem in plain words
When a shopper registers an account, Shopware checks whether that email is already taken before it lets the registration through. That check exists, and normally it works exactly as you would expect: two people cannot register with the same email in the same sales channel.
Guest checkout is a completely different code path, and it was never wired to ask the same question. A guest simply enters an email, an address, and a payment method, and Shopware creates a customer record marked guest true to hold that order. The underlying database constraint on the customer table only guarantees uniqueness within email plus sales channel plus the guest flag itself, so a guest row and a registered row can both exist for the same email at the same time without ever violating that constraint. The checkout register route only runs its email-exists validator for the non-guest path, so a guest checkout with an email that already belongs to a real registered account sails through with no error at all.
Why it happens
This is a validation gap, not a corrupted database. A few things line up to make it easy to trigger and easy to miss:
- The unique constraint on the
customertable is scoped to email, sales channel, and the guest flag together, not email alone, so it was designed to allow guest and registered rows to coexist for the same address. - The checkout register route's email-already-exists validator only executes for non-guest registrations. Guest checkout is a legitimately different, lighter-weight flow, and that validator was simply never attached to it.
- Any shopper who forgets they have an account, or who deliberately avoids logging in to skip a password prompt, can trigger this on the very next order with no error message telling them anything is different.
- This is a confirmed, currently open defect on Shopware 6.6.10.4, tracked as shopware/shopware#9398. It is not the same issue as the older double registration bug from Shopware 6.4.5.0, which was about two registered accounts colliding and which Shopware already fixed; this one is specifically the guest-versus-registered collision, and it remains open.
- Store staff and forum threads report the same symptom under different names, duplicate guest accounts and duplicate customer entries with the same email, which is the visible result of this same validation gap.
The moment a guest row and a registered row share an email, that guest order already has real orders and real financial records tied to it through order_customer. Repointing that ownership to the registered customer is a data-integrity decision, not a routine cleanup, because it touches who legally owns that purchase history. So the correct default is to detect and report the collision, not to merge anything automatically. The one safe, reversible action available without a human decision is disabling the duplicate guest account so it cannot be reused again, while every order stays exactly where it is.
The fix, as a flow
We authenticate, pull every customer with their email, guest flag, and timestamps, and group them client side by a normalized, trimmed, lower cased email. Any group that has at least one registered row and at least one guest row is a collision. Under DRY_RUN, the default, we only list the proposed action. If a store admin explicitly turns DRY_RUN off, the script performs exactly one reversible step per collision: it sets the guest customer's active field to false, and it never touches the registered account, the orders, or order_customer.
Build it step by step
Get Admin API credentials
Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" # start safe, change to false only after a human reviews the report
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" // start safe, change to false only after a human reviews the report
Authenticate and talk to the Admin API
A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search and for the one optional write.
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,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
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", Accept: "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 api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Search every customer, sorted by email
Fetch customer with an empty filter, sorted by email ascending, so that rows sharing an email naturally land next to each other. We include the orderCustomers association for the optional cross check later, and page through with page and limit 500, reading total-count-mode:1 so we know when to stop.
def search_customers(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [],
"sort": [{"field": "email", "order": "ASC"}],
"associations": {"orderCustomers": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/customer", token, body)
async function searchCustomers(token, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [],
sort: [{ field: "email", order: "ASC" }],
associations: { orderCustomers: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/customer", token, body);
}
Find the collisions, with one pure function
Keep the decision out of the network code entirely. Given a plain array of customers, each with an id, an email, and a guest boolean, this function groups them by a normalized email, trimmed and lower cased so casing and stray whitespace never hide a match. It keeps only groups that contain at least one registered row, guest false, and at least one guest row, guest true, and returns one record per email with the registered id and every colliding guest id. Groups of guests only, or a registered row with no guest collision, never get flagged.
def normalize_email(email):
return (email or "").strip().lower()
def find_duplicate_guest_emails(customers):
groups = {}
for c in customers:
key = normalize_email(c.get("email"))
if not key:
continue
groups.setdefault(key, []).append(c)
flagged = []
for email, rows in groups.items():
registered = [r for r in rows if r.get("guest") is False]
guests = [r for r in rows if r.get("guest") is True]
if not registered or not guests:
continue
flagged.append({
"email": email,
"registeredId": registered[0]["id"],
"guestIds": [g["id"] for g in guests],
})
return flagged
export function normalizeEmail(email) {
return (email || "").trim().toLowerCase();
}
export function findDuplicateGuestEmails(customers) {
const groups = new Map();
for (const c of customers) {
const key = normalizeEmail(c.email);
if (!key) continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(c);
}
const flagged = [];
for (const [email, rows] of groups) {
const registered = rows.filter((r) => r.guest === false);
const guests = rows.filter((r) => r.guest === true);
if (registered.length === 0 || guests.length === 0) continue;
flagged.push({
email,
registeredId: registered[0].id,
guestIds: guests.map((g) => g.id),
});
}
return flagged;
}
Report first, disable only when explicitly allowed
For every collision, log the registered id and every duplicate guest id, and optionally cross check with POST /api/search/order filtering on orderCustomer.email equals the email, associations orderCustomer, to list order numbers for the report. Under DRY_RUN, the default, that is the entire action. Only if a store admin sets DRY_RUN to false does the script call PATCH /api/customer/{guestCustomerId} with active false, one call per duplicate guest id, and it never touches the registered customer, the orders, or order_customer.
Always start with DRY_RUN=true. This script's job is to report, not to merge. It never deletes a customer record and never reassigns order_customer.customerId, because that changes who owns real financial and GDPR data, and that decision belongs to a store admin, not a script.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every collision it finds, respects the dry run flag, and its only write, guarded by DRY_RUN, is a soft disable of the duplicate guest account.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 guest orders that reuse an email already tied to a registered account.
Shopware 6's customer table enforces email uniqueness only within the scope of email
plus sales channel plus the guest flag, and the checkout register route only runs its
email-already-exists validator for non-guest registrations. That check never fires when
guest is true, so a shopper can check out as a guest with an email that already belongs
to an active registered account, silently creating a second customer row with guest true
tied to the same email. This is a confirmed, currently open defect on Shopware 6.6.10.4,
tracked as shopware/shopware#9398, distinct from an older, already fixed double
registration bug from 6.4.5.0. This script reports every collision for a human to review.
Its only write, guarded by DRY_RUN, is a soft disable of the duplicate guest account.
It never deletes a customer record and never reassigns order_customer.customerId.
Run on a schedule. 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("find_guest_email_collisions")
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"
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,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
def search_customers(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [],
"sort": [{"field": "email", "order": "ASC"}],
"associations": {"orderCustomers": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/customer", token, body)
def orders_for_email(token, email):
body = {
"filter": [{"type": "equals", "field": "orderCustomer.email", "value": email}],
"associations": {"orderCustomer": {}},
}
data = api("POST", "/api/search/order", token, body)
return [row.get("orderNumber") for row in data.get("data", [])]
def normalize_email(email):
return (email or "").strip().lower()
def find_duplicate_guest_emails(customers):
groups = {}
for c in customers:
key = normalize_email(c.get("email"))
if not key:
continue
groups.setdefault(key, []).append(c)
flagged = []
for email, rows in groups.items():
registered = [r for r in rows if r.get("guest") is False]
guests = [r for r in rows if r.get("guest") is True]
if not registered or not guests:
continue
flagged.append({
"email": email,
"registeredId": registered[0]["id"],
"guestIds": [g["id"] for g in guests],
})
return flagged
def disable_guest_customer(token, guest_customer_id):
"""Human-approved corrective action only, gated on DRY_RUN=false.
Never deletes the record and never reassigns order_customer.customerId."""
if DRY_RUN:
log.warning("DRY RUN: would PATCH /api/customer/%s with active=false", guest_customer_id)
return
api("PATCH", f"/api/customer/{guest_customer_id}", token, {"active": False})
def all_customers(token):
page = 1
while True:
data = search_customers(token, page=page)
rows = data.get("data", [])
if not rows:
return
for row in rows:
yield row
if page * 500 >= data.get("total", 0):
return
page += 1
def run():
token = get_token()
customers = list(all_customers(token))
collisions = find_duplicate_guest_emails(customers)
for collision in collisions:
log.warning(
"COLLISION email=%s registeredId=%s guestIds=%s",
collision["email"], collision["registeredId"], collision["guestIds"],
)
for guest_id in collision["guestIds"]:
disable_guest_customer(token, guest_id)
log.info(
"Done. %d customer(s) checked, %d email collision(s) %s.",
len(customers), len(collisions), "to disable" if DRY_RUN else "disabled",
)
if __name__ == "__main__":
run()
/**
* Find Shopware 6 guest orders that reuse an email already tied to a registered account.
*
* Shopware 6's customer table enforces email uniqueness only within the scope of email
* plus sales channel plus the guest flag, and the checkout register route only runs its
* email-already-exists validator for non-guest registrations. That check never fires when
* guest is true, so a shopper can check out as a guest with an email that already belongs
* to an active registered account, silently creating a second customer row with guest true
* tied to the same email. This is a confirmed, currently open defect on Shopware 6.6.10.4,
* tracked as shopware/shopware#9398, distinct from an older, already fixed double
* registration bug from 6.4.5.0. This script reports every collision for a human to review.
* Its only write, guarded by DRY_RUN, is a soft disable of the duplicate guest account.
* It never deletes a customer record and never reassigns order_customer.customerId.
* Run on a schedule. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function normalizeEmail(email) {
return (email || "").trim().toLowerCase();
}
export function findDuplicateGuestEmails(customers) {
const groups = new Map();
for (const c of customers) {
const key = normalizeEmail(c.email);
if (!key) continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(c);
}
const flagged = [];
for (const [email, rows] of groups) {
const registered = rows.filter((r) => r.guest === false);
const guests = rows.filter((r) => r.guest === true);
if (registered.length === 0 || guests.length === 0) continue;
flagged.push({
email,
registeredId: registered[0].id,
guestIds: guests.map((g) => g.id),
});
}
return flagged;
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "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 api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function searchCustomers(token, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [],
sort: [{ field: "email", order: "ASC" }],
associations: { orderCustomers: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/customer", token, body);
}
async function ordersForEmail(token, email) {
const body = {
filter: [{ type: "equals", field: "orderCustomer.email", value: email }],
associations: { orderCustomer: {} },
};
const data = await api("POST", "/api/search/order", token, body);
return (data.data || []).map((row) => row.orderNumber);
}
// Human-approved corrective action only, gated on DRY_RUN=false.
// Never deletes the record and never reassigns order_customer.customerId.
async function disableGuestCustomer(token, guestCustomerId) {
if (DRY_RUN) {
console.warn(`DRY RUN: would PATCH /api/customer/${guestCustomerId} with active=false`);
return;
}
await api("PATCH", `/api/customer/${guestCustomerId}`, token, { active: false });
}
async function* allCustomers(token) {
let page = 1;
while (true) {
const data = await searchCustomers(token, page);
const rows = data.data || [];
if (rows.length === 0) return;
for (const row of rows) yield row;
if (page * 500 >= (data.total || 0)) return;
page++;
}
}
export async function run() {
const token = await getToken();
const customers = [];
for await (const c of allCustomers(token)) customers.push(c);
const collisions = findDuplicateGuestEmails(customers);
for (const collision of collisions) {
console.warn(
`COLLISION email=${collision.email} registeredId=${collision.registeredId} guestIds=${collision.guestIds}`
);
for (const guestId of collision.guestIds) {
await disableGuestCustomer(token, guestId);
}
}
console.log(
`Done. ${customers.length} customer(s) checked, ${collisions.length} email collision(s) ${DRY_RUN ? "to disable" : "disabled"}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which customers get flagged as the same person. Because find_duplicate_guest_emails is pure, no network and no Shopware instance is needed. It just takes a plain array in and checks the answer.
from find_guest_email_collisions import find_duplicate_guest_emails, normalize_email
def cust(id_, email, guest):
return {"id": id_, "email": email, "guest": guest}
def test_flags_same_email_guest_plus_registered():
customers = [
cust("reg-1", "jane@example.com", False),
cust("guest-1", "jane@example.com", True),
]
result = find_duplicate_guest_emails(customers)
assert result == [{"email": "jane@example.com", "registeredId": "reg-1", "guestIds": ["guest-1"]}]
def test_no_flag_when_two_guests_only():
customers = [
cust("guest-1", "jane@example.com", True),
cust("guest-2", "jane@example.com", True),
]
assert find_duplicate_guest_emails(customers) == []
def test_matches_despite_case_and_whitespace_differences():
customers = [
cust("reg-1", " Jane@Example.com", False),
cust("guest-1", "jane@example.com ", True),
]
result = find_duplicate_guest_emails(customers)
assert result == [{"email": "jane@example.com", "registeredId": "reg-1", "guestIds": ["guest-1"]}]
def test_no_flag_without_registered_match():
customers = [
cust("reg-1", "jane@example.com", False),
cust("reg-2", "other@example.com", False),
]
assert find_duplicate_guest_emails(customers) == []
def test_multiple_guest_rows_all_collected():
customers = [
cust("reg-1", "jane@example.com", False),
cust("guest-1", "jane@example.com", True),
cust("guest-2", "jane@example.com", True),
]
result = find_duplicate_guest_emails(customers)
assert result == [{"email": "jane@example.com", "registeredId": "reg-1", "guestIds": ["guest-1", "guest-2"]}]
def test_empty_email_rows_are_ignored():
customers = [
cust("reg-1", "", False),
cust("guest-1", None, True),
]
assert find_duplicate_guest_emails(customers) == []
def test_normalize_email_trims_and_lowercases():
assert normalize_email(" Jane@Example.com ") == "jane@example.com"
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateGuestEmails, normalizeEmail } from "./find-guest-email-collisions.js";
const cust = (id, email, guest) => ({ id, email, guest });
test("flags same email guest plus registered", () => {
const customers = [cust("reg-1", "jane@example.com", false), cust("guest-1", "jane@example.com", true)];
assert.deepEqual(findDuplicateGuestEmails(customers), [
{ email: "jane@example.com", registeredId: "reg-1", guestIds: ["guest-1"] },
]);
});
test("no flag when two guests only", () => {
const customers = [cust("guest-1", "jane@example.com", true), cust("guest-2", "jane@example.com", true)];
assert.deepEqual(findDuplicateGuestEmails(customers), []);
});
test("matches despite case and whitespace differences", () => {
const customers = [cust("reg-1", " Jane@Example.com", false), cust("guest-1", "jane@example.com ", true)];
assert.deepEqual(findDuplicateGuestEmails(customers), [
{ email: "jane@example.com", registeredId: "reg-1", guestIds: ["guest-1"] },
]);
});
test("no flag without registered match", () => {
const customers = [cust("reg-1", "jane@example.com", false), cust("reg-2", "other@example.com", false)];
assert.deepEqual(findDuplicateGuestEmails(customers), []);
});
test("multiple guest rows all collected", () => {
const customers = [
cust("reg-1", "jane@example.com", false),
cust("guest-1", "jane@example.com", true),
cust("guest-2", "jane@example.com", true),
];
assert.deepEqual(findDuplicateGuestEmails(customers), [
{ email: "jane@example.com", registeredId: "reg-1", guestIds: ["guest-1", "guest-2"] },
]);
});
test("empty email rows are ignored", () => {
const customers = [cust("reg-1", "", false), cust("guest-1", null, true)];
assert.deepEqual(findDuplicateGuestEmails(customers), []);
});
test("normalizeEmail trims and lowercases", () => {
assert.equal(normalizeEmail(" Jane@Example.com "), "jane@example.com");
});
Case studies
A repeat buyer split into two customer records
A skincare brand had a customer who registered an account on her first order, then came back three months later, forgot she had signed up, and checked out as a guest with the same email. Support only noticed when she asked why her second order was not showing up in her account, order history, saved address, and loyalty points all lived on the registered row while the new order sat on a brand new guest row nobody had linked to anything.
Running the detection script surfaced the collision in the first pass: one email, one registered id, one guest id. The report gave support exactly what they needed to explain the mix up to the customer and hand the guest order details to a human for reconciliation, without the script ever touching the order itself.
A store saw its customer count creep up faster than its actual buyers
A homeware store noticed its customer table growing noticeably faster than its unique buyer count felt like it should. Shoppers were skipping the login step at checkout, some out of habit, some because the guest path felt faster, and every one of them who already had an account was quietly getting a second, disconnected customer row.
The store ran the script on a weekly schedule in dry run, reviewed the growing list of email collisions, and used it to prioritize which guest accounts to disable first, the ones with the most orders attached, while leaving the underlying order history untouched for their finance team to review case by case.
After this runs on a schedule, every guest order that reused an already-registered email shows up in a report with the registered id and every colliding guest id, not buried silently in the customer table. A store admin decides what happens to each collision with full context, and if a guest account is disabled, it happened through one explicit, reversible active flag flip, never a silent merge or a lost order.
FAQ
Why can a guest checkout use an email that already belongs to a registered customer in Shopware 6?
Shopware 6's customer table only enforces email uniqueness within the scope of email plus sales channel plus the guest flag. The checkout register route only runs its email-already-exists validator for non-guest registrations, so when guest is true that validator never fires. A shopper can complete checkout as a guest with an email that already belongs to an active registered account, and Shopware creates a second customer row with guest true tied to the same email, with no error and no warning.
Is this the same bug as the old Shopware double registration issue?
No. This is a distinct, currently open defect tracked as shopware/shopware#9398, reproducible on Shopware 6.6.10.4. It is not the same as the older double registration bug from Shopware 6.4.5.0, which was about two registered accounts, and which Shopware already fixed. This one is specifically about a guest order colliding with an already registered account, and it remains open.
Is it safe to automatically merge or delete the duplicate guest account?
No. Repointing order_customer.customerId from the guest record to the registered record changes financial and GDPR data ownership, so that is not a safe blind write. The correct action is to flag the collision for a store admin to review. If a script is explicitly allowed to act, the only safe step is PATCH /api/customer/{guestCustomerId} with active set to false, which soft disables the duplicate guest account so it can no longer be logged into or reused, while leaving every order and order_customer row untouched for manual reconciliation. Never delete the customer record or reassign order_customer.customerId automatically.
Related field notes
Citations
On the problem:
- GitHub Issue: E-mail from customer account can be used again for guest orders. github.com/shopware/shopware/issues/9398
- Shopware Community Forum: Duplicate guest accounts. forum.shopware.com/t/duplicate-guest-accounts/96565
- Shopware Community Forum: Doppelte Kundeneintraege mit gleiche Mailadresse. forum.shopware.com/t/doppelte-kundeneintraege-mit-gleiche-mailadresse/102221
On the solution:
- Shopware 6 Documentation: Settings, Log-in and sign-up. docs.shopware.com login-registration
- Shopware 6 Documentation: Customers, Overview. docs.shopware.com customers overview
- Shopware Admin API Entity Reference. shopware.stoplight.io admin-api entity-reference
Stuck on a tricky one?
If you have a problem in Shopware customers, checkout, promotions, or order data integrity that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear up a customer data mystery?
If this saved you a confusing support ticket or a messy customer table, 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