Self-hosted from my home lab. It's offline right now, so you're viewing a snapshot. Check back soon for the live site.
All posts
Infrastructure

Migrating a Shopify Store from Staging to Production

Dozens of metaobject definitions, hundreds of metafield definitions and a stack of Flows need a plan, not copy and paste.

You've built the new data model on a staging store: metaobjects, metafields, Flows, a theme that reads them. Now it has to exist on the live store, exactly the same, without breaking the store customers are using. Shopify has no "push to production" button for this, so here's a plan that works: inventory, checklist, dry run, a fixed order, and a check after go-live.

TL;DR

  • Shopify doesn't copy metaobject or metafield definitions between stores for you. Use the GraphQL Admin API, or an app built for store copies.
  • Inventory both stores first with a script, and diff them. The diff is your migration list.
  • Order matters. A field that points at another metaobject can only be created after that metaobject's definition exists in production.
  • IDs are different in every store. A metaobject_reference field stores the target definition's ID, so remap staging IDs to production IDs before you create anything.
  • Move Flows last (export and import .flow files), and re-run the inventory after go-live: the diff should be empty.

The problem

A data model built on staging grows quickly: a metaobject for each kind of content, metafields on products, variants, collections and customers, and Flows that tag, sort and notify. Recreating that by hand on production goes wrong in predictable ways:

  • A definition is missed, and a theme section shows nothing because the metafield it reads doesn't exist.
  • A key is typed slightly differently (custom.care_guide vs custom.care_guides), so the theme and Flows can't find it.
  • A reference field is created before its target, or is pointed at the wrong metaobject, because the IDs on production don't match staging.
  • A Flow goes live before the fields it reads exist, and fails on the first order.

Why it happened

Shopify treats each store as its own world. A definition you create on staging gets a staging ID, like gid://shopify/MetaobjectDefinition/1. The same definition on production gets a different ID. Anything that refers to a definition by ID, mainly metaobject_reference fields, has to be pointed at the production ID.

Think of it like moving house with labelled boxes. The labels (a definition's type, or a metafield's namespace.key) stay the same, but the rooms (IDs) are different in the new house. You can't put the "kitchen" box in "room 1" just because that's where it was before. You check which room is the kitchen in the new house first.

The theme is the easy part. Templates read metafields by namespace and key (product.metafields.custom.care_guide), so they keep working as long as the definitions on production use the same keys.

The fix

Treat the migration like a deployment, not a data entry job:

  1. Script an inventory of both stores, so you work from facts rather than memory.
  2. Keep a checklist with one row per definition and Flow, so nothing gets skipped and you can tick progress.
  3. Do a dry run: diff the inventories, work out the order, build every create request, and read them before anything touches production.
  4. Run it in a fixed order, then re-run the inventory to prove production matches staging.

An app built for store copies (see Tools used) can save time on a big store. The inventory and the checks after go-live are worth doing either way.

Steps

You need curl and jq, and an Admin API access token for each store. Since January 1, 2026, new custom apps are created in the Dev Dashboard, not the store admin. Give the app at least read_metaobject_definitions (and write_metaobject_definitions on production), plus read and write access for each resource whose metafields you move, such as read_products and write_products for product metafields. The token starts with shpat_.

  1. Set up a small GraphQL helper. Paste this into your terminal:

    # gql <store domain> <token> <query> [variables JSON]
    gql() {
      local vars=${4:-'{}'}
      jq -n --arg q "$3" --argjson v "$vars" '{query: $q, variables: $v}' |
        curl -s "https://$1/admin/api/2026-10/graphql.json" \
          -H "X-Shopify-Access-Token: $2" \
          -H 'Content-Type: application/json' \
          -d @-
    }
    
    export STAGING='staging-store.myshopify.com' STAGING_TOKEN='shpat_...'
    export PROD='live-store.myshopify.com' PROD_TOKEN='shpat_...'
    

    It worked if gql $STAGING $STAGING_TOKEN '{ shop { name } }' prints your staging store's name. Do the same for production.

  2. Inventory the metaobject definitions on both stores.

    MO_QUERY='{ metaobjectDefinitions(first: 250) { nodes {
      id type name
      fieldDefinitions { key name required type { name } validations { name value } }
    } } }'
    
    mkdir -p staging production
    gql $STAGING $STAGING_TOKEN "$MO_QUERY" > staging/metaobjects.json
    gql $PROD $PROD_TOKEN "$MO_QUERY" > production/metaobjects.json
    jq '.data.metaobjectDefinitions.nodes | length' staging/metaobjects.json production/metaobjects.json
    

    It worked if you get two counts, and the staging one matches what you see in Settings > Custom data > Metaobject definitions. If you see errors instead, the token is missing a scope. Above 250 definitions you need to page with after.

  3. Inventory the metafield definitions, one owner type at a time. The query needs an ownerType, so loop over the ones you use:

    MF_QUERY='query($t: MetafieldOwnerType!) { metafieldDefinitions(ownerType: $t, first: 250) { nodes {
      namespace key name ownerType type { name } validations { name value }
    } } }'
    
    for t in PRODUCT PRODUCTVARIANT COLLECTION CUSTOMER ORDER; do
      gql $STAGING $STAGING_TOKEN "$MF_QUERY" "{\"t\":\"$t\"}" > "staging/metafields-$t.json"
      gql $PROD $PROD_TOKEN "$MF_QUERY" "{\"t\":\"$t\"}" > "production/metafields-$t.json"
    done
    

    It worked if each file has a nodes list. Add owner types if your store uses others, such as COMPANY or PAGE.

  4. Turn the diff into a checklist. List what exists on staging but not on production:

    comm -23 \
      <(jq -r '.data.metaobjectDefinitions.nodes[].type' staging/metaobjects.json | sort) \
      <(jq -r '.data.metaobjectDefinitions.nodes[].type' production/metaobjects.json | sort)
    
    for t in PRODUCT PRODUCTVARIANT COLLECTION CUSTOMER ORDER; do
      comm -23 \
        <(jq -r '.data.metafieldDefinitions.nodes[] | "\(.ownerType) \(.namespace).\(.key)"' "staging/metafields-$t.json" | sort) \
        <(jq -r '.data.metafieldDefinitions.nodes[] | "\(.ownerType) \(.namespace).\(.key)"' "production/metafields-$t.json" | sort)
    done
    

    It worked if you get one line per missing definition, for example collection_story or PRODUCT custom.care_guide. Paste these into a spreadsheet with columns for Type, Item, Depends on, Created, Checked and Notes, and add one row per Flow. That sheet is your checklist on the day.

  5. Work out the order. Find every metaobject field that points at another metaobject:

    jq -r '.data.metaobjectDefinitions.nodes
      | (map({key: .id, value: .type}) | from_entries) as $types
      | .[] | .type as $t
      | .fieldDefinitions[].validations[]
      | select(.name == "metaobject_definition_id")
      | "\($t) needs \($types[.value])"' staging/metaobjects.json
    

    It worked if you see lines like collection_story needs designer. Create the right-hand side first. If two definitions need each other, create both without the reference fields, then add those fields with metaobjectDefinitionUpdate.

  6. Dry run: build the requests and read them. Map each staging ID to the production ID of the same type, then build the create input with the IDs swapped:

    jq -n --slurpfile s staging/metaobjects.json --slurpfile p production/metaobjects.json '
      ($p[0].data.metaobjectDefinitions.nodes | map({key: .type, value: .id}) | from_entries) as $prod
      | $s[0].data.metaobjectDefinitions.nodes | map({key: .id, value: $prod[.type]}) | from_entries' > id-map.json
    
    jq --arg type collection_story --slurpfile map id-map.json '
      .data.metaobjectDefinitions.nodes[] | select(.type == $type)
      | {definition: {type, name, fieldDefinitions: [.fieldDefinitions[] | {
          key, name, required, type: .type.name,
          validations: [.validations[] | if .name == "metaobject_definition_id" then .value = $map[0][.value] else . end]
        }]}}' staging/metaobjects.json > create-collection_story.json
    

    It worked if every metaobject_definition_id in the output points at a production ID. A null means the target doesn't exist on production yet, so it's out of order. Nothing has changed on either store so far. The input copies the type, name and fields. Check settings like storefront access and the display name field in the admin afterwards.

  7. Create, in order. For each definition, in the order from step 5, re-run the production inventory (step 2) and the ID map (step 6) so they include what you just created, then:

    CREATE='mutation($definition: MetaobjectDefinitionCreateInput!) {
      metaobjectDefinitionCreate(definition: $definition) {
        metaobjectDefinition { id type }
        userErrors { field message }
      } }'
    
    gql $PROD $PROD_TOKEN "$CREATE" "$(cat create-collection_story.json)"
    

    It worked if userErrors is empty and you get a new production id. Tick it off in the checklist. Metafield definitions go the same way with metafieldDefinitionCreate, after the metaobjects they point at.

  8. Move the Flows last. On staging, go to Apps > Flow, select the workflows, then More actions > Export to download .flow files. On production, click Import and upload them. On Shopify Plus, you can instead use More actions > Copy to another store for stores in your organization. Open each imported workflow and check email addresses, tags, collection choices and anything else that's specific to one store.

    It worked if every workflow from the checklist is on production and turned on, and the first real run shows in each workflow's run history without errors.

  9. Prove it matched. Re-run steps 2 to 4.

    It worked if the diff in step 4 prints nothing. Then click through the pages that use the new fields and place a test order, so you see the Flows run.

How it works

sequenceDiagram
  participant You
  participant Staging as Staging store
  participant Prod as Production store
  You->>Staging: inventory (definitions, Flows)
  You->>Prod: inventory (what's already there)
  You->>You: diff, order, ID map, build inputs (dry run)
  loop each definition, dependencies first
    You->>Prod: metaobjectDefinitionCreate / metafieldDefinitionCreate
    Prod-->>You: new production ID (update the ID map)
  end
  You->>Prod: import .flow files, review, turn on
  You->>Prod: inventory again
  Note over You,Prod: the diff against staging should be empty

How to avoid it next time

  • Freeze changes to definitions on staging from the first inventory until go-live, or the list goes stale under you.
  • Keep the inventory script in the repo and run it before every release, not only big migrations.
  • Never hard-code IDs in scripts, themes or Flows when a handle, type or namespace.key will do. Those stay the same across stores, and IDs don't.
  • Agree on names before building (custom.care_guide, not five spellings of it). Renaming a key later means migrating its values.
  • Export your Flows on a schedule. A .flow file is also a backup.

Tools used

Was this useful?
#shopify#migration#metaobjects#metafields#shopify-flow

Comments

Comments and votes open again when the site is back online.