Reconciler Customers & Notifications
Duplicate address rows created instead of reusing saved address
A loyal customer ships to the same home address on every order. After a dozen orders, their account address book holds a dozen copies of that one address, each a separate row with its own id, all identical. Nothing is broken, but the address book is a mess and any report that counts addresses is wrong. Here is why Saleor saves a fresh address row per order instead of reusing the one already on the account, and a script that finds every duplicate cluster per customer without deleting anything until you say so.
Saleor stores each order's shipping and billing address as its own Address row and attaches a copy to the customer account. It never compares the incoming address against the ones already saved, so an identical address becomes yet another separate row with its own id, on purpose, because an order's address is a frozen historical record of where that order shipped. Run a small Python or Node.js script that pages through customers with their addresses, groups each customer's addresses by a normalized key built from streetAddress1, streetAddress2, city, postalCode, country.code, firstName, and lastName, and flags any group with more than one row. It keeps the isDefaultShippingAddress row when present, otherwise the first, and returns the rest as the duplicate ids to merge. Because addressDelete is irreversible, it defaults to a report and only deletes when you set DRY_RUN=false. Full code, tests, and the reasoning are below.
The problem in plain words
When a shopper places an order with shipping, they fill in an address at checkout. Saleor saves that address as an Address record for the order, and it also copies that address onto the customer's account so it shows up in their saved address book. That copy is a brand new row with its own opaque id.
The catch is that Saleor never looks at the addresses the account already has before adding the new one. So a returning customer who ships to the exact same home on every order ends up with one identical address row per order. The rows all share the same street, city, postal code, country, and name, but Saleor treats them as separate records because it never de-duplicates. The address book quietly fills up with copies, and anything that counts or lists a customer's addresses shows the clutter.
Why it happens
- Saleor stores every order's shipping and billing address as its own
Addressrecord, and copies that address onto the customer account so it appears in the saved address book. - When it saves the copy, Saleor does not compare it against the addresses the account already holds. There is no built-in de-duplication step, so an identical address is written as a fresh row with a new id.
- This is deliberate. An order's address is a frozen historical record of where that order actually shipped, so Saleor keeps it independent rather than pointing many orders at one shared, editable row that a later edit could silently change.
- The side effect is that a returning customer accumulates one duplicate address row per order to the same place, and any query that lists
customer.addressesor counts them reflects the clutter.
This behavior shows up in Saleor's own address model, where addressCreate simply adds a row and there is no reuse-or-create matching. The customer's saved addresses are exposed as the addresses field on the User object, and each is deletable with addressDelete. See the citations at the end for the address mutations and the User.addresses field.
A pile of identical address rows is not corrupted data, it is Saleor keeping each order's address frozen and never merging. The fix is not to force Saleor to reuse addresses at checkout, because the historical copy is intentional. The safe move is to reconcile after the fact: group each customer's saved addresses by a normalized identity key, keep the one the account already treats as its default, and treat the rest as duplicates you can review and, only when you trust the key, delete. Deleting is irreversible, so it stays behind an explicit opt-in.
The fix, as a flow
The script runs on a schedule. It pages through customers and reads each one's addresses. A single pure function groups a customer's addresses by a normalized key, and for every group with more than one row it picks one address to keep, the isDefaultShippingAddress row if present, otherwise the first, and returns the ids of the rest as duplicates. Nothing is deleted while DRY_RUN=true, the default: the script only logs each cluster for review. When you set DRY_RUN=false, it calls addressDelete on each duplicate id, keeping the chosen address untouched.
Build it step by step
Get an app token with customer read and address manage access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read users or customers, and to manage users so it can call addressDelete. 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 only reports until you set it false
// 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 only reports until you set it false
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 customers with their addresses
Ask for customers(first, after) and read back each customer's id, email, and their addresses with the fields that make up the identity: firstName, lastName, streetAddress1, streetAddress2, city, postalCode, country { code }, and isDefaultShippingAddress. Page with a cursor so the job handles a large store without loading everything at once.
CUSTOMERS_QUERY = """
query($cursor: String) {
customers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
email
addresses {
id firstName lastName
streetAddress1 streetAddress2
city postalCode
country { code }
isDefaultShippingAddress
}
}
}
}
}"""
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 CUSTOMERS_QUERY = `
query($cursor: String) {
customers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
email
addresses {
id firstName lastName
streetAddress1 streetAddress2
city postalCode
country { code }
isDefaultShippingAddress
}
}
}
}
}`;
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 plain customers and returns the duplicate clusters. A pure function like this is easy to read and test, which we do later. It normalizes each address into an identity key by lowercasing and collapsing whitespace across streetAddress1, streetAddress2, city, postalCode, country.code, firstName, and lastName, groups a customer's addresses by that key, and for every group with more than one row it keeps the default shipping address if present, otherwise the first, and returns the rest as duplicate ids. It never touches the network and never deletes anything.
def _norm(value):
return " ".join((value or "").strip().lower().split())
def address_key(address):
country = address.get("country") or {}
return (
_norm(address.get("firstName")),
_norm(address.get("lastName")),
_norm(address.get("streetAddress1")),
_norm(address.get("streetAddress2")),
_norm(address.get("city")),
_norm(address.get("postalCode")),
_norm(country.get("code")),
)
def find_duplicate_addresses(customers):
results = []
for customer in customers:
groups = {}
for address in customer.get("addresses") or []:
groups.setdefault(address_key(address), []).append(address)
for key, group in groups.items():
if len(group) < 2:
continue
keep = next(
(a for a in group if a.get("isDefaultShippingAddress")),
group[0],
)
duplicate_ids = [a["id"] for a in group if a["id"] != keep["id"]]
if not duplicate_ids:
continue
results.append({
"customerId": customer["id"],
"email": customer.get("email"),
"key": key,
"keepId": keep["id"],
"duplicateIds": duplicate_ids,
})
return results
function norm(value) {
return (value || "").trim().toLowerCase().split(/\s+/).join(" ");
}
export function addressKey(address) {
const country = address.country || {};
return [
norm(address.firstName),
norm(address.lastName),
norm(address.streetAddress1),
norm(address.streetAddress2),
norm(address.city),
norm(address.postalCode),
norm(country.code),
].join("|");
}
export function findDuplicateAddresses(customers) {
const results = [];
for (const customer of customers) {
const groups = new Map();
for (const address of customer.addresses || []) {
const key = addressKey(address);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(address);
}
for (const [key, group] of groups) {
if (group.length < 2) continue;
const keep = group.find((a) => a.isDefaultShippingAddress) || group[0];
const duplicateIds = group.filter((a) => a.id !== keep.id).map((a) => a.id);
if (duplicateIds.length === 0) continue;
results.push({
customerId: customer.id,
email: customer.email,
key,
keepId: keep.id,
duplicateIds,
});
}
}
return results;
}
Delete only behind an explicit opt-in
Removing a duplicate is a real delete with addressDelete(id: ID!), and there is no undo. So under DRY_RUN=true, the default, the script only logs each cluster: the customer, the address it would keep, and the duplicate ids. When DRY_RUN=false, it calls addressDelete once per duplicate id, and never touches the kept address. Always read the report first and trust your key before you flip the switch.
ADDRESS_DELETE = """
mutation($id: ID!) {
addressDelete(id: $id) {
errors { field message }
}
}"""
def delete_address(address_id):
data = gql(ADDRESS_DELETE, {"id": address_id})
errors = data["addressDelete"]["errors"]
if errors:
raise RuntimeError(errors)
const ADDRESS_DELETE = `
mutation($id: ID!) {
addressDelete(id: $id) {
errors { field message }
}
}`;
async function deleteAddress(id) {
const data = await gql(ADDRESS_DELETE, { id });
const errors = data.addressDelete.errors;
if (errors && errors.length) throw new Error(JSON.stringify(errors));
}
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 clusters and duplicate addresses it found and prints each cluster for a quick look. When DRY_RUN=false, it additionally deletes every duplicate id it flagged, leaving the kept address alone. Report mode is always safe to run again and again on a schedule.
This script deletes real address rows when DRY_RUN=false, and addressDelete has no undo. Always run in report mode first, read every cluster, and confirm the key is not merging two addresses that only look alike. Once you delete, the duplicate rows are gone. Keep the default shipping address as the one to keep so nothing the account points at is ever removed.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through customers with their addresses, groups duplicates with the pure function, always logs the clusters, and deletes the flagged duplicate ids only when DRY_RUN=false. It never removes the address it chose to keep.
"""Find duplicate address rows saved on Saleor customer accounts.
Every Saleor order with a shipping address stores that address as a new row on
the customer's account. Saleor does not de-duplicate against the addresses the
account already has, so a returning shopper who ships to the same place every
time slowly accumulates identical address rows, one per order. The addresses
match on street, city, postal code, country, and name, but each is a separate
row with its own opaque id, and the account address book fills up with copies.
This script pages through customers and their addresses, and a pure function
groups each customer's addresses by a normalized key. Any group with more than
one address is a duplicate cluster. The function keeps one address per cluster,
preferring the default shipping address, and returns the ids of the rest as the
duplicates to merge, so a human can review or an operator can delete them.
Deleting is the risky part, so DRY_RUN defaults to true and the script only
reports the duplicate ids it found. Set DRY_RUN=false to actually call
addressDelete on the extra rows. Run on a schedule. Safe to run again and
again in report mode.
Guide: https://www.allanninal.dev/saleor/duplicate-customer-address-rows/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_duplicate_customer_addresses")
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"
CUSTOMERS_QUERY = """
query($cursor: String) {
customers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
email
addresses {
id
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country { code }
isDefaultShippingAddress
}
}
}
}
}"""
ADDRESS_DELETE = """
mutation($id: ID!) {
addressDelete(id: $id) {
errors { field message }
}
}"""
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(value):
return " ".join((value or "").strip().lower().split())
def address_key(address):
"""Build a normalized identity key for an address. Pure, no I/O."""
country = address.get("country") or {}
return (
_norm(address.get("firstName")),
_norm(address.get("lastName")),
_norm(address.get("streetAddress1")),
_norm(address.get("streetAddress2")),
_norm(address.get("city")),
_norm(address.get("postalCode")),
_norm(country.get("code")),
)
def find_duplicate_addresses(customers):
"""Pure decision function. No I/O.
customers: list of {id, email, addresses: [ {id, firstName, lastName,
streetAddress1, streetAddress2, city, postalCode,
country: {code}, isDefaultShippingAddress} ]}
returns: list of {customerId, email, key, keepId, duplicateIds}, one per
cluster that has more than one matching address.
"""
results = []
for customer in customers:
groups = {}
for address in customer.get("addresses") or []:
groups.setdefault(address_key(address), []).append(address)
for key, group in groups.items():
if len(group) < 2:
continue
keep = next(
(a for a in group if a.get("isDefaultShippingAddress")),
group[0],
)
duplicate_ids = [a["id"] for a in group if a["id"] != keep["id"]]
if not duplicate_ids:
continue
results.append({
"customerId": customer["id"],
"email": customer.get("email"),
"key": key,
"keepId": keep["id"],
"duplicateIds": duplicate_ids,
})
return results
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 delete_address(address_id):
data = gql(ADDRESS_DELETE, {"id": address_id})
errors = data["addressDelete"]["errors"]
if errors:
raise RuntimeError(errors)
def run():
customers = list(all_customers())
clusters = find_duplicate_addresses(customers)
total_dupes = 0
for cluster in clusters:
total_dupes += len(cluster["duplicateIds"])
log.warning(
"Duplicate addresses for %s: keep %s, %d duplicate(s) %s",
cluster["email"],
cluster["keepId"],
len(cluster["duplicateIds"]),
cluster["duplicateIds"],
)
if not DRY_RUN:
for cluster in clusters:
for address_id in cluster["duplicateIds"]:
delete_address(address_id)
log.info("Deleted duplicate address %s", address_id)
log.info(
"Done. %d cluster(s), %d duplicate address(es) found. %s",
len(clusters),
total_dupes,
"Duplicates deleted." if not DRY_RUN else "Dry run, nothing deleted.",
)
if __name__ == "__main__":
run()
/**
* Find duplicate address rows saved on Saleor customer accounts.
*
* Every Saleor order with a shipping address stores that address as a new row
* on the customer's account. Saleor does not de-duplicate against the
* addresses the account already has, so a returning shopper who ships to the
* same place every time slowly accumulates identical address rows, one per
* order. The addresses match on street, city, postal code, country, and name,
* but each is a separate row with its own opaque id, and the account address
* book fills up with copies.
*
* This script pages through customers and their addresses, and a pure
* function groups each customer's addresses by a normalized key. Any group
* with more than one address is a duplicate cluster. The function keeps one
* address per cluster, preferring the default shipping address, and returns
* the ids of the rest as the duplicates to merge.
*
* Deleting is the risky part, so DRY_RUN defaults to true and the script only
* reports the duplicate ids it found. Set DRY_RUN=false to actually call
* addressDelete on the extra rows. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/duplicate-customer-address-rows/
*/
import { pathToFileURL } from "node:url";
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";
function norm(value) {
return (value || "").trim().toLowerCase().split(/\s+/).join(" ");
}
export function addressKey(address) {
const country = address.country || {};
return [
norm(address.firstName),
norm(address.lastName),
norm(address.streetAddress1),
norm(address.streetAddress2),
norm(address.city),
norm(address.postalCode),
norm(country.code),
].join("|");
}
export function findDuplicateAddresses(customers) {
const results = [];
for (const customer of customers) {
const groups = new Map();
for (const address of customer.addresses || []) {
const key = addressKey(address);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(address);
}
for (const [key, group] of groups) {
if (group.length < 2) continue;
const keep = group.find((a) => a.isDefaultShippingAddress) || group[0];
const duplicateIds = group.filter((a) => a.id !== keep.id).map((a) => a.id);
if (duplicateIds.length === 0) continue;
results.push({
customerId: customer.id,
email: customer.email,
key,
keepId: keep.id,
duplicateIds,
});
}
}
return results;
}
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 CUSTOMERS_QUERY = `
query($cursor: String) {
customers(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
email
addresses {
id
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country { code }
isDefaultShippingAddress
}
}
}
}
}`;
const ADDRESS_DELETE = `
mutation($id: ID!) {
addressDelete(id: $id) {
errors { field message }
}
}`;
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;
}
}
async function deleteAddress(id) {
const data = await gql(ADDRESS_DELETE, { id });
const errors = data.addressDelete.errors;
if (errors && errors.length) throw new Error(JSON.stringify(errors));
}
export async function run() {
const customers = [];
for await (const node of allCustomers()) customers.push(node);
const clusters = findDuplicateAddresses(customers);
let totalDupes = 0;
for (const cluster of clusters) {
totalDupes += cluster.duplicateIds.length;
console.warn(
`Duplicate addresses for ${cluster.email}: keep ${cluster.keepId}, ${cluster.duplicateIds.length} duplicate(s)`,
cluster.duplicateIds
);
}
if (!DRY_RUN) {
for (const cluster of clusters) {
for (const id of cluster.duplicateIds) {
await deleteAddress(id);
console.log(`Deleted duplicate address ${id}`);
}
}
}
console.log(
`Done. ${clusters.length} cluster(s), ${totalDupes} duplicate address(es) found. ${DRY_RUN ? "Dry run, nothing deleted." : "Duplicates deleted."}`
);
}
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 addresses get flagged for deletion. Because find_duplicate_addresses is pure, taking plain lists of customers, the test needs no network and no Saleor account. It just feeds in fixture addresses and checks the clusters.
from find_duplicate_customer_addresses import (
address_key,
find_duplicate_addresses,
)
def address(**over):
base = {
"id": "gid://saleor/Address/1",
"firstName": "Jane", "lastName": "Doe",
"streetAddress1": "12 Oak Street", "streetAddress2": "",
"city": "Portland", "postalCode": "97201",
"country": {"code": "US"},
"isDefaultShippingAddress": False,
}
base.update(over)
return base
def customer(addresses, **over):
base = {"id": "gid://saleor/User/1", "email": "jane@example.com", "addresses": addresses}
base.update(over)
return base
def test_key_is_case_and_whitespace_insensitive():
a = address(streetAddress1="12 Oak Street", city="Portland")
b = address(id="a2", streetAddress1=" 12 OAK street ", city="portland ")
assert address_key(a) == address_key(b)
def test_flags_two_identical_addresses():
result = find_duplicate_addresses([customer([address(id="a1"), address(id="a2")])])
assert result[0]["keepId"] == "a1"
assert result[0]["duplicateIds"] == ["a2"]
def test_single_address_is_never_a_duplicate():
assert find_duplicate_addresses([customer([address(id="a1")])]) == []
def test_different_addresses_are_not_grouped():
a1 = address(id="a1", streetAddress1="12 Oak Street")
a2 = address(id="a2", streetAddress1="99 Elm Avenue")
assert find_duplicate_addresses([customer([a1, a2])]) == []
def test_default_shipping_address_is_kept():
a1 = address(id="a1")
a2 = address(id="a2", isDefaultShippingAddress=True)
a3 = address(id="a3")
result = find_duplicate_addresses([customer([a1, a2, a3])])
assert result[0]["keepId"] == "a2"
assert sorted(result[0]["duplicateIds"]) == ["a1", "a3"]
def test_country_participates_in_the_key():
a1 = address(id="a1", country={"code": "US"})
a2 = address(id="a2", country={"code": "CA"})
assert find_duplicate_addresses([customer([a1, a2])]) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { addressKey, findDuplicateAddresses } from "./find-duplicate-customer-addresses.js";
const address = (over = {}) => ({
id: "gid://saleor/Address/1",
firstName: "Jane", lastName: "Doe",
streetAddress1: "12 Oak Street", streetAddress2: "",
city: "Portland", postalCode: "97201",
country: { code: "US" },
isDefaultShippingAddress: false,
...over,
});
const customer = (addresses, over = {}) => ({
id: "gid://saleor/User/1",
email: "jane@example.com",
addresses,
...over,
});
test("key is case and whitespace insensitive", () => {
const a = address({ streetAddress1: "12 Oak Street", city: "Portland" });
const b = address({ id: "a2", streetAddress1: " 12 OAK street ", city: "portland " });
assert.equal(addressKey(a), addressKey(b));
});
test("flags two identical addresses", () => {
const result = findDuplicateAddresses([customer([address({ id: "a1" }), address({ id: "a2" })])]);
assert.equal(result[0].keepId, "a1");
assert.deepEqual(result[0].duplicateIds, ["a2"]);
});
test("single address is never a duplicate", () => {
assert.deepEqual(findDuplicateAddresses([customer([address({ id: "a1" })])]), []);
});
test("default shipping address is kept", () => {
const a1 = address({ id: "a1" });
const a2 = address({ id: "a2", isDefaultShippingAddress: true });
const a3 = address({ id: "a3" });
const result = findDuplicateAddresses([customer([a1, a2, a3])]);
assert.equal(result[0].keepId, "a2");
assert.deepEqual([...result[0].duplicateIds].sort(), ["a1", "a3"]);
});
test("country participates in the key", () => {
const a1 = address({ id: "a1", country: { code: "US" } });
const a2 = address({ id: "a2", country: { code: "CA" } });
assert.deepEqual(findDuplicateAddresses([customer([a1, a2])]), []);
});
Case studies
A subscription customer had eleven copies of the same address in their account
A monthly coffee brand had a customer email in confused about why her saved addresses list showed the same home address over and over, making it hard to pick the right one when she wanted to update her apartment number. She had ordered eleven times, each time from her account, and every order had quietly copied the same address onto the profile as a fresh row.
Running the report script in dry run grouped all eleven rows into one cluster and named the default shipping address as the one to keep. Staff reviewed the report, saw the ten duplicate ids all shared an identical normalized key, and ran the delete pass once. The address book dropped back to a single clean row, and the customer could finally edit the one address that mattered.
An address export ballooned to five times its real size
A homeware store wanted to sync customer addresses into a shipping-label tool and found the export had roughly five times more rows than they had customers. The tool was choking on the volume and charging per address record. The bloat came entirely from repeat buyers accumulating identical address rows, one per order, none of which Saleor had ever merged.
The report script surfaced the scale without deleting anything: a few hundred customers accounted for thousands of duplicate rows. The team reviewed the clusters, confirmed the normalized key was conservative because it included street line two and country, and then ran a scheduled cleanup that kept each customer's default address and removed the rest, shrinking the export to match the real customer count.
After this runs on a schedule, a customer's saved addresses stop drifting into a wall of identical rows. Each duplicate cluster becomes a clearly reported group with the address to keep already chosen, the default shipping row is never removed, and deletes only happen once staff have read the report and set DRY_RUN=false. Nothing gets deleted purely because two rows looked similar, and the account address book stays as small as the number of real places the customer actually ships to.
FAQ
Why does Saleor create a new address every time I order instead of reusing my saved one?
Saleor stores each order's shipping and billing address as its own Address row and attaches a copy to the customer account. It does not compare the incoming address against the addresses already saved, so an identical address becomes a separate row with its own id. That is by design: an order's address is a historical record of where that order shipped, so Saleor keeps it independent from the account's editable address book rather than pointing many orders at one shared, mutable row.
Is it safe to delete the duplicate address rows automatically?
Deleting is the risky part, so do not automate it blindly. addressDelete is irreversible, and if your grouping key is too loose you could delete an address that is not really a duplicate. The safe pattern is to default to a report that lists each cluster, the address it would keep, and the duplicate ids, let a human review it, and only run the delete pass once you trust the key. The script here defaults to DRY_RUN=true and only deletes when you explicitly opt in.
How do I decide which address in a duplicate cluster to keep?
Keep the one the customer or Saleor already treats as canonical. The script keeps the address flagged isDefaultShippingAddress when a cluster contains one, because that is the row the storefront and dashboard already point at, and otherwise keeps the first address seen. The remaining rows in the cluster are returned as the duplicate ids to merge away, so nothing the account relies on as its default is ever removed.
Related field notes
Citations
On the problem:
- Saleor API Reference: the User object and its addresses field. docs.saleor.io/api-reference/users/objects/user
- Saleor API Reference: the Address object. docs.saleor.io/api-reference/miscellaneous/objects/address
- Saleor source: the address models in the account app. github.com/saleor/saleor/blob/main/saleor/account/models.py
On the solution:
- Saleor API Reference: the addressDelete mutation. docs.saleor.io/api-reference/users/mutations/address-delete
- Saleor API Reference: the addressCreate mutation. docs.saleor.io/api-reference/users/mutations/address-create
- 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 tidy up a messy address book for you?
If this saved a support ticket or cleared out a pile of duplicate addresses, 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