Development

The Shopify dry-run that almost wasn't: bulk price updates via Admin API

A client asked for a "price increase" across 393 products. The dry-run said: net negative £680,000. Here's how we caught it, what we built, and why you should never run a bulk Shopify deploy without reading the CSV first.

When a client tells you they want a "price increase", you don't expect the spreadsheet to take £680,000 of headline revenue off the catalogue. We almost found that out the hard way.

This is a write-up of a bulk Shopify deploy we ran for a D2C art and homeware brand: 13,824 variants, 393 products, two mutation passes, all driven by the Admin GraphQL API directly. The headline lesson sounds obvious in hindsight but isn't always obvious in the moment: never run a bulk price change without a dry-run that diffs current against new for every single variant.

The brief

Two things had to land on a fixed date, ten days out:

  1. Add a new £75 "Print Only Mini" variant to every Open Edition print on the store, sized to match each product's smallest mounted print — different per product because square pieces and landscape pieces have different smallest dimensions.
  2. Apply a "price increase" across all Open and Limited Edition products per a pricing matrix the client sent over.

Reasonable scope. The pricing matrix turned out to be a three-axis lookup: Edition (Open or Limited) × physical size × finish (Print Only, Mounted, Framed Group A, Framed Group B, Canvas Only, Framed Canvas). Three sheets in the workbook, each with the same column structure but a different size domain: standard sizes, odd landscape sizes, canvas.

261 Open Edition products, 132 Limited Edition products, and a per-product "smallest mounted print" calculation needed for both the new variant creation and the Mini Prints row in the price matrix.

What we built

The script was Python against Shopify's Admin GraphQL API, authing via the client-credentials grant on a custom app — which sidesteps the session-token dance entirely for backend scripts and runs unattended without re-authenticating mid-job.

Four pieces:

  • Product fetch. Pull all in-scope products by tag (Edition Type:Open Edition Prints, Edition Type:Limited Edition Prints) using a paginated products query, storing every variant's ID, title, price, and option values.
  • Price lookup. A function that took a variant's edition, size, and finish and returned the new price from the matrix — or flagged it as unmatched if no row covered it.
  • Mini size calculation. For each Open Edition product, compute width × height for every mounted variant, pick the minimum. That dimension drove both the new variant title and the correct Mini Prints price row.
  • Dry-run report. A CSV with one row per variant: product_id, variant_id, title, current_price, new_price, delta, match_status.

The match_status field had three values: matched, no_price_found, and unknown_finish. Bucketing instead of failing on the first unmatched row meant the script ran to completion and produced the full picture in a single pass — no iterating until all the edge cases surfaced.

The dry-run that caught it

Before any writes, we generated the CSV. The summary at the top:

  • 13,305 variants would change price
  • 5,150 were dropping by more than £50 each
  • Net delta across the catalogue: negative £680,000

The brief said price increase. The data said price restructure, with most variants going down.

We paused and went back to the client. Turned out the move was intentional — certain SKUs had been overpriced for years and the new sheet was the correction. But "price increase" in their internal language and "net positive revenue change" in ours weren't the same thing. Without the dry-run we'd have pushed the change without understanding what we were actually doing — and depending on cache and CDN behaviour, possibly without anyone noticing the drops for weeks.

The specific numbers: one of their best-selling large framed prints was set to drop from £605 to £515. A mid-size mounted print from £220 to £170. Only the smallest mini sizes were going up, by a tenner.

The dry-run isn't a safety check. It's a communication artifact. It gives you something concrete to put in front of a client and say: "Is this what you meant?" before any data changes.

What else the dry-run surfaced

While every variant was in a single file, the report also flagged:

  • 104 unmatched bundle variants — Duo Sets, Triple Sets, "Four Stages" prints — that weren't in the matrix at all. The client handled these manually.
  • One Framed Canvas variant in Dove Grey, a colour that didn't appear in the canvas pricing column. Turned out to be a finish the store doesn't currently offer but the variant still existed.
  • Six edge-case variants on three products with sizes the matrix didn't cover. Too small to chase, skipped with client sign-off.

None of these were disasters individually. But a bulk deploy that silently skips 104 variants and reprices a discontinued finish is a customer-service queue two weeks later. Surfacing them before the deploy — even if the resolution is "skip with sign-off" — means nothing is ambiguous after go-live.

The mutations

Once the brief was confirmed, two passes:

Pass 1 — Reprice existing variants:

mutation BulkUpdateVariants($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    product { id }
    productVariants { id price }
    userErrors { field message }
  }
}

One mutation call per product, passing an array of { id, price } objects for all that product's variants. 385 products, 13,305 variants repriced. Zero userErrors.

Pass 2 — Add new Mini variants:

mutation BulkCreateVariants($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkCreate(productId: $productId, variants: $variants) {
    product { id }
    productVariants { id title price }
    userErrors { field message }
  }
}

Same structure — one call per product, one new variant per Open Edition product (255 products; 6 skipped because they were sold as bundles with no single mounted size to derive a mini from).

Rate limit note: Shopify's Admin GraphQL API uses a cost-based throttle, not a simple requests-per-second limit. productVariantsBulkUpdate with a large variants array costs more points than a single-variant update. For a catalogue this size, we built in a small sleep between product batches and monitored the X-Shopify-Shop-Api-Call-Limit response header to back off when needed. Total runtime for both passes: under 30 minutes including the client confirmation pause in the middle.

The post-deploy verification

After both mutations completed, we re-pulled every affected variant from the API and diffed it against the dry-run CSV. Zero mismatches on price. Zero missing mini variants. The post-deploy report was the proof of work — not "it ran without errors" but "every variant now has exactly the price we intended."

This step takes maybe ten minutes to script once the dry-run CSV structure exists. It's worth it every time.

Three things that paid off

1. The dry-run was non-destructive and disposable. It cost nothing to generate — just reads, no writes — gave us a complete picture before touching anything, and made the client conversation cheap: one CSV, fifteen minutes, full alignment before a single price changed.

2. The match-status flag was load-bearing. By bucketing variants into matched, no_price_found, and unknown_finish instead of raising an exception on the first unmatched row, the script produced a complete report in one run. You want to see all the edge cases at once, not discover them one at a time across five separate runs.

3. The pre-change snapshot is the rollback. Every variant's current price was in the dry-run CSV before we touched anything. If we'd needed to revert, the script was a single column swap: current_price into new_price, same mutation, run it again. No database backup, no Shopify restore point — just the CSV you already generated.


If you're touching more than a handful of variants on Shopify, generate the dry-run. If you're touching prices, double-generate it. The thirty minutes you spend reading a CSV before the deploy is cheaper than the thirty days of customer-service tickets after a wrong one.

Filip Rastovic
Filip Rastovic
Shopify Developer & CRO Specialist · Stargazer Studio

Need a bulk Shopify operation handled properly?

Variant pricing updates, catalogue restructures, bulk metafield writes — if it touches more than a handful of SKUs, it needs a dry-run. Book a call and let's scope it.

Book a free call More articles
Filip Rastovic
Book a Call Get started today