Shopify Bulk Operations API Across Multiple Stores
How the Shopify Bulk Operations API replaced my throttled update loops across multiple stores — the async model, the migration, and what still bites.
Key points — AI summary
- A synchronous update loop over the Admin API works until it doesn't — at a few thousand products across several stores it throttles, drags for hours, and can die halfway with no clean way to know what already changed
- The Bulk Operations API flips the model — you upload a JSONL file where each line is one mutation, submit bulkOperationRunMutation, and Shopify runs it asynchronously and hands back a result file (mechanism per shopify.dev), instead of you babysitting thousands of live calls
- Since API version 2026-01 a shop can run up to five bulk mutation operations at once (up from one per type before), which changes how you fan work out across a fleet — confirm current limits on shopify.dev before you architect around them
- Bulk ops aren't free of pain — staged uploads, a completion window, per-line error handling in the result JSONL, and the fact that not every mutation is bulk-eligible mean you still need real error handling
- When CSV is too rigid and a plain loop throttles, bulk ops are the correct middle layer — that's the tier our own multi-store tooling runs product, price, and tag changes through
Summarized from this article by our writing pipeline; reviewed by the author.
On this page
In May 2026 I sat watching a terminal update roughly 4,000 products across three of the stores we run — a supplier-wide price change plus a tag cleanup — and I did the math on the progress bar. At the rate it was crawling, it was going to take most of the afternoon. Then, about 1,200 products in, one store started returning 429 Too Many Requests, my retry logic backed off, backed off again, and the whole run stalled. Worse: I no longer knew, without querying every product back, which ones had already been updated and which hadn't. A half-applied price change across a live store is not the kind of ambiguity you want at 3pm.
That afternoon is why I finally moved our bulk update path off synchronous API loops and onto Shopify's Bulk Operations API. This is the write-up I wish I'd read first: why the loop breaks at scale, how the async model actually works, what the migration cost me, and where it still bites. If you've already read our piece on how Shopify API rate limits work across multiple stores, think of this as the practical follow-up — the same throttling problem, but the version where you stop fighting the rate limiter and route around it entirely.
Why the loop felt fine until it wasn't
The synchronous loop is the obvious first design, and I'd defend it for small jobs. You read a list of products, you iterate, you fire one mutation per product, you handle the response. For a couple hundred items on one store it's over before you've refilled your coffee.
It falls apart on two axes at once, and multi-store hits both. Axis one is volume: every item is a live round-trip, so 4,000 products is 4,000 sequential calls, each paying network latency and each drawing down your rate-limit budget. Axis two is fan-out: run that same job across a fleet and you're now draining multiple per-store buckets in parallel, which is exactly the compounding pressure I've written about before — each store stays inside its own limit while your worker pool lights up throttling everywhere at once.
You can paper over it with backoff, jitter, and a queue, and we did for a long time. But backoff doesn't make the job faster — it makes it slower on purpose so it fails less. And it doesn't solve the part that actually scared me: a long-running loop that dies partway leaves you in an unknown state. CSV export/import sidesteps the state problem for catalog data — that's still my first reach for straightforward product edits, and our guide to bulk product management with CSV covers where that ceiling is. But CSV is rigid: it's product-shaped, it's one file per store, and it can't express the more surgical GraphQL mutations. When CSV is too blunt and the loop is too fragile, you've arrived at bulk operations.
The mental model that made it click
I resisted bulk operations for months because I'd skimmed the docs and thought "this is just batching." It isn't. The shift that made it click: you stop making the changes and start describing them.
Instead of your code executing thousands of mutations live, you write a single JSONL file where each line is one input unit — Shopify runs your mutation once per line. You upload that file (you first call stagedUploadsCreate to reserve the storage and get upload credentials), then submit it with bulkOperationRunMutation. Shopify runs the whole thing asynchronously on its side and, when it finishes, hands you back a result as a JSONL file you download from a url field on the operation (mechanism per shopify.dev). There's a query-side twin, bulkOperationRunQuery, for pulling large datasets out the same way.
The consequence that mattered most to me: per Shopify's docs, the bulkOperationRunMutation submission isn't metered by the standard rate limit the way your thousands of individual calls were. You're no longer babysitting a live loop against a leaky bucket — you hand off the batch and Shopify absorbs the pacing. My afternoon-long, throttle-prone run became: build a file, submit it, poll for status, download results.
Polling is its own small thing. Historically you'd query currentBulkOperation for status; as of API version 2026-01 there's bulkOperation(id:) to check a specific one, and you can subscribe to the bulk_operations/finish webhook instead of polling at all. I went with the webhook so nothing sits in a poll loop — confirm the current field names against shopify.dev for whichever API version you pin, because this area moved recently.
The migration, including the part I got wrong
The first thing I got wrong was assuming everything I did in my loop had a bulk equivalent. Not every mutation is bulk-eligible, and there's a documented constraint that a bulk mutation can only contain a single connection field. My original nested payload — updating a product and its variants and its media in one shot — didn't translate cleanly. I had to flatten the work into separate passes. That's not a footnote; it changed how I model the job.
The second surprise was a good one. On the old one-operation-per-type limit I'd assumed I had to serialize bulk jobs per store. But since API version 2026-01, a shop can run up to five bulk mutation operations simultaneously (up from one bulk operation of each type before). Across a fleet that reshapes the scheduler — you can fan several imports out per store rather than queueing them nose-to-tail. I'd still verify the exact concurrency number on shopify.dev before you build a scheduler around it, because it's precisely the kind of limit that gets revised, and I don't want you architecting on my memory of it.
Error handling also moves. In a live loop you catch each failure inline. With bulk ops, failures come back inside the result JSONL, line by line — you parse the completed file, find the rows that errored, and decide what to retry. That's genuinely better for the state problem that burned me: the result file is the record of what happened, so "which products actually changed" is a file you read, not a guess you make. But you do have to write the parser. And Shopify enforces a 24-hour completion window and a 100 MB file-size ceiling on each operation — an operation that runs past that window is stopped and marked failed — so for very large fleets you're chunking work into multiple files regardless. Bulk ops didn't remove the batching problem — it moved it somewhere I can reason about.
Where I actually draw the lines now
After migrating, my rule of thumb for the update path across the stores we run is a three-tier one, and I pick by shape of the job, not size alone:
- CSV for plain catalog edits an operator can eyeball — prices, tags, stock on a product list. It's the most forgiving and the least code. Same tier as the collections, tags, and themes in bulk work.
- A short synchronous call for genuinely small or one-off surgical changes where spinning up a bulk job is overkill. A loop of 50 calls is fine; a loop of 5,000 is a mistake.
- Bulk Operations API the moment the job is large, cross-store, or needs a mutation CSV can't express. This is the tier our own tooling runs product, price, and tag pushes through, and it's why I lean toward automating store ops in code rather than stacking apps — bulk ops is a capability you own, not a subscription with a per-store fee.
Here's my actual opinion, the one that took an afternoon of watching a stalled progress bar to earn: at multi-store scale, a synchronous update loop isn't a smaller version of the right approach — it's a different, wrong one. It scales the failure modes right along with the work. The async model feels like more moving parts on day one (staged uploads, JSONL, webhooks, a result parser) and it is. But every one of those parts exists to remove the ambiguity that a dying loop leaves behind, and at three, five, ten stores that ambiguity is the expensive thing. I don't run big fleet-wide product changes any other way now.