Technical explainer

Why your Shopify bulk operation says COMPLETED but nothing changed

A precise look at a genuinely confusing behavior in Shopify's Bulk Operations API: a job can report status COMPLETED while every individual change inside it failed. Here's exactly why, and how to check for it.

If you've built against Shopify's GraphQL Admin API at any scale, you've probably used bulkOperationRunMutation to apply a mutation across many records at once, typically by uploading a JSONL file of variables and letting Shopify process it asynchronously. It's the right tool for large batch writes, since it avoids hammering the regular rate-limited API one call at a time.

The confusing part shows up when you poll the operation and see status: COMPLETED, check your products, and find that some or all of the intended changes never applied. This isn't a bug or a rare edge case. It's how the API is designed to behave, and it catches out a lot of integrations that only check the top-level status.

What COMPLETED actually means

The status field on a bulk operation (COMPLETED, FAILED, CANCELED, EXPIRED) describes whether the job itself ran to completion, not whether the mutation succeeded on each individual line inside it. A bulk operation is a wrapper that processes a JSONL file line by line, running your specified mutation once per line. As long as the job's infrastructure processes every line without a fatal, job-level error, the top-level status is COMPLETED, regardless of how many of those individual mutation calls returned application-level errors.

In other words: COMPLETED tells you Shopify finished attempting your changes. It does not tell you the changes worked.

Where the real result lives

When a bulk operation finishes, the operation object exposes a url field pointing to a JSONL file with the results, one line per input record. Each line contains the response payload for that mutation call, and critically, that includes the mutation's userErrors array if your mutation's selection set requested it. Most Admin API mutations follow Shopify's standard user-error pattern: the mutation can "succeed" at the transport level (no GraphQL error, HTTP 200) while still returning a non-empty userErrors array describing exactly why that particular write didn't apply.

If your code checks the top-level bulk operation status and stops there, it never opens this file. If it opens the file but doesn't check each line's userErrors, it still won't see the failures, because a line with a populated userErrors array and an otherwise well-formed response looks, at a glance, like a normal result line.

{"data":{"productSet":{"product":null,"userErrors":[{"field":["input","variants","0","optionValues"],"message":"Option value does not match any option on the product"}]}},"extensions":{...}} {"data":{"productSet":{"product":{"id":"gid://shopify/Product/123..."},"userErrors":[]}},"extensions":{...}}

Both of those are ordinary lines in a result file from a job that reports COMPLETED. The first one failed. Nothing about the top-level operation status distinguishes it from the second, successful line.

A concrete example: productSet and optionValues

A common way this shows up in practice: bulk-updating variants or options through a mutation like productSet, where a line's input references an optionValues entry that doesn't cleanly match an existing option on that specific product, perhaps because the option was renamed, the value has a typo relative to what's actually on the product, or the variant count implied by the option combinations doesn't line up. Shopify rejects that line with a userErrors entry describing the mismatch, while every other line in the same job, referencing products where the option values do line up, succeeds normally. The job as a whole still finishes and reports COMPLETED, because nothing about that failure is a job-level infrastructure problem. It's a targeted, per-line rejection that only shows up if you go looking for it.

At scale, this is exactly the kind of failure that's easy to miss: it usually affects a minority of lines, often ones with unusual or slightly malformed data, so a quick spot-check of a few random products after the job often looks completely fine.

Why this trips up so many integrations

How to actually verify a bulk operation

  1. Always include userErrors (and any relevant nested error fields) in the mutation's selection set before you use it in a bulk operation. If it's not requested, it can't be checked.
  2. After status becomes COMPLETED, download and parse the full result file, not just its line count. Check every line's userErrors array, not just a sample.
  3. Treat a non-empty userErrors array as a failure for that line, and log or surface it with enough context (which entity, which field, the message) to act on it, rather than only counting successes.
  4. For the highest confidence, independently re-query the entities you targeted after the job, using a normal GraphQL query rather than trusting the mutation's own response. This catches failures even in edge cases where userErrors doesn't fully capture what happened, such as a race with another process writing to the same product between your bulk job and your check.

This is the exact failure Gridproof is built around

This behavior is the specific, provable reason Gridproof treats "the bulk operation completed" as the start of verification rather than the end of it. Every job parses every line of the result JSONL for userErrors, and afterward, independently re-reads every targeted entity from the store and compares the observed value against the intended one, field by field. Anything that didn't land is flagged with the specific entity, field, and observed value as evidence, rather than being silently absorbed into a generic success count.

If you're a merchant rather than a developer and you're relying on an app to run bulk changes for you, the practical takeaway is the same: a tool saying a job is "done" isn't proof that it verified anything. It's worth asking, or checking the app's own documentation, whether "done" means the job finished or means it confirmed the result. Those are different claims, and this API is exactly why the difference matters.

See how Gridproof verifies every job

Related reading

If a bulk operation already ran and you're trying to figure out what actually changed versus what didn't, see how to undo a bulk edit in Shopify. For how different bulk editing tools handle (or don't handle) this verification gap, see the bulk editor comparison.