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.
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
- COMPLETED reads as "success" in plain English. It's a reasonable assumption to make, and it's wrong for this API.
- The result lives in a separate file you have to fetch and parse, not inline in any response you get back from polling. It's an extra step that's easy to skip under deadline pressure.
userErrorsis opt-in. It only appears in the result lines if your mutation's GraphQL selection set explicitly requestsuserErrors { field message }. If the mutation string used for the bulk operation doesn't ask for it, that information isn't in the result file to find at all, even if you do parse every line.- Partial failure looks like full success at a glance. When most lines succeed and only a targeted subset fail, spot-checking a handful of products after the job often doesn't surface the problem, because you're statistically likely to check ones that worked.
How to actually verify a bulk operation
- 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. - After status becomes COMPLETED, download and parse the full result
file, not just its line count. Check every line's
userErrorsarray, not just a sample. - Treat a non-empty
userErrorsarray 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. - 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
userErrorsdoesn'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 jobRelated 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.