Diagnostic Customers & Notifications
Guest checkout order not linked to matching customer account
A returning shopper checks out as a guest, typing the same email they used to register months ago. The order goes through fine. But it never shows up under their account, their order history looks empty, and support has no easy way to explain why. Here is why Saleor leaves order.user null on purpose in this case, and a script that finds every affected order without ever guessing at who owns it.
Saleor sets order.user during checkout completion, in _process_user_data_for_order, and that function reads the checkout's own user_id, not the checkout email. A guest checkout has no user_id, so order.user stays null even when order.userEmail exactly matches a registered account, because Saleor deliberately does not run a post-hoc email lookup against the User table for security reasons. Run a small Python or Node.js script that pages through orders, reads id, number, userEmail, and user, cross-references any order with a null user against the customers list by normalized email, and reports every match for staff review. It never writes order.user itself. Full code, tests, and the reasoning for staying report-only are below.
The problem in plain words
When someone checks out while logged in, Saleor already knows exactly which account placed the order, because the checkout object itself was created under that session and carries the account's user_id. Completing the checkout just carries that identity forward onto the new order's user field.
A guest checkout never has that identity attached anywhere. The buyer types an email so they can get a confirmation and a shipping update, and that email lands on order.userEmail. But nothing about a guest checkout ever creates or touches a user_id, so when the order is created there is simply no account reference to carry forward. Even if that same email belongs to a real, registered customer sitting one click away in the admin, Saleor never looks it up. The order and the account remain two separate records that happen to share a string.
Why it happens
- Saleor links
order.userto an authenticatedUseronly when the checkout itself was performed while logged in. The linkage happens in_process_user_data_for_orderduring checkout completion, and it reads the checkout'suser_id, not the checkoutemail. - A guest, or anonymous, checkout stores the buyer's address and contact email into
order.userEmail, but leavesorder.usernull, because there was never auser_idon that checkout to carry forward. - Saleor does not perform a post-hoc lookup against the
Usertable by email once the order is created, even when the emails match exactly. - That omission is deliberate. Auto-linking by email alone would let anyone claim another account's order history just by entering their email at guest checkout, which is an account-takeover pattern, not a convenience.
This is confirmed by Saleor's own GitHub discussion #8508, where orders placed anonymously with a registered email showed zero orders under the matching customer's profile. It is also the reason guest checkout, on its own, never sends a confirmation prompting the buyer to create or sign in to an account, which is tracked separately in issue #432. See the citations at the end for both threads.
An unlinked guest order is not corrupted data, it is Saleor refusing to guess. The fix is not to force a link based on a matching email string, because that is exactly the shortcut that would let an attacker place a guest order with someone else's email and gain visibility into, or association with, that account. The safe move is to detect the gap, report it with the matching customer clearly identified, and let a human decide, or fix the storefront so future guest checkouts get an easy path to link themselves at checkout time instead.
The fix, as a flow
The script runs on a schedule. It pages through orders, reads each order's userEmail and whether user is already set, and separately pages through customers to build a lookup by normalized email. A single pure function cross-references the two lists and returns one flagged record per match. Nothing is written back to order.user. The output is a CSV or JSON report for staff, and the recommended corrective action is either a storefront prompt to link accounts at checkout, or a staff-confirmed manual orderUpdate with a note, never an unattended write.
Build it step by step
Get an app token with order and customer read access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and read users or customers. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" # start safe, this script only ever writes a report file
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true" // start safe, this script only ever writes a report file
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
import os, requests
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
Page through orders and through customers
Ask for orders(first, after) and read back id, number, userEmail, and user { id }. Separately, page through customers(first, after, filter: {}), the bulk-safe path, reading id and email. Page both with a cursor so the job handles a large store without loading everything at once.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges { node { id number userEmail user { id } } }
}
}"""
CUSTOMERS_QUERY = """
query($cursor: String) {
customers(first: 50, after: $cursor, filter: {}) {
pageInfo { hasNextPage endCursor }
edges { node { id email } }
}
}"""
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def all_customers():
cursor = None
while True:
data = gql(CUSTOMERS_QUERY, {"cursor": cursor})["customers"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges { node { id number userEmail user { id } } }
}
}`;
const CUSTOMERS_QUERY = `
query($cursor: String) {
customers(first: 50, after: $cursor, filter: {}) {
pageInfo { hasNextPage endCursor }
edges { node { id email } }
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function* allCustomers() {
let cursor = null;
while (true) {
const data = (await gql(CUSTOMERS_QUERY, { cursor })).customers;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the plain orders and customers and returns the flagged matches. A pure function like this is easy to read and test, which we do later. It builds a map of lowercased and trimmed customer email to customer id, then filters orders where user is null, userEmail is non-empty, and the normalized userEmail exists in that map. It never touches the network and never decides to write anything.
def _norm(email):
return (email or "").strip().lower()
def find_unlinked_guest_orders(orders, customers):
by_email = {}
for customer in customers:
key = _norm(customer.get("email"))
if key:
by_email[key] = customer["id"]
flagged = []
for order in orders:
if order.get("user") is not None:
continue
email = _norm(order.get("userEmail"))
if not email:
continue
matched_id = by_email.get(email)
if not matched_id:
continue
flagged.append({
"orderId": order["id"],
"orderNumber": order["number"],
"userEmail": order["userEmail"],
"matchedCustomerId": matched_id,
})
return flagged
function norm(email) {
return (email || "").trim().toLowerCase();
}
export function findUnlinkedGuestOrders(orders, customers) {
const byEmail = new Map();
for (const customer of customers) {
const key = norm(customer.email);
if (key) byEmail.set(key, customer.id);
}
const flagged = [];
for (const order of orders) {
if (order.user !== null && order.user !== undefined) continue;
const email = norm(order.userEmail);
if (!email) continue;
const matchedId = byEmail.get(email);
if (!matchedId) continue;
flagged.push({
orderId: order.id,
orderNumber: order.number,
userEmail: order.userEmail,
matchedCustomerId: matchedId,
});
}
return flagged;
}
Emit a report, never an automatic write
Saleor has no first-class orderUpdate field meant to attach a customer to an existing order after the fact, and forcing a link purely from an email match reintroduces the account-takeover risk Saleor avoids by design. So the script's only output is a report row per flagged order: {orderId, orderNumber, userEmail, matchedCustomerId, orderCreatedAt}. Under DRY_RUN=true, the default, it only logs the report. When DRY_RUN=false, it additionally writes the report to a CSV or JSON file, still never touching order.user.
import csv
def write_report_csv(path, flagged_rows):
fieldnames = ["orderId", "orderNumber", "userEmail", "matchedCustomerId"]
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in flagged_rows:
writer.writerow({k: row.get(k, "") for k in fieldnames})
import { writeFileSync } from "node:fs";
export function toCsv(flaggedRows) {
const fields = ["orderId", "orderNumber", "userEmail", "matchedCustomerId"];
const lines = [fields.join(",")];
for (const row of flaggedRows) {
lines.push(fields.map((f) => JSON.stringify(row[f] ?? "")).join(","));
}
return lines.join("\n");
}
export function writeReportCsv(path, flaggedRows) {
writeFileSync(path, toCsv(flaggedRows));
}
Wire it together with a dry run guard
The loop ties every piece together. Under DRY_RUN=true, the default, the script only logs how many unlinked guest orders it found and prints the report rows to the console for a quick look. When DRY_RUN=false, it additionally writes the same rows to a report file for staff review. Neither mode ever calls a mutation, so it is always safe to run again and again on a schedule.
This script never writes order.user. Treat every row in the report as a lead for a human, not a fact to act on automatically. Attaching a customer to an existing order should only ever happen through a staff-confirmed manual orderUpdate with a note, or better, by fixing the storefront so guest checkouts get an easy prompt to link or create an account going forward.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through orders and customers, flags unlinked guest orders with the pure function, always logs the findings, and writes a report file only when DRY_RUN=false. It never calls a mutation against order.user.
"""Find Saleor orders placed as a guest whose userEmail matches a registered
customer's email, even though order.user is null.
Saleor links order.user to a User only when the checkout itself was performed
while logged in. The linkage happens in _process_user_data_for_order during
checkout completion, and it reads the checkout's user_id, not its email. A
guest checkout stores the buyer's email on order.userEmail but never gets a
user_id, so order.user stays null even when the email matches an existing
account, because Saleor deliberately never runs a post-hoc lookup against the
User table by email (see saleor/saleor discussion #8508, issue #432).
This script only ever reports. There is no first-class orderUpdate field for
reassigning a customer after the fact, and auto-linking by email alone would
let anyone claim another account's order history just by entering their email
at guest checkout. Under DRY_RUN=true (the default) it only logs the report.
When DRY_RUN=false it additionally writes a CSV report file for staff review.
Run on a schedule. Safe to run again and again.
"""
import os
import csv
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_unlinked_guest_orders")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPORT_PATH = os.environ.get("REPORT_PATH", "unlinked_guest_orders.csv")
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges { node { id number userEmail user { id } } }
}
}"""
CUSTOMERS_QUERY = """
query($cursor: String) {
customers(first: 50, after: $cursor, filter: {}) {
pageInfo { hasNextPage endCursor }
edges { node { id email } }
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def _norm(email):
return (email or "").strip().lower()
def find_unlinked_guest_orders(orders, customers):
by_email = {}
for customer in customers:
key = _norm(customer.get("email"))
if key:
by_email[key] = customer["id"]
flagged = []
for order in orders:
if order.get("user") is not None:
continue
email = _norm(order.get("userEmail"))
if not email:
continue
matched_id = by_email.get(email)
if not matched_id:
continue
flagged.append({
"orderId": order["id"],
"orderNumber": order["number"],
"userEmail": order["userEmail"],
"matchedCustomerId": matched_id,
})
return flagged
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def all_customers():
cursor = None
while True:
data = gql(CUSTOMERS_QUERY, {"cursor": cursor})["customers"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def to_plain_order(node):
return {
"id": node["id"],
"number": node["number"],
"userEmail": node.get("userEmail"),
"user": node.get("user"),
}
def write_report_csv(path, flagged_rows):
fieldnames = ["orderId", "orderNumber", "userEmail", "matchedCustomerId"]
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in flagged_rows:
writer.writerow({k: row.get(k, "") for k in fieldnames})
def run():
orders = [to_plain_order(node) for node in all_orders()]
customers = list(all_customers())
flagged = find_unlinked_guest_orders(orders, customers)
for row in flagged:
log.warning("Unlinked guest order found for staff review: %s", row)
if not DRY_RUN:
write_report_csv(REPORT_PATH, flagged)
log.info("Report written to %s", REPORT_PATH)
log.info("Done. %d unlinked guest order(s) found. %s", len(flagged),
"Report file written." if not DRY_RUN else "Dry run, no file written.")
if __name__ == "__main__":
run()
/**
* Find Saleor orders placed as a guest whose userEmail matches a registered
* customer's email, even though order.user is null.
*
* Saleor links order.user to a User only when the checkout itself was
* performed while logged in. The linkage happens in
* _process_user_data_for_order during checkout completion, and it reads the
* checkout's user_id, not its email. A guest checkout stores the buyer's
* email on order.userEmail but never gets a user_id, so order.user stays
* null even when the email matches an existing account, because Saleor
* deliberately never runs a post-hoc lookup against the User table by email
* (see saleor/saleor discussion #8508, issue #432).
*
* This script only ever reports. There is no first-class orderUpdate field
* for reassigning a customer after the fact, and auto-linking by email alone
* would let anyone claim another account's order history just by entering
* their email at guest checkout. Under DRY_RUN=true (the default) it only
* logs the report. When DRY_RUN=false it additionally writes a CSV report
* file for staff review. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/guest-order-not-linked-to-customer/
*/
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPORT_PATH = process.env.REPORT_PATH || "unlinked-guest-orders.csv";
function norm(email) {
return (email || "").trim().toLowerCase();
}
export function findUnlinkedGuestOrders(orders, customers) {
const byEmail = new Map();
for (const customer of customers) {
const key = norm(customer.email);
if (key) byEmail.set(key, customer.id);
}
const flagged = [];
for (const order of orders) {
if (order.user !== null && order.user !== undefined) continue;
const email = norm(order.userEmail);
if (!email) continue;
const matchedId = byEmail.get(email);
if (!matchedId) continue;
flagged.push({
orderId: order.id,
orderNumber: order.number,
userEmail: order.userEmail,
matchedCustomerId: matchedId,
});
}
return flagged;
}
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges { node { id number userEmail user { id } } }
}
}`;
const CUSTOMERS_QUERY = `
query($cursor: String) {
customers(first: 50, after: $cursor, filter: {}) {
pageInfo { hasNextPage endCursor }
edges { node { id email } }
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function* allCustomers() {
let cursor = null;
while (true) {
const data = (await gql(CUSTOMERS_QUERY, { cursor })).customers;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
function toPlainOrder(node) {
return {
id: node.id,
number: node.number,
userEmail: node.userEmail ?? null,
user: node.user ?? null,
};
}
export function toCsv(flaggedRows) {
const fields = ["orderId", "orderNumber", "userEmail", "matchedCustomerId"];
const lines = [fields.join(",")];
for (const row of flaggedRows) {
lines.push(fields.map((f) => JSON.stringify(row[f] ?? "")).join(","));
}
return lines.join("\n");
}
export async function run() {
const orders = [];
for await (const node of allOrders()) orders.push(toPlainOrder(node));
const customers = [];
for await (const node of allCustomers()) customers.push(node);
const flagged = findUnlinkedGuestOrders(orders, customers);
for (const row of flagged) {
console.warn("Unlinked guest order found for staff review:", row);
}
if (!DRY_RUN) {
writeFileSync(REPORT_PATH, toCsv(flagged));
console.log(`Report written to ${REPORT_PATH}`);
}
console.log(`Done. ${flagged.length} unlinked guest order(s) found. ${DRY_RUN ? "Dry run, no file written." : "Report file written."}`);
}
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 orders end up in front of staff as a likely match. Because find_unlinked_guest_orders is pure, taking plain lists of orders and customers, the test needs no network and no Saleor account. It just feeds in fixture arrays and checks the answer.
from find_unlinked_guest_orders import find_unlinked_guest_orders
def order(**over):
base = {"id": "gid://saleor/Order/1", "number": "1001", "userEmail": "jane@example.com", "user": None}
base.update(over)
return base
def customer(**over):
base = {"id": "gid://saleor/User/1", "email": "jane@example.com"}
base.update(over)
return base
def test_flags_guest_order_matching_a_customer_email():
result = find_unlinked_guest_orders([order()], [customer()])
assert result == [{
"orderId": "gid://saleor/Order/1",
"orderNumber": "1001",
"userEmail": "jane@example.com",
"matchedCustomerId": "gid://saleor/User/1",
}]
def test_skips_order_already_linked_to_a_user():
linked = order(user={"id": "gid://saleor/User/1"})
assert find_unlinked_guest_orders([linked], [customer()]) == []
def test_skips_order_with_no_matching_customer():
assert find_unlinked_guest_orders([order(userEmail="stranger@example.com")], [customer()]) == []
def test_skips_order_with_no_email():
assert find_unlinked_guest_orders([order(userEmail=None)], [customer()]) == []
def test_matches_case_insensitively_and_trims_whitespace():
result = find_unlinked_guest_orders(
[order(userEmail=" Jane@Example.com ")],
[customer(email="jane@example.com")],
)
assert len(result) == 1
assert result[0]["matchedCustomerId"] == "gid://saleor/User/1"
def test_multiple_orders_only_flags_the_unlinked_matches():
orders = [
order(id="o1", number="1001"),
order(id="o2", number="1002", user={"id": "gid://saleor/User/9"}),
order(id="o3", number="1003", userEmail="nomatch@example.com"),
]
result = find_unlinked_guest_orders(orders, [customer()])
assert [row["orderId"] for row in result] == ["o1"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findUnlinkedGuestOrders } from "./find-unlinked-guest-orders.js";
const order = (over = {}) => ({
id: "gid://saleor/Order/1",
number: "1001",
userEmail: "jane@example.com",
user: null,
...over,
});
const customer = (over = {}) => ({
id: "gid://saleor/User/1",
email: "jane@example.com",
...over,
});
test("flags guest order matching a customer email", () => {
const result = findUnlinkedGuestOrders([order()], [customer()]);
assert.deepEqual(result, [{
orderId: "gid://saleor/Order/1",
orderNumber: "1001",
userEmail: "jane@example.com",
matchedCustomerId: "gid://saleor/User/1",
}]);
});
test("skips order already linked to a user", () => {
const linked = order({ user: { id: "gid://saleor/User/1" } });
assert.deepEqual(findUnlinkedGuestOrders([linked], [customer()]), []);
});
test("skips order with no matching customer", () => {
assert.deepEqual(findUnlinkedGuestOrders([order({ userEmail: "stranger@example.com" })], [customer()]), []);
});
test("skips order with no email", () => {
assert.deepEqual(findUnlinkedGuestOrders([order({ userEmail: null })], [customer()]), []);
});
test("matches case insensitively and trims whitespace", () => {
const result = findUnlinkedGuestOrders(
[order({ userEmail: " Jane@Example.com " })],
[customer({ email: "jane@example.com" })],
);
assert.equal(result.length, 1);
assert.equal(result[0].matchedCustomerId, "gid://saleor/User/1");
});
test("multiple orders only flags the unlinked matches", () => {
const orders = [
order({ id: "o1", number: "1001" }),
order({ id: "o2", number: "1002", user: { id: "gid://saleor/User/9" } }),
order({ id: "o3", number: "1003", userEmail: "nomatch@example.com" }),
];
const result = findUnlinkedGuestOrders(orders, [customer()]);
assert.deepEqual(result.map((row) => row.orderId), ["o1"]);
});
Case studies
A loyal customer swore they never received an order confirmation email in their account
A subscription box brand had a customer email in asking why last month's order was not in her order history, even though she was sure she used her usual email. Support checked the order and saw the email matched her account exactly, yet user was null. The buyer had simply typed her email at guest checkout on a phone, without noticing she was not signed in.
Running the report script in dry run turned up eleven similar orders across three months, all guest checkouts with emails matching existing accounts. Staff manually confirmed each one and used a signed-off orderUpdate to attach the right customer, and the storefront team added a login prompt at checkout to cut down on future guest orders from returning shoppers.
Repeat purchase rate looked lower than it actually was
A skincare brand's dashboard showed a surprisingly low repeat purchase rate, which did not match what the founder felt from talking to customers. The dashboard counted repeat purchases by orders attached to a customer account, and it turned out a meaningful share of returning buyers were checking out as guests using the same email as their account, so those orders never counted as a second purchase from an existing customer.
The report script surfaced the scale of the gap without needing to touch a single order. Marketing used the numbers to justify prioritizing an account-linking prompt in the storefront, and left the historical orders in the report queue for a slower, staff-reviewed cleanup rather than a bulk rewrite.
After this runs on a schedule, an unlinked guest order stops being an invisible gap in someone's order history and becomes a clearly identified report row with the matching customer already looked up. Staff decide, order by order, whether to attach it with a confirmed manual orderUpdate, and the storefront gets a nudge to prompt account linking at checkout so fewer of these ever happen again. Nothing ever gets auto-linked purely because an email string matched.
FAQ
Why does my Saleor order show a customer email but no linked customer?
Saleor sets order.user during checkout completion, in _process_user_data_for_order, and it reads the checkout's own user_id rather than the checkout email. If the buyer checked out as a guest, that user_id is empty even though order.userEmail captured their address contact email, so order.user stays null no matter what the email says.
Why doesn't Saleor just match the order to the account with the same email?
Because email alone is not proof of identity. If Saleor auto-linked any order to whichever account shares its email, anyone could type a stranger's email at guest checkout and see that stranger's order history appear associated with the account, or worse, appear as their own. Saleor's own GitHub discussion #8508 confirms this is deliberate, not a bug.
Is it safe to bulk update order.user for every matching guest order?
No, do not automate that write. There is no first-class orderUpdate mutation meant for reassigning a customer after the fact, and forcing the link from an email match alone recreates the exact account-takeover risk Saleor avoided by not doing it automatically. The safe pattern is a report for staff review, plus fixing the storefront so logged-in checkout or account linking is easy going forward.
Related field notes
Citations
On the problem:
- Customers not always linked to order. github.com/saleor/saleor/discussions/8508
- No confirmation email is sent when checkout as Guest. github.com/saleor/saleor/issues/432
On the solution:
- Saleor API Reference: the Order object. docs.saleor.io/docs/3.x/api-reference/orders/objects/order
- Saleor API Reference: overview. docs.saleor.io/api-reference/
- Saleor Commerce Documentation: authentication and authorization. docs.saleor.io/api-usage/authentication
Stuck on a tricky one?
If you have a problem in Saleor checkout, customers, payments, stock, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this untangle a missing order for you?
If this saved a support ticket or cleared up a confusing gap in a customer's order history, 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