The counts match. 65 products don’t.

A working cutover check for Shopify migrations. I built it as my application for Execution Space’s contract Shopify developer role.

Open the check
reconcile-catalogstale stale · 05:20 UTC
  • 412 products matched · 1,186 variants matched · 0 lost
  • 68 fields wrong on 65 products
  • 2 products need a person
  • Gate: BLOCK
  • Stale · last successful comparison 05:20 UTC, 5 Oct 2026 (run #101). fetched more than 15 minutes ago

Each mark is one product. A lit mark arrived wrong. A ring needs a person. Independent work on a synthetic catalog, not affiliated with Execution Space.

Row 001The question

Counts show how many arrived. They cannot show what changed.

Your migration page (opens in a new tab) says to reconcile the counts, then “spot-check the edges”. That is the right order.

This page is what I would add as your contractor. It runs that spot-check on every record and every mapped field, and gives each failure a reason and an owner. When the full check comes back clean, the spot-check has little left to find.

Everything below runs in your browser on a synthetic catalog. Nothing of yours or your clients’ is connected.

Row 002The demonstration

Change one rule. Watch one record.

The panel holds six decisions a migration script makes about this catalog. It opens in a state I would call plausible for a careful team: three decisions right, three quietly wrong. Flip any of them and everything beside it is recalculated from the catalog: the record, the ledger, the redirect map and the gate.

Tool 1 · Diagnose

Follow one record across the cutover

SKU 00417 is a case of 24 mugs, on sale, renamed once since 2019. It arrives in the new store with the right count and, as found, three wrong fields.

record 00417stale 3 of 6 fields wrong

Speckled Enamel Mug 12 oz, Case of 24

Camp Kitchen

Address/product/speckled-camp-mug-12oz//products/speckled-enamel-mug-12-oz-case-of-24Blockrenamed. The old address /product/speckled-camp-mug-12oz/ no longer matches the new handle.
SKU00417417Blockchanged. A spreadsheet read the SKU as a number. Inventory and ERP sync look the item up by this value.
Price$28.80 on sale, $34.00 regular$28.80, compare-at $34.00matches
Case pack24 per caseemptyBlockcleared. The case-pack custom field has no mapping, so the metafield arrived empty.
At zero stockkeeps sellingkeeps sellingmatches
Visibilitypublicpublicmatches

Where this record is used next

On the product page

The case-pack section finds no pack size and hides itself. The shopper sees $28.80 with no unit price and no pack-size line.

At the old address
  1. GET /product/speckled-camp-mug-12oz/
  2. 301 to /products/speckled-camp-mug-12oz
  3. 404. That handle does not exist.
In the warehouse

In this model the ERP matches stock by SKU. It asks for 00417. The store holds 417. No match, so stock for this item would stop syncing.

Then every record, the same way

Records are matched on the source id carried into the new store, never on SKU. A SKU that changed in transit would otherwise read as one record lost and one created.

reconcile-catalogstale 70 rows flagged

412 products matched · 1,186 variants matched · 0 lost. 68 fields wrong on 65 products. 2 products need a person.

Flagged records, most serious first
GateRecordFieldOld storeNew storeDetails
BlockBirch Camp SockAP-1069-S-SNDHandlecamp-sock-birchbirch-camp-sock
BlockBirch Tin CandleCN-1020-08Handletin-candle-birchbirch-tin-candle
BlockBirch Trail MapPG-1022Handletrail-map-birchbirch-trail-map
BlockCedar Cast Iron Skillet00404SKU00404404
BlockCedar Dutch Oven00420SKU00420420
BlockCedar Field NotebookPG-1003Case pack12 per caseempty
BlockCedar Flannel ShirtAP-1003-S-RSTHandleflannel-shirt-cedarcedar-flannel-shirt
BlockCedar Kettle00412SKU00412412

Tool 2 · Decide

Build the redirect map from what arrived

Every product URL changes shape in this move, from /product/ to /products/. The question is where the last segment comes from.

redirects.csvstale 410 rows
Build the map from

Rewrites /product/slug/ to /products/slug and assumes the handle survived.

410redirects in the map

24point at a page that does not exist

0chains

0duplicate sources

The pattern map is right for 386 URLs and wrong for exactly the 24 products whose handle changed. A pattern cannot know which ones those are.

A sample of the redirect map, dead destinations first
Redirect fromRedirect toLands on
/product/steel-bottle-cedar//products/steel-bottle-cedar404
/product/canteen-ember//products/canteen-ember404
/product/kettle-ridgeline//products/kettle-ridgeline404
/product/speckled-camp-mug-12oz//products/speckled-camp-mug-12oz404
/product/cutting-board-ember//products/cutting-board-ember404
/product/ridgeline-enamel-mug//products/ridgeline-enamel-mug200

Shopify redirects are one to one, fire only from addresses that no longer load, and cannot start from reserved paths such as /shop or /cart. Source: Shopify Help Center, URL redirects (opens in a new tab), read October 3, 2026.

Tool 3 · Verify

Read the first two days against the map

After cutover the old addresses keep arriving. This sorts them into Covered, Dead end, Add, Reserved and Ignore, using the map above. One of them, /shop/, no redirect can fix, because Shopify reserves it.

old-urls.logstale first 48 hours, synthetic

23%of page requests reach a live page (140 of 603 hits). Bots, feeds, old image paths and reserved paths are left out.

3 requested addresses already have a redirect in the map, but it points at a missing page. Adding redirects will not fix them. Build the map from reconciled records, or keep the old handles.

Requested old addresses and what happens to each
RequestedHitsResultWhat to do
/wp-login.php310IgnoreBot traffic hunting for a WordPress login. Nothing to redirect.
/shop/212ReservedShopify reserves /shop and will not redirect from it. Repoint menus, emails and ads at /collections/all.
/product/speckled-camp-mug-12oz/146Dead endThe map has this URL, but it points at a handle that does not exist.
/xmlrpc.php122IgnoreBot traffic. Nothing to redirect.
/blog/how-to-season-cast-iron/88AddRedirect to /blogs/journal/how-to-season-cast-iron. Blog posts moved under /blogs/. The most linked article on the old site.
/product/juniper-camp-tumbler/71CoveredCovered.
/my-account/64AddRedirect to /account. Customer accounts moved. Passwords do not transfer, so this page also needs the reset message.
/wp-content/uploads/2021/05/enamel-mug.jpg57IgnoreAn old image path. Fix the page that links to it, or redirect the file to its new address.
/product/trailhead-headlamp-2/49Dead endThe map has this URL, but it points at a handle that does not exist.
/spring-sale/41AddRedirect to /collections/sale. A campaign link still printed on packaging inserts.
/product-category/apparel/?filter_color=navy33CoveredCovered by the path. The filter in the query string may not carry over, so test it.
/product/steel-bottle-cedar/29Dead endThe map has this URL, but it points at a handle that does not exist.
/wholesale-catalog/27AddRedirect to /collections/wholesale. The wholesale landing page that trade emails point to.
/product-category/camp-kitchen/22CoveredCovered.
/product-tag/enamel/19AddRedirect to /collections/drinkware. No exact equivalent. Send them to the nearest collection.
/feed/18IgnoreThe old RSS feed. Only worth a redirect if subscribers matter.
/product/harbor-quilted-throw/14CoveredCovered.

The requested addresses are a synthetic 17-line sample, not traffic measured on this site.

Take the result with you

  • redirects.csv

    410 rows for the invented catalog, in the two columns Shopify imports: Redirect from, Redirect to. Built from the map as it stands above. Do not import it into a real store.

    Download redirects.csv
  • exceptions.csv

    70 flagged rows with the gate, both values, the reason, the action, a Status to work down and an empty Owner column. Open it with the SKU column set to text, or use the workbook, so leading zeros stay.

    Download exceptions.csv
  • cutover-check-workbook.xlsx

    Six sheets: how to use it, the as-found reconciliation, the redirect map, a gate made of formulas, the old-URL watch and the assumptions. Replace the rows with your own and the gate recounts. Static example of the as-found run, not the live comparison above.

    Download the workbook

Row 003One decision, argued

Keep the old handles, or clean them up?

Every tool above depends on this one choice. Each option costs something, so here is how I would decide it for this catalog and what would change my mind.

Option A

Keep every old handle

Old slugs become handles as they are. All 402 product URLs still need a redirect, because /product/ becomes /products/, but the last segment never changes.

Cost
24 products keep an address that no longer matches their name. The mug stays speckled-camp-mug-12oz.
Risk
Lowest. A pattern-built map and a reconciled map give the same answer.

Option B

Rebuild handles from titles

Clean, consistent addresses that match what the product is called today. 24 of 402 live products change their last segment.

Cost
The redirect map becomes the only thing between those 24 pages and a 404.
Risk
Highest if the map is built from a pattern: it fails on exactly those 24. Safe only when the map is built from reconciled records.

Option C

Keep now, rename later in small batches

Cut over with old handles. Once rankings and 404s have settled, rename the handful that matter, a few at a time, each with its redirect.

Cost
Two passes instead of one, and untidy addresses for a few weeks.
Risk
Low. If a rename hurts a page, the cause is one known change.

My call

Option A at cutover, then Option C. A replatform already changes the platform, the theme, the URLs and the page speed at once. If traffic drops afterwards, I want one fewer thing to suspect, and keeping handles gives me that. The cost is some untidy addresses.

What would change it

  • Search Console showing the renamed pages earn almost no organic traffic. Then clean them now.
  • A rebrand where the old names must be gone on launch day.
  • An ERP or product feed that matches products by handle. Then renaming a handle changes data as well as the URL.

Row 004Four files

The Shopify side, written for a real store

The demonstration runs on synthetic data in your browser. These four files are the Shopify half of the same check on a client’s catalog. None has run against a live store yet. Each says how far it has been checked.

shopify/scripts/export-catalog.mjs

A Node script that pulls every product and variant with one bulk operation and writes the target side of the reconciliation.

Queries validate against the Admin GraphQL schema. Not yet run against a store.

#!/usr/bin/env node
/**
 * Exports the catalog from a Shopify store as the "target" side of the
 * reconciliation: one JSON array of products with their variants.
 *
 *   SHOP=example.myshopify.com SHOPIFY_ADMIN_TOKEN=shpat_... node export-catalog.mjs > target.json
 *
 * Needs Node 20 or later and an Admin API access token for an app with the
 * read_products scope, installed on the store: a Dev Dashboard app using the
 * client credentials grant (stores in the app's own organization only; the
 * token lasts 24 hours), or the shpat_ token of a custom app already created in
 * the store's admin. Shopify no longer allows new admin-created custom apps.
 *
 * Uses a bulk operation, so it is one request to start and one file to read,
 * whatever the size of the catalog.
 *
 * Status: every query here validates against the Admin GraphQL schema
 * (checked October 4, 2026). The script has not yet been run against a store.
 */
import { readFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { Readable } from 'node:stream';

const { SHOP, SHOPIFY_ADMIN_TOKEN, SHOPIFY_API_VERSION = '2026-07' } = process.env;
if (!SHOP || !SHOPIFY_ADMIN_TOKEN) {
  console.error('Set SHOP and SHOPIFY_ADMIN_TOKEN.');
  process.exit(1);
}

const endpoint = `https://${SHOP}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`;

async function admin(query, variables = {}) {
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': SHOPIFY_ADMIN_TOKEN },
    body: JSON.stringify({ query, variables }),
  });
  if (!response.ok) throw new Error(`Admin API ${response.status}: ${await response.text()}`);
  const body = await response.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

// Strip the comment lines: the bulk operation takes the bare query.
const catalogQuery = (await readFile(new URL('../graphql/catalog-export.graphql', import.meta.url), 'utf8'))
  .split('\n')
  .filter((line) => !line.trimStart().startsWith('#'))
  .join('\n');

const started = await admin(
  `mutation RunCatalogExport($query: String!) {
    bulkOperationRunQuery(query: $query) {
      bulkOperation { id status }
      userErrors { field message }
    }
  }`,
  { query: catalogQuery },
);

const { bulkOperation, userErrors } = started.bulkOperationRunQuery;
if (userErrors.length) throw new Error(JSON.stringify(userErrors));

// Poll by id. bulkOperation(id:) is available from API version 2026-01, where currentBulkOperation is deprecated.
let operation = bulkOperation;
while (operation.status === 'CREATED' || operation.status === 'RUNNING') {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  const data = await admin(
    `query ExportStatus($id: ID!) {
      bulkOperation(id: $id) { id status errorCode objectCount url }
    }`,
    { id: operation.id },
  );
  operation = data.bulkOperation;
  console.error(`${operation.status} · ${operation.objectCount} objects`);
}
if (operation.status !== 'COMPLETED') throw new Error(`Export ${operation.status}: ${operation.errorCode ?? 'no error code'}`);
if (!operation.url) {
  console.log('[]');
  process.exit(0);
}

/**
 * How the check reads Shopify's product status. ARCHIVED counts as draft: neither has a public page.
 * UNLISTED (reachable by link, kept out of listings and search) is left out on purpose. Whether it
 * counts as public depends on the migration, so the export stops and asks instead of guessing.
 */
const STATUS = { ACTIVE: 'active', DRAFT: 'draft', ARCHIVED: 'draft' };

/** The old store's id, from the migration.source_id metafield. Missing is allowed; garbage is not. */
function sourceIdOf(row) {
  if (!row.sourceId) return null;
  const id = Number(row.sourceId.value);
  if (!Number.isInteger(id)) throw new Error(`${row.id}: migration.source_id is "${row.sourceId.value}", not a number.`);
  return id;
}

const toCents = (money) => (money === null ? null : Math.round(Number(money) * 100));

// The file is JSONL, one object per line. Each variant names its product in __parentId.
// It is read line by line so a large catalog never sits in memory as one string.
const download = await fetch(operation.url);
if (!download.ok || !download.body) throw new Error(`Export file ${download.status}: could not download it.`);

const products = new Map();
const variants = [];
for await (const line of createInterface({ input: Readable.fromWeb(download.body), crlfDelay: Infinity })) {
  if (!line.trim()) continue;
  const row = JSON.parse(line);
  if (row.__parentId) {
    variants.push(row);
    continue;
  }
  const status = STATUS[row.status];
  if (!status) throw new Error(`${row.id}: product status ${row.status} is not mapped. Decide how the check should treat it, then add it to STATUS.`);
  products.set(row.id, {
    sourceId: sourceIdOf(row),
    handle: row.handle,
    title: row.title,
    status,
    casePack: row.casePack ? Number(row.casePack.value) : null,
    variants: [],
  });
}

for (const row of variants) {
  const product = products.get(row.__parentId);
  if (!product) throw new Error(`${row.id}: variant with no product in the export (parent ${row.__parentId}).`);
  product.variants.push({
    sourceId: sourceIdOf(row),
    sku: row.sku ?? '',
    options: row.selectedOptions.map((option) => [option.name, option.value]),
    price: toCents(row.price),
    compareAtPrice: toCents(row.compareAtPrice),
    inventoryPolicy: row.inventoryPolicy === 'CONTINUE' ? 'continue' : 'deny',
  });
}

console.log(JSON.stringify([...products.values()], null, 2));

Download the four files

Validated October 4, 2026. Bulk operation status is read with bulkOperation(id:), available from API version 2026-01, where currentBulkOperation is deprecated (Shopify dev docs (opens in a new tab)).

Row 005Method and limits

What is known, what is modeled, what is untested

This is an independent demonstration. It uses only Execution Space’s public pages and invented data. It is not a diagnosis of Execution Space or of any client. Here is exactly what stands behind each part.

Known

Public, read October 3, 2026

Modeled

Synthetic, generated from a fixed seed

  • The catalog: 412 products and 1,186 variants for an invented store. No real merchant’s data is used.
  • The proportions of renamed, case-sold and draft products are my assumptions, not measurements of any store.
  • The six rules: common ways a catalog import goes wrong. The SKU damage reproduces what a spreadsheet does to a column left as General.
  • The old-URL log: seventeen invented lines, with product paths drawn from the catalog.

Proposed

Not measured

  • That a full row-level check before cutover leaves the spot-check with little to find.
  • That keeping handles at cutover is the safer default for this kind of catalog.
  • The first engagement described on the About page, including its scope and how to judge it.

Untested

Would need a development store

  • The export script against a live Admin API. Its queries validate against the schema and that is all.
  • The Liquid section in a theme. It passes Theme Check and has not been previewed in one.
  • How Shopify treats a trailing slash and a query string in “Redirect from”. Its help page says query strings may not work as expected.
  • Scale. The browser handles this catalog instantly. At 100,000 variants the same engine would run as a Node job.

What this check cannot see

  • A value that is wrong on both sides. It compares two stores. It does not know the truth. A price that was wrong before is faithfully wrong after.
  • Meaning. The right SKU with the wrong photo passes every check here.
  • Fields nobody mapped. App data, reviews and subscriptions need their own checks. Customer passwords do not transfer at all.
  • Whether the theme uses the field. A metafield can arrive intact and never be rendered.
  • Customers and orders. Those need admin access and a privacy conversation first.