Shopify Bulk Operations: Export Without Throttling

A Shopify bulk operation runs your Admin GraphQL query asynchronously across the whole dataset and hands back a JSONL file. No pagination, no cost ceiling, no throttling. You submit it with bulkOperationRunQuery, poll bulkOperation(id:) until the status reaches COMPLETED, then stream the file from the signed url it gives you.

Everything below is written against Admin API version 2026-07, the latest stable release at the time of writing. Shopify ships a new version at 5pm UTC on the first day of every quarter and keeps each one available for a minimum of twelve months, so re-check the field names if you are reading this a year from now.

Why not just paginate the Admin API?

The Admin GraphQL API charges by calculated query cost rather than by request. Shopify’s rate limit documentation puts the restore rate at 100 points per second on standard plans, 200 on Advanced, 1,000 on Plus and 2,000 on Enterprise, and caps any single query at 1,000 points regardless of plan. Exceed the bucket and you get a 429 Too Many Requests.

The per-query ceiling is the part that actually bites. More complex requests cost more points, and a products query that also fans out into variants and metafields reaches the cap at a page size most people would call small. So you drop to 25 products per request, add a sleep between pages, and hold a cursor across an hour-long run that you now have to make resumable, because nothing on Shopify’s side remembers where you were.

Shopify’s own guidance is blunt about it. For large amounts of data “you should use bulk operations instead of single queries”, because they “don’t have the max cost limits or rate limits that single queries have”.

How do you run a bulk operation in Admin GraphQL?

Submit the query you would have written anyway, wrapped as a string inside bulkOperationRunQuery.

mutation {
  bulkOperationRunQuery(
    query: """
    {
      products(query: "created_at:>=2026-01-01") {
        edges {
          node {
            id
            handle
            status
            variants {
              edges {
                node { id sku inventoryQuantity }
              }
            }
          }
        }
      }
    }
    """
  ) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

The mutation returns immediately with a BulkOperation whose status is CREATED. Nothing has run yet.

Four rules govern what goes in that query string, and breaking any of them fails validation rather than producing a broken export. The query has to include a connection, since there is nothing to iterate otherwise. Nested connections go a maximum of two levels deep, so products to variants to images is allowed and hanging metafields off those images is not. You get five connections in total across the whole query. The top-level node and nodes fields are off limits.

Pagination arguments are where people trip. first is optional and ignored if present, as are cursor and pageInfo, so leave them out and stop reasoning about page size altogether. Connection filters and sortKey do still work, and that is how you keep a scheduled export incremental instead of re-pulling the entire catalogue every night.

How do you know when it has finished?

Poll bulkOperation(id:) with the ID the mutation returned.

query {
  bulkOperation(id: "gid://shopify/BulkOperation/720918") {
    status
    errorCode
    objectCount
    fileSize
    url
    partialDataUrl
  }
}

Statuses run CREATED, RUNNING, COMPLETED, FAILED and CANCELED, and objectCount climbs while the operation is running, which is the only progress signal on offer. On failure, errorCode is one of ACCESS_DENIED, INTERNAL_SERVER_ERROR or TIMEOUT, and partialDataUrl holds whatever was written before it stopped. Keep that partial file. A TIMEOUT on a very large export is usually a sign to filter by updated_at and run the job in slices rather than to retry it unchanged.

If your code still calls currentBulkOperation, note that it is deprecated in 2026-07 in favour of the plural bulkOperations connection with a status filter, which is worth changing before it goes.

Polling in a loop is fine for a one-off script. For anything running unattended, subscribe to the bulk_operations/finish webhook instead. It fires when an operation completes, fails or is cancelled, and the payload carries admin_graphql_api_id, status, error_code, type and completed_at. Verify it the same way you verify every other Shopify webhook, which we covered in our guide to Shopify webhook verification.

Two limits shape the plumbing around all of this: the signed download URL expires after one week, and the operation itself has ten days to finish. From API 2026-01 onwards an app can run up to five bulk query operations per shop at once, where before it was one per type.

Reading the JSONL without losing the nesting

The output is one JSON object per line, and nested connections are flattened into the same file. Child rows carry a generated __parentId:

{"id":"gid://shopify/Product/1921569226808"}
{"id":"gid://shopify/ProductVariant/19435458986123","__parentId":"gid://shopify/Product/1921569226808"}

Shopify guarantees that nested nodes appear after their parents, so a single forward pass is enough and you never need to sort. Do not buffer the file into memory to do it. Stream it.

import { createInterface } from 'node:readline';
import { Readable } from 'node:stream';

const res = await fetch(url);
const rl = createInterface({
  input: Readable.fromWeb(res.body),
  crlfDelay: Infinity,
});

const products = new Map();

for await (const line of rl) {
  const node = JSON.parse(line);

  if (!node.__parentId) {
    products.set(node.id, { ...node, variants: [] });
    continue;
  }

  products.get(node.__parentId)?.variants.push(node);
}

With two levels of nesting the second-level rows point at the child, not at the product, so key a Map on every id you have seen rather than assuming everything hangs off the root. The same flattening applies to metafields exported alongside a product, which is one more reason to consider modelling repeated structures as metaobjects instead of scattering dozens of loose metafields across a catalogue.

How do bulk imports work?

The other direction takes three steps. Call stagedUploadsCreate with resource: BULK_MUTATION_VARIABLES, mimeType: "text/jsonl" and httpMethod: POST, which returns a url and a set of parameters. POST your JSONL file to that URL as multipart form data, including every returned parameter, with the file field last. Shopify is strict about that ordering. Then run the mutation.

mutation {
  bulkOperationRunMutation(
    mutation: "mutation call($input: ProductInput!) { productCreate(product: $input) { product { id } userErrors { field message } } }"
    stagedUploadPath: "tmp/123/bulk/abc/products.jsonl"
  ) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

Each line of your JSONL is one set of variables for that mutation. The import documentation caps the file at 100MB, gives the operation 24 hours to finish, and supports productCreate, collectionCreate, productUpdate and productUpdateMedia. The mutation itself is limited to one connection field.

Results come back as JSONL too, one response line per input line, and you need to read every one of them. The operation only reports FAILED for system-level problems, so a run that finishes as COMPLETED can still hold thousands of per-row validation errors sitting quietly in the results file.

What I would actually do

Reach for a bulk query as soon as an export runs to more than a few hundred objects or touches variants and metafields together. Below that, an ordinary paginated query with a sensible page size is less machinery for the same answer. Filter with query: and sortKey so a nightly job pulls a delta, wire up bulk_operations/finish rather than a polling loop, and download the file well inside the week before the URL expires.

Imports deserve more caution. The supported mutation list is short and the failure reporting is per-row rather than per-run, so a CSV import or a queue of ordinary mutations is usually easier to retry and easier to reason about. Use bulkOperationRunMutation when you are creating tens of thousands of products in one go, not for routine updates.

Whoooop builds and maintains Shopify integrations, including the migrations and sync jobs that sit behind them. If you have an export that keeps timing out, or a replatform with a catalogue too large to move by hand, our Shopify development work covers this ground.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch