Reconciler Customers and data

Duplicate customers for one email in Shopify

The same shopper checked out as a guest, then made an account, then typed their email with a different capital letter one busy Friday. Now Shopify shows three customer records for one person, each with its own slice of order history, store credit, and marketing consent. Here is why Shopify splits them apart and a small script that finds the duplicates and merges them back into one record, safely.

Python and Node.js Admin GraphQL API Safe by default (dry run)
Two young women standing together
Photo by Bardia Golzar on Unsplash
The short answer

Shopify creates a new customer record whenever it sees an email it cannot already match, so a guest checkout, an account, and a re-typed capitalization can all become separate customers that share one real mailbox. Run a small Python or Node.js script that groups customers by a normalized email, keeps the record with the most order history as the survivor, checks Shopify's own customerMergePreview for conflicts, and calls the customerMerge mutation on any clean pair. Full code, tests, and a dry run guard are below.

The problem in plain words

Shopify treats a customer record as tied to an email, but it does not always recognize that two slightly different-looking emails, or a guest order and a later account, belong to the same person. Each time it cannot make that match, it quietly creates a new customer instead of adding to an existing one.

The shopper never notices. They just keep buying. But behind the scenes their order history is now split across two or three records, their lifetime spend looks smaller than it really is, a support agent pulls up the wrong record and sees no past orders, and a loyalty or store credit balance sits on a record the customer does not use anymore. None of this is a bug exactly, it is what happens when nobody tells Shopify these records are the same person.

One shopper buys three times Guest checkout buyer@example.com Makes an account buyer@example.com Re-types the email Buyer@Example.com Customer #1 Customer #2 Customer #3 Shopify never links them
One person, one real mailbox, but three customer records, because Shopify never automatically matched them.

Why it happens

Shopify keys a customer record on the exact email it captured. A few ordinary shopping habits split that into several records without anyone doing anything wrong:

This is a common source of confusion for support teams. An agent looks up a customer by email, sees a thin order history, and assumes the shopper is new, when really half their orders live on a different record. Shopify does offer a manual merge in the Admin, but finding every duplicate pair by hand across a large customer list does not scale, and merging the wrong two records loses nobody's money but does lose time untangling it. See the citations at the end for the exact docs.

The key insight

Merging two customers is a claim that they are the same person. So the safe pattern is not "merge anything that shares an email." It is "merge only a clean pair, and let Shopify itself tell you when it is not clean." We group by a normalized email, keep the record with the most order history as the survivor, and always run Shopify's own customerMergePreview first so a real conflict, like two different default addresses, stops the merge instead of silently picking one.

The fix, as a flow

We do not touch checkout or account creation. We add a job that lists every customer, groups them by a normalized email, and for each group of exactly two customers previews the merge with Shopify's own tool before calling customerMerge. Anything that is not a clean pair, a group of three or more, or a customer tagged as protected, is left alone for a human to review.

Scheduled job runs on a timer List all customers page with a cursor Group by email normalized, case-folded Clean pair, no conflicts, not protected? yes no, skip customerMerge one record remains
The script only merges a clean pair of customers with no conflicting fields and no protected tag. Everything else is skipped for manual review.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_customers and write_customers scopes and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Admin GraphQL API

Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to list customers, preview a merge, and run the mutation.

step2.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": 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"]
step2.js
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List customers and read the fields the decision needs

Ask for every customer's email, how many orders they have, when the record was created, and their amount spent. We page through with a cursor so the job handles a large customer list, and we keep this query read only.

step3.py
CUSTOMERS_QUERY = """
query($cursor: String) {
  customers(first: 50, after: $cursor, sortKey: NAME) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      email
      createdAt
      numberOfOrders
      amountSpent { amount currencyCode }
    }
  }
}"""

def all_customers():
    cursor = None
    while True:
        data = gql(CUSTOMERS_QUERY, {"cursor": cursor})["customers"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const CUSTOMERS_QUERY = `
query($cursor: String) {
  customers(first: 50, after: $cursor, sortKey: NAME) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      email
      createdAt
      numberOfOrders
      amountSpent { amount currencyCode }
    }
  }
}`;

async function* allCustomers() {
  let cursor = null;
  while (true) {
    const data = (await gql(CUSTOMERS_QUERY, { cursor })).customers;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Group by a normalized email, then decide, with pure functions

Fold case and trim whitespace so Buyer@Example.com and buyer@example.com land in the same group, then keep only groups with more than one customer. For each group, a pure decision function picks the survivor, the record with the most order history, using the older record as a tiebreaker, and refuses to guess on anything that is not a clean pair of two. A group of three is left for manual review rather than merged twice in a row automatically. A customer tagged do-not-merge protects the whole group.

decide.py
def normalize_email(email):
    return (email or "").strip().lower()

def group_by_email(customers):
    groups = {}
    for customer in customers:
        key = normalize_email(customer.get("email"))
        if not key:
            continue
        groups.setdefault(key, []).append(customer)
    return {email: nodes for email, nodes in groups.items() if len(nodes) > 1}

def choose_survivor(duplicates):
    return sorted(
        duplicates,
        key=lambda c: (-int(c.get("numberOfOrders") or 0), c.get("createdAt") or ""),
    )[0]

def plan_merge(duplicates):
    if len(duplicates) != 2:
        return {"action": "skip", "reason": "group is not exactly two customers"}
    for customer in duplicates:
        if "do-not-merge" in (customer.get("tags") or []):
            return {"action": "skip", "reason": "a customer in this group is protected"}
    survivor = choose_survivor(duplicates)
    loser = duplicates[0] if duplicates[1] is survivor else duplicates[1]
    return {"action": "merge", "survivor": survivor, "loser": loser}
decide.js
export function normalizeEmail(email) {
  return (email || "").trim().toLowerCase();
}

export function groupByEmail(customers) {
  const groups = new Map();
  for (const customer of customers) {
    const key = normalizeEmail(customer.email);
    if (!key) continue;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(customer);
  }
  const duplicates = {};
  for (const [email, nodes] of groups) {
    if (nodes.length > 1) duplicates[email] = nodes;
  }
  return duplicates;
}

export function chooseSurvivor(duplicates) {
  return [...duplicates].sort((a, b) => {
    const ordersDiff = (b.numberOfOrders || 0) - (a.numberOfOrders || 0);
    if (ordersDiff !== 0) return ordersDiff;
    return (a.createdAt || "").localeCompare(b.createdAt || "");
  })[0];
}

export function planMerge(duplicates) {
  if (duplicates.length !== 2) {
    return { action: "skip", reason: "group is not exactly two customers" };
  }
  for (const customer of duplicates) {
    if ((customer.tags || []).includes("do-not-merge")) {
      return { action: "skip", reason: "a customer in this group is protected" };
    }
  }
  const survivor = chooseSurvivor(duplicates);
  const loser = duplicates[0] === survivor ? duplicates[1] : duplicates[0];
  return { action: "merge", survivor, loser };
}
5

Preview the merge before you commit to it

Shopify has its own customerMergePreview query that tells you what the merged customer would look like and lists any conflictingFields, such as two different default addresses or two different marketing consents. If that list is not empty, skip the merge and let a human choose. This is the safety net that catches the cases a simple email match cannot.

preview.py
MERGE_PREVIEW_QUERY = """
query($customerOneId: ID!, $customerTwoId: ID!) {
  customerMergePreview(customerOneId: $customerOneId, customerTwoId: $customerTwoId) {
    resultingCustomer { defaultEmail }
    conflictingFields { description }
  }
}"""

def merge_preview_is_clean(preview):
    return not (preview or {}).get("conflictingFields")

def preview_merge(customer_one_id, customer_two_id):
    data = gql(MERGE_PREVIEW_QUERY, {"customerOneId": customer_one_id, "customerTwoId": customer_two_id})
    return data["customerMergePreview"]
preview.js
const MERGE_PREVIEW_QUERY = `
query($customerOneId: ID!, $customerTwoId: ID!) {
  customerMergePreview(customerOneId: $customerOneId, customerTwoId: $customerTwoId) {
    resultingCustomer { defaultEmail }
    conflictingFields { description }
  }
}`;

export function mergePreviewIsClean(preview) {
  return !(preview && preview.conflictingFields && preview.conflictingFields.length);
}

async function previewMerge(customerOneId, customerTwoId) {
  const data = await gql(MERGE_PREVIEW_QUERY, { customerOneId, customerTwoId });
  return data.customerMergePreview;
}
6

Merge, then wire it together with a dry run guard

When a group is a clean pair with no conflicting fields, call customerMerge with the survivor first and the loser second. Shopify combines the order history and returns a job id. Always read back userErrors. On the first few runs, leave DRY_RUN on so the script only reports which pairs it would merge. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how quickly duplicates build up, for example once a day.

Run it safe

Always start with DRY_RUN=true, and never merge a group larger than two automatically. Merging is a claim that two records are the same person, so let Shopify's own conflict check and a protected tag give you a way to say no.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only merges a clean pair of duplicate customers that Shopify's own preview reports as conflict free.

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

find_duplicate_customers.py
"""Find Shopify customers who share one email under separate customer records,
and merge the duplicates into a single customer, safely.

A customer with a guest checkout, then an account, then a second guest checkout
under a slightly different capitalized email often ends up as two or three
customer records that all resolve to the same mailbox. Order history, store
credit, and marketing consent all get split across them. This job groups
customers by a normalized email, keeps the record with the most orders as the
survivor (oldest as the tiebreaker), and calls customerMerge to fold the rest
into it. It skips any group that is not a clean two-customer merge, since that
is what the current customerMerge mutation supports, and it skips a group when
Shopify's own merge preview reports a conflicting field that needs a human
pick. 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_duplicate_customers")

SHOP = os.environ.get("SHOPIFY_SHOP", "example.myshopify.com")
TOKEN = os.environ.get("SHOPIFY_ACCESS_TOKEN", "shpat_dummy")
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CUSTOMERS_QUERY = """
query($cursor: String) {
  customers(first: 50, after: $cursor, sortKey: NAME) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      email
      createdAt
      numberOfOrders
      amountSpent { amount currencyCode }
    }
  }
}"""

MERGE_PREVIEW_QUERY = """
query($customerOneId: ID!, $customerTwoId: ID!) {
  customerMergePreview(customerOneId: $customerOneId, customerTwoId: $customerTwoId) {
    resultingCustomer { defaultEmail }
    conflictingFields { description }
  }
}"""

MERGE_MUTATION = """
mutation($customerOneId: ID!, $customerTwoId: ID!) {
  customerMerge(customerOneId: $customerOneId, customerTwoId: $customerTwoId) {
    resultingCustomer { id email }
    jobId
    userErrors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": 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 to_cents(amount):
    return round(float(amount) * 100)


def normalize_email(email):
    """Fold case and trim whitespace, the way most mailbox providers treat an address."""
    return (email or "").strip().lower()


def group_by_email(customers):
    """Group customer nodes by normalized email. Pure, order-preserving."""
    groups = {}
    for customer in customers:
        key = normalize_email(customer.get("email"))
        if not key:
            continue
        groups.setdefault(key, []).append(customer)
    return {email: nodes for email, nodes in groups.items() if len(nodes) > 1}


def choose_survivor(duplicates):
    """Pick which customer record in a duplicate group should stay.

    Prefer the record with more orders (more history worth keeping), then the
    older record as the tiebreaker (created_at ascending). Pure function, no I/O.
    """
    return sorted(
        duplicates,
        key=lambda c: (-int(c.get("numberOfOrders") or 0), c.get("createdAt") or ""),
    )[0]


def plan_merge(duplicates):
    """Decide whether, and how, to merge one group of duplicate customers.

    Returns a dict describing the action:
      {"action": "merge", "survivor": , "loser": }
      {"action": "skip", "reason": ""}

    Only groups of exactly two customers are merged, since customerMerge takes
    exactly two customer ids. A record tagged "do-not-merge" is treated as
    protected and the whole group is skipped, so a support agent can opt a
    customer out.
    """
    if len(duplicates) != 2:
        return {"action": "skip", "reason": "group is not exactly two customers"}

    for customer in duplicates:
        if "do-not-merge" in (customer.get("tags") or []):
            return {"action": "skip", "reason": "a customer in this group is protected"}

    survivor = choose_survivor(duplicates)
    loser = duplicates[0] if duplicates[1] is survivor else duplicates[1]
    return {"action": "merge", "survivor": survivor, "loser": loser}


def merge_preview_is_clean(preview):
    """A merge preview is safe to apply automatically only when Shopify reports
    no conflicting fields that would need a human pick, such as two different
    default addresses or two different marketing consents."""
    return not (preview or {}).get("conflictingFields")


def preview_merge(customer_one_id, customer_two_id):
    data = gql(MERGE_PREVIEW_QUERY, {"customerOneId": customer_one_id, "customerTwoId": customer_two_id})
    return data["customerMergePreview"]


def merge_customers(customer_one_id, customer_two_id):
    result = gql(MERGE_MUTATION, {"customerOneId": customer_one_id, "customerTwoId": customer_two_id})["customerMerge"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["resultingCustomer"]


def all_customers():
    cursor = None
    while True:
        data = gql(CUSTOMERS_QUERY, {"cursor": cursor})["customers"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    customers = list(all_customers())
    groups = group_by_email(customers)
    merged = 0
    skipped = 0
    for email, duplicates in groups.items():
        decision = plan_merge(duplicates)
        if decision["action"] == "skip":
            log.info("Skipping %s: %s", email, decision["reason"])
            skipped += 1
            continue

        survivor, loser = decision["survivor"], decision["loser"]
        preview = preview_merge(survivor["id"], loser["id"])
        if not merge_preview_is_clean(preview):
            log.info("Skipping %s: merge preview has conflicting fields to resolve by hand", email)
            skipped += 1
            continue

        log.info(
            "Duplicate email %s. %s %s into %s",
            email,
            "would merge" if DRY_RUN else "merging",
            loser["id"],
            survivor["id"],
        )
        if not DRY_RUN:
            merge_customers(survivor["id"], loser["id"])
        merged += 1

    log.info("Done. %d group(s) %s, %d skipped.", merged, "to merge" if DRY_RUN else "merged", skipped)


if __name__ == "__main__":
    run()
find-duplicate-customers.js
/**
 * Find Shopify customers who share one email under separate customer records,
 * and merge the duplicates into a single customer, safely.
 *
 * A customer with a guest checkout, then an account, then a second guest
 * checkout under a slightly different capitalized email often ends up as two
 * or three customer records that all resolve to the same mailbox. Order
 * history, store credit, and marketing consent all get split across them.
 * This job groups customers by a normalized email, keeps the record with the
 * most orders as the survivor (oldest as the tiebreaker), and calls
 * customerMerge to fold the rest into it. It skips any group that is not a
 * clean two-customer merge, since that is what the current customerMerge
 * mutation supports, and it skips a group when Shopify's own merge preview
 * reports a conflicting field that needs a human pick. Run on a schedule.
 * Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CUSTOMERS_QUERY = `
query($cursor: String) {
  customers(first: 50, after: $cursor, sortKey: NAME) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      email
      createdAt
      numberOfOrders
      amountSpent { amount currencyCode }
    }
  }
}`;

const MERGE_PREVIEW_QUERY = `
query($customerOneId: ID!, $customerTwoId: ID!) {
  customerMergePreview(customerOneId: $customerOneId, customerTwoId: $customerTwoId) {
    resultingCustomer { defaultEmail }
    conflictingFields { description }
  }
}`;

const MERGE_MUTATION = `
mutation($customerOneId: ID!, $customerTwoId: ID!) {
  customerMerge(customerOneId: $customerOneId, customerTwoId: $customerTwoId) {
    resultingCustomer { id email }
    jobId
    userErrors { field message }
  }
}`;

export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function normalizeEmail(email) {
  return (email || "").trim().toLowerCase();
}

export function groupByEmail(customers) {
  const groups = new Map();
  for (const customer of customers) {
    const key = normalizeEmail(customer.email);
    if (!key) continue;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(customer);
  }
  const duplicates = {};
  for (const [email, nodes] of groups) {
    if (nodes.length > 1) duplicates[email] = nodes;
  }
  return duplicates;
}

export function chooseSurvivor(duplicates) {
  return [...duplicates].sort((a, b) => {
    const ordersDiff = (b.numberOfOrders || 0) - (a.numberOfOrders || 0);
    if (ordersDiff !== 0) return ordersDiff;
    return (a.createdAt || "").localeCompare(b.createdAt || "");
  })[0];
}

export function planMerge(duplicates) {
  if (duplicates.length !== 2) {
    return { action: "skip", reason: "group is not exactly two customers" };
  }
  for (const customer of duplicates) {
    if ((customer.tags || []).includes("do-not-merge")) {
      return { action: "skip", reason: "a customer in this group is protected" };
    }
  }
  const survivor = chooseSurvivor(duplicates);
  const loser = duplicates[0] === survivor ? duplicates[1] : duplicates[0];
  return { action: "merge", survivor, loser };
}

export function mergePreviewIsClean(preview) {
  return !(preview && preview.conflictingFields && preview.conflictingFields.length);
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

async function previewMerge(customerOneId, customerTwoId) {
  const data = await gql(MERGE_PREVIEW_QUERY, { customerOneId, customerTwoId });
  return data.customerMergePreview;
}

async function mergeCustomers(customerOneId, customerTwoId) {
  const result = (await gql(MERGE_MUTATION, { customerOneId, customerTwoId })).customerMerge;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.resultingCustomer;
}

async function* allCustomers() {
  let cursor = null;
  while (true) {
    const data = (await gql(CUSTOMERS_QUERY, { cursor })).customers;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

export async function run() {
  const customers = [];
  for await (const customer of allCustomers()) customers.push(customer);
  const groups = groupByEmail(customers);

  let merged = 0;
  let skipped = 0;
  for (const [email, duplicates] of Object.entries(groups)) {
    const decision = planMerge(duplicates);
    if (decision.action === "skip") {
      console.log(`Skipping ${email}: ${decision.reason}`);
      skipped++;
      continue;
    }

    const { survivor, loser } = decision;
    const preview = await previewMerge(survivor.id, loser.id);
    if (!mergePreviewIsClean(preview)) {
      console.log(`Skipping ${email}: merge preview has conflicting fields to resolve by hand`);
      skipped++;
      continue;
    }

    console.log(`Duplicate email ${email}. ${DRY_RUN ? "would merge" : "merging"} ${loser.id} into ${survivor.id}`);
    if (!DRY_RUN) await mergeCustomers(survivor.id, loser.id);
    merged++;
  }

  console.log(`Done. ${merged} group(s) ${DRY_RUN ? "to merge" : "merged"}, ${skipped} skipped.`);
}

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

Add a test

The grouping and decision functions are the part most worth testing, because they decide which two customer records get folded into one. Because we kept group_by_email, choose_survivor, and plan_merge pure, the tests need no network and no Shopify account. They just feed in plain objects and check the answer.

test_duplicate_customer_grouping.py
from find_duplicate_customers import (
    normalize_email,
    group_by_email,
    choose_survivor,
    plan_merge,
    merge_preview_is_clean,
)


def customer(id, email, orders=0, created="2024-01-01T00:00:00Z", tags=None):
    return {"id": id, "email": email, "numberOfOrders": orders, "createdAt": created, "tags": tags or []}


def test_group_by_email_is_case_insensitive():
    customers = [customer("gid://1", "Buyer@Example.com"), customer("gid://2", "buyer@example.com")]
    groups = group_by_email(customers)
    assert len(groups["buyer@example.com"]) == 2


def test_choose_survivor_prefers_more_orders():
    a = customer("gid://1", "x@example.com", orders=1)
    b = customer("gid://2", "x@example.com", orders=5)
    assert choose_survivor([a, b]) is b


def test_plan_merge_skips_groups_of_three():
    a = customer("gid://1", "x@example.com")
    b = customer("gid://2", "x@example.com")
    c = customer("gid://3", "x@example.com")
    assert plan_merge([a, b, c])["action"] == "skip"


def test_plan_merge_skips_protected_customer():
    a = customer("gid://1", "x@example.com", tags=["do-not-merge"])
    b = customer("gid://2", "x@example.com")
    assert plan_merge([a, b])["action"] == "skip"


def test_merge_preview_is_clean_false_when_conflicts_present():
    preview = {"conflictingFields": [{"description": "Default address"}]}
    assert merge_preview_is_clean(preview) is False
duplicate-customers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { groupByEmail, chooseSurvivor, planMerge, mergePreviewIsClean } from "./find-duplicate-customers.js";

const customer = (id, email, { orders = 0, created = "2024-01-01T00:00:00Z", tags = [] } = {}) =>
  ({ id, email, numberOfOrders: orders, createdAt: created, tags });

test("groupByEmail is case insensitive", () => {
  const customers = [customer("gid://1", "Buyer@Example.com"), customer("gid://2", "buyer@example.com")];
  const groups = groupByEmail(customers);
  assert.equal(groups["buyer@example.com"].length, 2);
});

test("chooseSurvivor prefers more orders", () => {
  const a = customer("gid://1", "x@example.com", { orders: 1 });
  const b = customer("gid://2", "x@example.com", { orders: 5 });
  assert.equal(chooseSurvivor([a, b]), b);
});

test("planMerge skips groups of three", () => {
  const a = customer("gid://1", "x@example.com");
  const b = customer("gid://2", "x@example.com");
  const c = customer("gid://3", "x@example.com");
  assert.equal(planMerge([a, b, c]).action, "skip");
});

test("planMerge skips a protected customer", () => {
  const a = customer("gid://1", "x@example.com", { tags: ["do-not-merge"] });
  const b = customer("gid://2", "x@example.com");
  assert.equal(planMerge([a, b]).action, "skip");
});

test("mergePreviewIsClean false when conflicts present", () => {
  assert.equal(mergePreviewIsClean({ conflictingFields: [{ description: "Default address" }] }), false);
});

Case studies

Guest checkout plus account

The loyalty program that never added up

A skincare brand ran a loyalty tier based on lifetime spend. A regular customer had checked out as a guest for her first two orders, then created an account for later ones. Support saw only the account's orders and told her she was short of the next tier, when her real spend across both records already qualified.

Running the merge job in dry run surfaced the pair immediately, with a clean preview and no conflicting fields. One real merge later, her account showed the full history and the tier calculation was correct without anyone editing spend by hand.

Capitalization mismatch

Two records from one typo-free but differently cased email

A subscription box store noticed its customer count kept climbing faster than new signups should explain. A look at the data showed hundreds of pairs where the same address appeared once in lowercase and once with a capital letter, mostly from a checkout field on an older theme that did not normalize input.

The script grouped them by normalized email, found dozens of clean pairs in the first run, and merged them once the store owner reviewed the dry run list. The remaining unmerged groups were mostly three-or-more clusters flagged for a manual look, exactly as designed.

What good looks like

After this runs on a schedule, a shopper who checks out three different ways still ends up as one customer with one order history, one store credit balance, and one accurate lifetime spend. Support sees the whole picture on the first lookup, and reporting stops undercounting your best customers. Keep the group-of-two rule and the merge preview check, since together they are what keeps the script from ever guessing.

FAQ

Why does Shopify create more than one customer for the same person?

Shopify creates a customer record the first time an email is used at checkout. A guest checkout, then an account signup, then a slightly different capitalization of the same address can each create a separate record, because Shopify does not always match them automatically. Each record keeps its own order history, store credit, and marketing consent until someone merges them.

Is it safe to merge Shopify customers with a script?

Yes, when the script only merges a clean pair of records that share one normalized email, checks Shopify's own customerMergePreview for conflicting fields first, and skips anything tagged do-not-merge. Running in dry run first lets you review the exact list before anything is written.

What does the customerMerge mutation actually do?

customerMerge combines two customer records into one, moving order history, store credit, and other data onto the resulting customer and returning a job you can poll for completion. It only accepts two customer ids at a time, so groups of three or more duplicates need to be merged in pairs.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: customer profiles and how duplicate customers can appear. help.shopify.com/en/manual/customers
  2. Shopify Help Center: merging customer profiles in the Admin. help.shopify.com/en/manual/customers/manage-customers/customer-profile-merging
  3. Shopify Community: duplicate customer records after guest checkout and account creation. community.shopify.com graphql admin api

On the solution:

  1. Shopify Admin GraphQL: the customerMerge mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/customerMerge
  2. Shopify Admin GraphQL: the customerMergePreview query and its conflictingFields. shopify.dev/docs/api/admin-graphql/latest/queries/customerMergePreview
  3. Shopify Admin GraphQL: the customers query and the Customer object. shopify.dev/docs/api/admin-graphql/latest/queries/customers

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, or customer data that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clean up your customer list?

If this saved you a pile of manual lookups or a wrong lifetime spend, 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 Shopify field notes