To migrate customers to Shopify through the Admin API, upsert each record with customerSet, keyed on email or phone, then write marketing consent in a second call with customerEmailMarketingConsentUpdate, passing the original opt-in date. Passwords cannot move. Since legacy customer accounts were deprecated in February 2026, customers sign in with a one-time code instead, so nobody needs a reset email.
Every GraphQL operation below was validated against Admin API version 2026-07.
Why can’t customerSet set marketing consent?
Because the field isn’t there. customerSet became stable in 2025-04, when Shopify added upserts to productSet and customerSet. Its input type, CustomerSetInput, takes names, email, phone, addresses, tags, locale, note and tax settings. There is no emailMarketingConsent, no smsMarketingConsent and no metafields. Send one anyway and validation fails with:
Field "emailMarketingConsent" is not defined by type "CustomerSetInput".
The older CustomerInput, used by customerCreate, does accept consent. It has no upsert, though. Run a customerCreate import twice and the second pass fails on every email that already exists, so you end up writing your own lookup-then-create logic around it.
So split the work. customerSet handles identity and profile; customerEmailMarketingConsentUpdate handles email consent and customerSmsMarketingConsentUpdate handles SMS. Both consent mutations set a state rather than append to one, so running them again with the same input leaves the customer where they were.
How do you migrate customers to Shopify with the Admin API?
Export customers from the old platform to JSON first, and keep three things per subscriber: the consent state, whether it was double opt-in, and the timestamp. The script below assumes rows shaped like { email, firstName, lastName, address, emailConsent: { state, doubleOptIn, at } } and an Admin API token with the write_customers scope.
// import-customers.mjs (Node 20+): SHOP=x.myshopify.com SHOPIFY_ADMIN_TOKEN=... node import-customers.mjs
import { readFile } from "node:fs/promises";
const ENDPOINT = `https://${process.env.SHOP}/admin/api/2026-07/graphql.json`;
const UPSERT = `
mutation UpsertCustomer($identifier: CustomerSetIdentifiers, $input: CustomerSetInput!) {
customerSet(identifier: $identifier, input: $input) {
customer { id }
userErrors { field message code }
}
}`;
const CONSENT = `
mutation SetEmailConsent($input: CustomerEmailMarketingConsentUpdateInput!) {
customerEmailMarketingConsentUpdate(input: $input) {
customer { defaultEmailAddress { marketingState marketingUpdatedAt } }
userErrors { field message code }
}
}`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function gql(query, variables) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": process.env.SHOPIFY_ADMIN_TOKEN,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
// Pace against the leaky bucket rather than waiting to be throttled.
const status = body.extensions?.cost?.throttleStatus;
if (status && status.currentlyAvailable < 100) {
await sleep(((100 - status.currentlyAvailable) / status.restoreRate) * 1000);
}
return body.data;
}
const rows = JSON.parse(await readFile("customers.json", "utf8"));
for (const row of rows) {
const { customerSet } = await gql(UPSERT, {
identifier: { email: row.email },
input: {
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
tags: ["migrated"],
// List fields replace what is there, so omit them rather than send [].
...(row.address && { addresses: [row.address] }),
},
});
if (customerSet.userErrors.length) {
console.error(row.email, customerSet.userErrors);
continue;
}
// No recorded consent: leave the customer at Shopify's default.
if (!row.emailConsent) continue;
const subscribed = row.emailConsent.state === "SUBSCRIBED";
const { customerEmailMarketingConsentUpdate: result } = await gql(CONSENT, {
input: {
customerId: customerSet.customer.id,
emailMarketingConsent: {
marketingState: subscribed ? "SUBSCRIBED" : "UNSUBSCRIBED",
consentUpdatedAt: row.emailConsent.at, // ISO 8601, from the old platform
...(subscribed && {
marketingOptInLevel: row.emailConsent.doubleOptIn ? "CONFIRMED_OPT_IN" : "SINGLE_OPT_IN",
}),
},
},
});
if (result.userErrors.length) console.error(row.email, result.userErrors);
}
People who explicitly unsubscribed on the old store get written as UNSUBSCRIBED with their date. Skip them and they sit at the default, with nothing on the record to say they ever said no. The email consent mutation accepts only SUBSCRIBED, UNSUBSCRIBED and PENDING; NOT_SUBSCRIBED, REDACTED and INVALID are internal states you cannot set.
consentUpdatedAt carries the original date. Leave it out and Shopify records the moment of the import instead, so a customer who subscribed in 2019 looks as though they signed up on migration day. If a customer ever asks when they agreed to marketing, you want the real answer on file.
The opt-in level comes from CustomerMarketingOptInLevel: SINGLE_OPT_IN, CONFIRMED_OPT_IN or UNKNOWN. When the old platform never tracked it, send UNKNOWN rather than rounding up to CONFIRMED_OPT_IN.
The pacing reads throttleStatus from each response. According to Shopify’s rate limit docs, a mutation costs 10 points by default and a Standard plan store restores 100 points a second, which works out at roughly ten mutations a second. Plus restores 1,000. At two mutations per consenting customer, 50,000 customers means up to 100,000 mutations, close to three hours on a Standard store. That belongs in the cutover plan, not in a surprise on the night.
SMS follows the same pattern with customerSmsMarketingConsentUpdate, with two differences: the customer needs a phone number on the record first, and marketingOptInLevel is required rather than optional.
What happens to customer passwords?
They stay behind. Shopify’s customer import guide says passwords are encrypted outside Shopify and cannot be migrated, and its old advice was to invite every imported customer to create a new one.
That advice has aged. Shopify deprecated legacy customer accounts from 19 February 2026. They are unavailable to new stores and to stores not already using them, and as of September 2026 the final sunset date is still to be announced. The current customer accounts sign customers in with an email address and a six-digit one-time code, so a migrated customer enters the email the old store had and receives a code. There is no password to carry over and no invite campaign to run.
Headless builds are the exception. If your storefront signs customers in through Storefront API customer mutations, the same changelog entry directs custom storefronts to the Customer Account API instead, and that work should land before the customer import, since it changes how those customers will sign in.
When is the CSV import good enough?
For a small store with no marketing list worth protecting, the admin CSV import is fine. Know what it drops before you pick it.
The Accepts Email Marketing and Accepts SMS Marketing columns take yes or no, and that is all. There is no column for the consent date or the opt-in level, so every subscriber arrives looking the same. Files are capped at 15 MB. Duplicate emails or phone numbers are skipped, and choosing to overwrite existing customers replaces all of their data rather than merging it.
Past a few thousand customers, or once the email list drives real revenue, we would use the API.
What breaks when you run the import twice?
You will run it more than once: a trial import on a development store, a full import before cutover, then a delta import of everyone who registered on the old site during the freeze. The script copes because customerSet with an email identifier updates the existing record instead of failing.
The list fields are where it bites. The customerSet docs say that for list fields “all existing entries not included will be deleted”. An address a customer added on the new store between runs disappears if your second pass sends only the old address. Tags behave the same way, so tags: ["migrated"] wipes any tag the merchant added by hand. For the delta run, send only the fields that changed, or skip customers updated in Shopify since the last pass.
Customers with no email need phone as the identifier instead. If the old platform had its own customer IDs, customerSet also accepts a customId identifier backed by an id-type metafield definition, whose values are unique by default. The docs don’t show a customerSet example using it, so prove it on a development store before you depend on it.
When the import finishes, pull the customers back out with a bulk operation query and diff the counts and consent states against the source. A script that logged no errors can still have skipped rows you never fed it.
What we would do
For any store with a real marketing list, use customerSet plus the consent mutations, and carry the original consent dates across. Use the CSV import only when the list is small and consent history genuinely doesn’t matter. Either way, pair it with a 301 map for the old URLs, because the customer import does nothing for the search traffic pointing at the old store’s pages.
Whoooop migrates stores onto Shopify, including the customer and consent data that CSV templates flatten. Our Shopify development work covers the import scripts, the cutover and the checks afterwards.