Rob Scott

Case study

Migrating millions of Shopify customers to a new regional store with zero downtime

Feb 2026Data migration5 min

A fashion retailer opening a new Shopify store for the UK and EU needed its customers there moved across from its Australian store, consent and all. I designed and led a migration that ran while the business kept trading. The new store opened with its customers in place.

Client
Fashion retailer, Shopify
Route
AU store (and US) to a new UK and EU store
Role
Architecture and delivery lead, at Comet CX
Timeline
April to September 2024
Stack
Firebase Cloud Functions, Shopify Admin API

Shopify treats every store as its own island. A customer ID means nothing outside the store that issued it, and there's no built-in way to move a customer from one store to another.

A fashion retailer I worked with needed to open a new Shopify store to serve their UK and EU customers. Until then, those customers had mostly been served by the Australian store, which reached them through Shopify Markets. They had to arrive in the new store with their accounts, addresses and email consent intact.

The hard requirement was that nothing could stop. The business trades around the clock across regions. A maintenance window, or an email asking people to register again, would cost real sales. The migration had to run alongside normal trading.

Three decisions that shaped everything

I wrote the plan before there was any code. It went into the repository on day one as a numbered checklist in the webhook handler, and the system that shipped still follows it.

Only move the customers who matter

A customer qualified if they had placed at least one order and had a default address in one of the 39 countries the new store would serve. Accounts that had never bought anything, such as newsletter sign-ups and abandoned checkouts, stayed behind. That cut the volume a lot, and it meant the new store opened without a pile of inherited dead records.

One sync, two ways in

The usual way to migrate a store is to freeze it, copy everything and switch over. We couldn't afford a freeze, so I split the work into two paths that share one sync function.

Source stores AU store US store still trading customers real-time path batchSyncCustomerV2 backfill syncCustomer() one code path UK store new webhook paged GET one customer 100 per page writes tag UKCustomerId:…
One function, two ways in. Webhooks push live changes. The backfill pulls every existing customer. Both go through the same sync, which writes to the new store and leaves a forwarding tag on the source customer.

Webhooks copy every live change to the new store as it happens. Meanwhile a backfill works through the existing customers in the background. The sync is idempotent, meaning it produces the same result however many times it runs, so the two paths never conflict. If a customer changes their address halfway through, the webhook sends the change. If the backfill reaches someone the webhook already handled, it just updates the record. Whichever path gets there first, the new store ends up matching the source.

That's why there was no downtime. The live path kept every record current while the backfill caught up, so there was never a moment that needed freezing. The result is a simple system: no queue, no extra database, just one sync that can safely run as often as it needs to.

Let the stores remember

A migration needs a record of which old ID became which new ID. Instead of building and hosting a separate database for that, I had the stores hold it themselves as Shopify tags.

AU store customer 7012345678 TAGS newsletter UKCustomerId:8123456789 UK store customer 8123456789 TAGS newsletter Migrated:AU AUCustomerId:7012345678
The stores hold their own mapping. Each side carries the other's ID as a tag, so there was no separate database to build, host or reconcile. IDs are illustrative.

That meant no extra infrastructure to keep consistent, and it gave the client things a hidden database couldn't. Their support team could open any customer in the Shopify admin and see where they'd come from. Marketing could filter on Migrated:AU in their segments straight away.

The same tags let the sync recognise a customer it had already moved and update them instead of creating a duplicate. It also matched people by email, so someone with more than one account became a single customer in the new store.

Keeping email consent up to date

Moving consent once wasn't enough. It had to stay current. A customer who unsubscribed from the Australian store the day before launch couldn't wake up subscribed in the new one, and someone who opted in had to be reachable from day one. Under UK and EU rules, getting this wrong isn't just a bad customer experience.

Because consent travels through the same sync as everything else, the live path carried every subscribe and unsubscribe across as it happened, and the backfill brought over each customer's current state rather than a snapshot from weeks earlier. Older records sometimes had consent data the API rejected, such as a subscription missing its opt-in level. I fixed those in the mapping instead of dropping them, so customers who were subscribed arrived subscribed, and customers who weren't stayed that way.

Launch

We launched on time by turning on the geo redirect. From the first visit, the new store had its customers in place, with their accounts, addresses and up-to-date email consent. Customers never had to do anything.

Built on Firebase Cloud Functions and the Shopify Admin API (2024-04). Customer IDs in the diagrams are illustrative.

All writing