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.
Old store · WooCommerce
New store · Shopify
Cutover
A working cutover check for Shopify migrations. I built it as my application for Execution Space’s contract Shopify developer role.
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 001 · The question
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 002 · The demonstration
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
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.
Camp Kitchen
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.
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.
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.
412 products matched · 1,186 variants matched · 0 lost. 68 fields wrong on 65 products. 2 products need a person.
| Gate | Record | Field | Old store | New store | Details |
|---|---|---|---|---|---|
| Block | Birch Camp SockAP-1069-S-SND | Handle | camp-sock-birch | birch-camp-sock | |
| Block | Birch Tin CandleCN-1020-08 | Handle | tin-candle-birch | birch-tin-candle | |
| Block | Birch Trail MapPG-1022 | Handle | trail-map-birch | birch-trail-map | |
| Block | Cedar Cast Iron Skillet00404 | SKU | 00404 | 404 | |
| Block | Cedar Dutch Oven00420 | SKU | 00420 | 420 | |
| Block | Cedar Field NotebookPG-1003 | Case pack | 12 per case | empty | |
| Block | Cedar Flannel ShirtAP-1003-S-RST | Handle | flannel-shirt-cedar | cedar-flannel-shirt | |
| Block | Cedar Kettle00412 | SKU | 00412 | 412 |
Tool 2 · Decide
Every product URL changes shape in this move, from /product/ to /products/. The question is where the last segment comes from.
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.
| Redirect from | Redirect to | Lands on |
|---|---|---|
| /product/steel-bottle-cedar/ | /products/steel-bottle-cedar | 404 |
| /product/canteen-ember/ | /products/canteen-ember | 404 |
| /product/kettle-ridgeline/ | /products/kettle-ridgeline | 404 |
| /product/speckled-camp-mug-12oz/ | /products/speckled-camp-mug-12oz | 404 |
| /product/cutting-board-ember/ | /products/cutting-board-ember | 404 |
| /product/ridgeline-enamel-mug/ | /products/ridgeline-enamel-mug | 200 |
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
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.
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 | Hits | Result | What to do |
|---|---|---|---|
| /wp-login.php | 310 | Ignore | Bot traffic hunting for a WordPress login. Nothing to redirect. |
| /shop/ | 212 | Reserved | Shopify reserves /shop and will not redirect from it. Repoint menus, emails and ads at /collections/all. |
| /product/speckled-camp-mug-12oz/ | 146 | Dead end | The map has this URL, but it points at a handle that does not exist. |
| /xmlrpc.php | 122 | Ignore | Bot traffic. Nothing to redirect. |
| /blog/how-to-season-cast-iron/ | 88 | Add | Redirect 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/ | 71 | Covered | Covered. |
| /my-account/ | 64 | Add | Redirect to /account. Customer accounts moved. Passwords do not transfer, so this page also needs the reset message. |
| /wp-content/uploads/2021/05/enamel-mug.jpg | 57 | Ignore | An old image path. Fix the page that links to it, or redirect the file to its new address. |
| /product/trailhead-headlamp-2/ | 49 | Dead end | The map has this URL, but it points at a handle that does not exist. |
| /spring-sale/ | 41 | Add | Redirect to /collections/sale. A campaign link still printed on packaging inserts. |
| /product-category/apparel/?filter_color=navy | 33 | Covered | Covered by the path. The filter in the query string may not carry over, so test it. |
| /product/steel-bottle-cedar/ | 29 | Dead end | The map has this URL, but it points at a handle that does not exist. |
| /wholesale-catalog/ | 27 | Add | Redirect to /collections/wholesale. The wholesale landing page that trade emails point to. |
| /product-category/camp-kitchen/ | 22 | Covered | Covered. |
| /product-tag/enamel/ | 19 | Add | Redirect to /collections/drinkware. No exact equivalent. Send them to the nearest collection. |
| /feed/ | 18 | Ignore | The old RSS feed. Only worth a redirect if subscribers matter. |
| /product/harbor-quilted-throw/ | 14 | Covered | Covered. |
The requested addresses are a synthetic 17-line sample, not traffic measured on this site.
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.
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.
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.
Row 003 · One decision, argued
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
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.
Option B
Clean, consistent addresses that match what the product is called today. 24 of 402 live products change their last segment.
Option C
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.
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
Row 004 · Four files
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));
shopify/graphql/catalog-export.graphql
The fields the check compares, including the source id carried in a metafield so records match when a SKU or handle has changed.
Validates against the Admin GraphQL schema. Scope: read_products.
# The catalog as the new store holds it, in the shape the reconciliation needs.
# Run as a bulk operation: Shopify strips the pagination arguments and returns
# every product and variant as one JSONL file.
#
# migration.source_id is a metafield written during import. It carries the old
# store's id so records can be matched even when a SKU or handle has changed.
#
# Scope: read_products
{
products {
edges {
node {
id
handle
title
status
sourceId: metafield(namespace: "migration", key: "source_id") {
value
}
casePack: metafield(namespace: "custom", key: "case_pack") {
value
}
variants {
edges {
node {
id
sku
price
compareAtPrice
inventoryPolicy
selectedOptions {
name
value
}
sourceId: metafield(namespace: "migration", key: "source_id") {
value
}
}
}
}
}
}
}
}
shopify/graphql/redirect-import.graphql
Three mutations that stage redirects.csv and create the import, so you can read how many rows the file holds and preview the first few before anything is applied, then a query that reads the result.
Validates against the Admin GraphQL schema. Not yet run against a store. Scopes: read and write online store navigation.
# Loads the reconciled redirect map (redirects.csv, columns "Redirect from" and
# "Redirect to") through the Admin API instead of the admin screen.
#
# Three mutations and a status query, in order:
# 1. StageRedirectCsv reserve an upload target, then POST the CSV to it
# 2. CreateRedirectImport point Shopify at the uploaded file; read how many rows
# the file holds and preview the first few, before
# anything is applied
# 3. SubmitRedirectImport apply it, once the row count matches the map
# 4. RedirectImportStatus poll until finished, then read created, updated and
# failed counts
#
# Variables for StageRedirectCsv:
# {"input":[{"resource":"URL_REDIRECT_IMPORT","filename":"redirects.csv",
# "mimeType":"text/csv","httpMethod":"POST"}]}
#
# Not yet confirmed against a store: whether CreateRedirectImport wants the staged
# target's url or its resourceUrl. I would try resourceUrl first.
#
# Scopes: write_online_store_navigation, read_online_store_navigation
mutation StageRedirectCsv($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
userErrors {
field
message
}
}
}
mutation CreateRedirectImport($url: URL!) {
urlRedirectImportCreate(url: $url) {
urlRedirectImport {
id
count
previewRedirects {
path
target
}
}
userErrors {
field
message
}
}
}
mutation SubmitRedirectImport($id: ID!) {
urlRedirectImportSubmit(id: $id) {
job {
id
done
}
userErrors {
field
message
}
}
}
query RedirectImportStatus($id: ID!) {
urlRedirectImport(id: $id) {
id
count
createdCount
updatedCount
failedCount
finished
finishedAt
}
}
shopify/sections/case-pack-price.liquid
The section that shows case price, units per case and unit price. It hides itself when the pack size is missing, so an empty pack size never becomes a wrong unit price.
Theme Check 3.30, recommended rules: 0 offenses. Not yet previewed in a theme.
{% comment %}
Case-pack price
Shows the case price, how many units a case holds and the price per unit.
The pack size is read from the product metafield custom.case_pack (integer).
If the metafield is missing or 1, the section renders nothing on the
storefront. A product whose pack size was lost in a migration then shows a
normal price instead of a wrong unit price, and the theme editor says why the
section is hidden.
The login setting hides only this section's prices. Real wholesale price
gating also needs the theme's other price outputs hidden: the product form,
product cards, search results and the cart.
The price shown is for the selected or first available variant. Case products
normally have one variant. For multi-variant products, re-render this section
from the theme's variant-change handler.
{% endcomment %}
{%- liquid
assign variant = product.selected_or_first_available_variant
assign case_pack = product.metafields.custom.case_pack.value | default: 0 | plus: 0
assign gated = false
if section.settings.require_login and customer == blank
assign gated = true
endif
-%}
{%- if case_pack > 1 -%}
<div class="case-pack">
{%- if section.settings.heading != blank -%}
<h2 class="case-pack__heading">{{ section.settings.heading | escape }}</h2>
{%- endif -%}
{%- if gated -%}
<p class="case-pack__gate">
{{ section.settings.login_text | escape }}
<a href="{{ routes.account_login_url }}">{{ section.settings.login_link_label | escape }}</a>
</p>
{%- else -%}
<dl class="case-pack__list">
<div class="case-pack__row">
<dt>{{ section.settings.case_label | escape }}</dt>
<dd>{{ variant.price | money }}</dd>
</div>
<div class="case-pack__row">
<dt>{{ section.settings.units_label | escape }}</dt>
<dd>{{ case_pack }}</dd>
</div>
{%- if section.settings.show_unit_price -%}
<div class="case-pack__row">
<dt>{{ section.settings.unit_label | escape }}</dt>
<dd>{{ variant.price | times: 1.0 | divided_by: case_pack | round | money }}</dd>
</div>
{%- endif -%}
</dl>
{%- endif -%}
</div>
{%- elsif request.design_mode -%}
<p class="case-pack__empty">
This product has no pack size above 1 in custom.case_pack, so this section is hidden on the storefront.
</p>
{%- endif -%}
{% stylesheet %}
.case-pack__heading {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.case-pack__list {
margin: 0;
display: grid;
gap: 0.25rem;
}
.case-pack__row {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.case-pack__row dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
{% endstylesheet %}
{% schema %}
{
"name": "Case-pack price",
"tag": "section",
"class": "section-case-pack",
"enabled_on": {
"templates": ["product"]
},
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "Sold by the case"
},
{
"type": "text",
"id": "case_label",
"label": "Case price label",
"default": "Price per case"
},
{
"type": "text",
"id": "units_label",
"label": "Units label",
"default": "Units per case"
},
{
"type": "checkbox",
"id": "show_unit_price",
"label": "Show price per unit",
"default": true
},
{
"type": "text",
"id": "unit_label",
"label": "Unit price label",
"default": "Price per unit"
},
{
"type": "checkbox",
"id": "require_login",
"label": "Hide this section’s prices from logged-out visitors",
"default": false
},
{
"type": "text",
"id": "login_text",
"label": "Message for logged-out visitors",
"default": "Case pricing is available to trade accounts."
},
{
"type": "text",
"id": "login_link_label",
"label": "Login link label",
"default": "Log in"
}
],
"presets": [
{
"name": "Case-pack price"
}
]
}
{% endschema %}
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 005 · Method and limits
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.
Public, read October 3, 2026
Synthetic, generated from a fixed seed
Not measured
Would need a development store
Row 008 · Files and contact
hireme@mousabatarseh.com