TECHNICAL · DATA · APPS · 3 JUNE 2025 · 8 MIN READ
Bulk operations: changing ten thousand products safely
A bulk mutation is a machine for making ten thousand changes quickly, which is also what it is when the change is wrong. The safety is in the rehearsal, not the API.
For a one-off edit to a few hundred records, use the admin's bulk editor or a CSV import — they are reversible in ways an API call is not. Beyond that, use bulkOperationRunMutation: write one JSONL line per record, stage the file with stagedUploadsCreate, run the mutation asynchronously, then read the per-line results file. Shopify documents the JSONL file cap as 100MB and does not process its contents serially, so every line must stand alone. The dangerous part is never the API. It is that nothing here has an undo.
IN SHORT
- Bulk operations run asynchronously and are exempt from the normal query rate limits — only the low-cost calls that start and poll them count.
- An import is four steps: build JSONL, `stagedUploadsCreate` with `BULK_MUTATION_VARIABLES` and `text/jsonl`, `bulkOperationRunMutation`, then read the results file.
- Shopify documents a 100MB cap on the JSONL file, and states the file is not processed serially — no line may depend on another.
- Per-line validation and execution errors appear in the output file; the operation itself is only marked FAILED for critical system errors, so "completed" does not mean "worked".
- Result URLs are authenticated and expire after a week, so download and keep the file if you want an audit trail.
- Take a full export of the fields you are about to change before you change them. That export is your rollback, and there is no other one.
First, check you need the API at all
The most common version of this request is "we need to reprice 4,000 products before Friday", and the most common right answer is not an engineering project. Shopify's admin has a bulk editor that opens a spreadsheet-style grid over a filtered selection, and a CSV product import that will update existing records. Between them they cover a large share of what people arrive asking for a script to do.
They have two real advantages over anything you build. Somebody in the merchandising team can run them without waiting for a developer, and they fail in small, visible ways — a bad column in a CSV throws errors you read before the damage is done, rather than after 10,000 successful writes.
Reach for the API when one of three things is true: the change repeats on a schedule, the new values come from another system rather than from a person, or the logic is conditional in a way a spreadsheet cannot express — "raise price by 8% but only where cost is set and the product is not in the outlet collection". If none of those hold, a bulk operation is engineering effort buying you nothing but risk.
How a bulk mutation actually works
The mechanism is deliberately unlike a normal mutation. You are not sending records to Shopify over a connection you hold open; you are handing over a file and coming back later.
- Build the JSONL. One line per record, each line a JSON object of the variables for a single invocation of your mutation, matching the schema of its input type.
- Stage the upload.
stagedUploadsCreatewith resourceBULK_MUTATION_VARIABLES, MIME typetext/jsonland HTTP methodPOST. It returns a URL and form parameters. - Upload the file as multipart form data to that URL, with the file parameter last.
- Run it.
bulkOperationRunMutationtakes the mutation string and the staged upload path, and returns an operation id with statusCREATED. - Wait. Poll the operation, or subscribe to the
bulk_operations/finishwebhook topic.objectCountmoves while it runs. - Read the results. Download the JSONL at
url, or atpartialDataUrlif the run partially failed.
The limits that shape the design
Four documented constraints decide how you structure the job, and each one has caught somebody out.
100MB of JSONL. Shopify states the file cannot exceed it. Ten thousand product updates will not come close; ten thousand products with full variant and metafield payloads might. Size the file before you plan the run, and split by a stable key — vendor, product type, id range — rather than by "the first 5,000", so a rerun of one chunk is a rerun of the same records.
Nothing is serial. The docs are explicit that the contents of the JSONL are not processed in order. So no line may depend on a line above it, and a file that creates a parent and then references it is a file that will fail unpredictably. Two runs, not one clever one.
Not every mutation is eligible. The import guide enumerates which mutations can be run this way, and that list has changed across API versions. Check it for the version you are pinned to before you design around a mutation that turns out not to be supported.
Concurrency is capped per shop, and the cap depends on the API version. This matters mostly if an app and your integration both run bulk jobs against the same store — your nightly sync can be refused because something else got there first. Handle that as a normal outcome, not an exception.
"Completed" does not mean "correct"
This is the single most expensive misunderstanding in this whole area. Shopify marks a bulk mutation FAILED only for critical system errors. Per-line validation and execution errors do not fail the operation — they are written into the results file, line by line, alongside the successes.
So an integration that polls until status is COMPLETED and then logs "bulk update finished" is an integration that will one day report success on a run where 3,000 of 10,000 lines were rejected. The status tells you the job finished, not that it did what you asked.
Parse the results file every time. Count lines in, count successes out, count userErrors, and make a mismatch loud. On a run of any size, group the errors by message before anyone reads them — bulk failures are rarely ten thousand distinct problems, they are one problem ten thousand times, usually a field that is required in this API version and was not in the last one.
Keep the file. The authenticated result URLs expire after a week, and that file is the only per-record account of what actually changed.
The rehearsal, which is the actual safety
There is no rollback. Shopify will not undo a bulk mutation, and "run the opposite mutation" only works if you captured the old values first. Everything below exists because of that one sentence.
- Export before you write. Run a
bulkOperationRunQueryfor the ids and exactly the fields you are about to change, and keep the JSONL. That file is your rollback, and building the reverse JSONL from it is mechanical. - Generate the file, then read it. Before the first upload, open the JSONL and look at ten lines by hand — the first, the last, and eight at random. Most bad runs were visible in the file.
- Rehearse on a development store with a representative slice: the product with 200 variants, the one with no cost set, the one in three collections, the archived one. Edge cases are where conditional logic turns out to have been wrong.
- Run a chunk of fifty against production first, then stop and check them in the admin. The full run is the second decision, not the first.
- Pick a quiet window and tell the people who will see the change. A merchandiser watching prices move on a Tuesday afternoon with no warning will raise an incident, correctly.
- Make the job resumable and idempotent. A run that dies halfway must be safe to start again. Writing the same value twice should be a no-op; writing a *delta* twice — "increase price by 8%" — is how a product ends up 17% more expensive.
Reading the results back, and the shape of the data
Bulk *queries* have their own rules worth knowing, because you will use one to take the before-snapshot. Results come back as JSONL where each line is a node, and children of nested connections appear as their own lines carrying a __parentId that points at the parent record. You reassemble the hierarchy yourself, streaming — which is the point, since it means a 200,000-product export never has to exist in memory as one object.
The query itself is constrained: it must contain at least one connection, connection nesting is limited in depth and in total count, and pagination arguments like first and cursor are ignored if you include them out of habit. Result files expire after a week, the same as imports.
One practical consequence of __parentId: a variant line arrives with no guarantee its product line came first in your processing order. If your importer assumes parents precede children, it will work on the test store and fail on the real one.
When this belongs in a real system
A script somebody runs from their laptop is fine for a migration and wrong for anything recurring. Once a bulk job runs on a schedule, it needs the things any other production process needs: the source of truth for the new values, a record of every run and its counts, alerting when the error rate moves, and somebody who can answer "why did this product change on the 14th".
That is the point at which we stop writing scripts and build a small back end around the job — usually a queue, a run log and a diff, and rarely more than that. It is not a large piece of software. It is the difference between a change you can explain and a change you can only apologise for.
Questions this raises
How do you update thousands of Shopify products at once?
For a one-off, use the admin bulk editor or a CSV import. For anything repeating, conditional or driven by another system, use `bulkOperationRunMutation`: build a JSONL file with one line of variables per record, stage it with `stagedUploadsCreate` using resource `BULK_MUTATION_VARIABLES` and MIME type `text/jsonl`, run the mutation, poll or subscribe to `bulk_operations/finish`, then parse the per-line results file.
Are bulk operations subject to Shopify API rate limits?
The bulk operation itself is exempt — only the low-cost calls that start and poll it count against your limit. That is the main reason to use one for full-catalogue work rather than paginating and throttling your way through it.
How big can a Shopify bulk operation JSONL file be?
Shopify documents the cap as 100MB. Split larger jobs by a stable key such as vendor or id range rather than by position in the file, so re-running a chunk touches the same records both times.
Why did my bulk mutation complete but not change anything?
Because per-line validation and execution errors are written into the results file rather than failing the operation. Shopify only marks a run FAILED for critical system errors. Parse the output, count `userErrors`, and compare lines in against successes out — a completed run with a 30% rejection rate looks identical to a clean one from the status alone.
Can you undo a Shopify bulk operation?
No. There is no rollback. The only working plan is to run a bulk query first that exports the ids and the exact fields you are about to overwrite, and keep that JSONL — the reverse file is then mechanical to build from it.
Is the JSONL file processed in order?
No. Shopify states the contents are not processed serially, so no line may depend on another. Anything that creates a record and then references it needs two separate runs.
How long are bulk operation result files available?
The authenticated result URLs expire after one week. Download the file and store it if you want a per-record audit trail of what the run actually did.
NEXT STEP
Free store audit
A senior Shopify engineer reviews your storefront, theme performance and checkout, then sends a prioritised list of fixes.
