ADSX
JULY 1, 2026 // UPDATED JUL 1, 2026

Bulk-Write Large Catalogs to Shopify Without Throttling

Write thousands of Shopify products at once with bulkOperationRunMutation: the staged JSONL upload flow, throttle-safe throughput, and cost monitoring.

AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING
READ TIME
7 MIN
SUMMARY

Write thousands of Shopify products at once with bulkOperationRunMutation: the staged JSONL upload flow, throttle-safe throughput, and cost monitoring.

To write thousands of products to Shopify without hitting the throttle, use bulkOperationRunMutation: stage a JSONL file of your inputs with stagedUploadsCreate, upload it, then run one mutation that Shopify executes asynchronously server-side. The mutation itself costs almost nothing against your rate-limit bucket, so a 50,000-record sync moves at throughput the synchronous API can never match. This guide covers the full write path — staging, uploading, running, and reconciling results.

This is the write companion to the export deep-dive. For pulling the catalog back out — bulkOperationRunQuery and JSONL parsing — see fetch your entire Shopify catalog with GraphQL. For OAuth and token fundamentals, see the Shopify Admin API guide, and for the full surface, the Shopify Product Catalog API guide. Everything here uses the GraphQL Admin API, version 2026-01.

Read vs write: two different bulk tools

Shopify exposes two bulk operations, and teams routinely reach for the wrong one. A bulk query reads and returns a JSONL result. A bulk mutation consumes a JSONL input you provide and applies it as writes. This piece is entirely about the second.

You want to...UseInputOutput
Export the catalogbulkOperationRunQueryA GraphQL queryJSONL result file
Sync thousands of products inbulkOperationRunMutationJSONL you uploadPer-record result file
Write a few hundred recordsLooped productSetGraphQL variablesInline response

The export side lives in the read guide. If you only need to write a few hundred records interactively, a synchronous loop of productSet is simpler — reach for bulk when the volume makes throttling the bottleneck.

The write path in four steps

A bulk mutation never takes its data through GraphQL variables. Instead you hand Shopify a JSONL file where each line is one mutation's variables, and Shopify replays the mutation over every line. The flow is always the same:

  1. stagedUploadsCreate — ask Shopify for a signed upload target.
  2. POST your JSONL to that target.
  3. bulkOperationRunMutation — run the mutation against the uploaded file.
  4. Poll currentBulkOperation, then stream the results and reconcile errors.

Step 1: stage the upload

stagedUploadsCreate returns a URL and a set of signed form parameters pointing at Shopify-managed storage. Request a BULK_MUTATION_VARIABLES resource so Shopify knows the file is a JSONL input, not a media asset (Shopify: bulk import data).

mutation StageUpload {
  stagedUploadsCreate(input: {
    resource: BULK_MUTATION_VARIABLES
    filename: "catalog-sync.jsonl"
    mimeType: "text/jsonl"
    httpMethod: POST
  }) {
    stagedTargets {
      url
      resourceUrl
      parameters { name value }
    }
    userErrors { field message }
  }
}

Step 2: build the JSONL and upload it

Each line is the exact variables object one mutation call would receive. Here we sync products with productSet, which upserts a product and its variants declaratively — ideal for bulk because it is idempotent on a stable handle or id.

import { createReadStream } from "node:fs";

// Each line = the variables for one productSet call.
function toJsonl(products) {
  return products
    .map((p) =>
      JSON.stringify({
        input: {
          handle: p.handle,
          title: p.title,
          status: p.status,
          productOptions: p.options,
          variants: p.variants.map((v) => ({
            optionValues: v.optionValues,
            sku: v.sku,
            barcode: v.barcode,
            price: v.price,
          })),
        },
      })
    )
    .join("\n");
}

// Upload to the staged target. The signed parameters must come first.
async function uploadJsonl(target, jsonl) {
  const form = new FormData();
  for (const { name, value } of target.parameters) form.append(name, value);
  form.append("file", new Blob([jsonl], { type: "text/jsonl" }));
  const res = await fetch(target.url, { method: "POST", body: form });
  if (!res.ok) throw new Error(`Staged upload failed: ${res.status}`);
  return target.resourceUrl; // pass this to bulkOperationRunMutation
}

Step 3: run the bulk mutation

Now point bulkOperationRunMutation at the uploaded file. You pass the mutation as a string plus the stagedUploadPath from the previous step. Shopify runs the mutation once per line, asynchronously, with no per-record throttling.

mutation RunBulkSync($stagedPath: String!) {
  bulkOperationRunMutation(
    mutation: "mutation call($input: ProductSetInput!) { productSet(input: $input) { product { id } userErrors { field message } } }"
    stagedUploadPath: $stagedPath
  ) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

The mutation string must define exactly the variables each JSONL line supplies — here a single $input of type ProductSetInput!. If the shapes disagree, every line fails, so keep the JSONL generator and this signature in lockstep.

Step 4: poll, then reconcile results

Poll currentBulkOperation (filtered to the mutation type) until it leaves RUNNING. On COMPLETED you get a url with one result line per input, plus a partialDataUrl for rows that errored.

query PollBulkMutation {
  currentBulkOperation(type: MUTATION) {
    id
    status
    objectCount
    url
    partialDataUrl
  }
}
async function runBulkSync(shop, token, products) {
  const target = await stageUpload(shop, token);
  const path = await uploadJsonl(target, toJsonl(products));
  await gql(shop, token, RUN_BULK_SYNC, { stagedPath: path });

  let op;
  do {
    await new Promise((r) => setTimeout(r, 5000));
    op = (await gql(shop, token, POLL_BULK_MUTATION)).data.currentBulkOperation;
  } while (op.status === "RUNNING" || op.status === "CREATED");
  if (op.status !== "COMPLETED") throw new Error(`Bulk op ${op.status}`);

  // Stream results; a row can fail validation even when the op COMPLETED.
  return op.url;
}

In production, don't busy-poll — subscribe to the bulk_operations/finish webhook and process the results when it fires. Then stream the results file line by line (never JSON.parse the whole thing) and collect any userErrors, exactly as you would when parsing a bulk export.

Watching cost so you never trip the throttle

The whole reason to go bulk is rate-limit headroom. The GraphQL Admin API bills by calculated cost, not request count — you have a bucket that refills at a fixed rate, and a bulk mutation's actual work runs server-side, so only the stagedUploadsCreate, submit, and poll calls draw down your bucket. That turns a 50,000-record write into a few cheap calls.

Read extensions.cost.throttleStatus on every non-bulk call and back off before the bucket empties:

function backoffFor(json) {
  const t = json.extensions?.cost?.throttleStatus;
  if (t && t.currentlyAvailable < 200) {
    const deficit = 200 - t.currentlyAvailable;
    return (deficit / t.restoreRate) * 1000; // ms to wait
  }
  return 0;
}
FieldMeaningUse it to
maximumAvailableBucket sizeKnow your ceiling
currentlyAvailablePoints left nowDecide when to pause
restoreRateRefill per secondCompute the wait

Two operational limits shape throughput. First, only one bulk operation of each type runs per shop at a time — you cannot fan out ten concurrent mutations; you pipeline them, letting each finish before the next submits (Shopify: bulk operations). Second, batch generously: one bulk mutation over 50,000 lines beats fifty over 1,000, because the fixed submit-and-poll overhead amortizes across the whole file. For the exact numeric limits, error codes, and retry semantics, see the Shopify Catalog API rate limits and errors reference rather than hard-coding numbers that change by plan.

Throughput strategy for very large syncs

  • Diff before you write. Only stage records that actually changed. A daily sync that ships 2,000 deltas instead of the full 50,000 finishes in a fraction of the time.
  • Chunk enormous inputs. JSONL files have a size ceiling; if you exceed it, split into sequential bulk mutations and pipeline them.
  • Idempotent inputs. Keying on handle or id with productSet means a retried file re-applies cleanly instead of creating duplicates.
  • Reconcile every run. Treat partialDataUrl as a work queue: parse the failed rows, fix the data, and re-stage only those.

From clean sync to revenue

A reliable write path is what keeps your ad and AI feeds trustworthy: if the catalog Shopify holds is stale or half-written, every downstream Google Merchant Center, Meta, and AI shopping feed inherits the gaps. Getting bulk writes right — complete variants, GTINs, prices, and statuses applied atomically — is the same data-quality discipline that caps ad performance. That is exactly the work AdsX does for Shopify brands: making the catalog the feeds are built on actually correct.

Next steps

ABOUT THE AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING

The AdsX engineering team builds the data pipelines that turn a Shopify product catalog into high-performing ad feeds across Google, Meta, and AI shopping agents. We work hands-on with the Shopify Admin GraphQL API, the Product Feed and Catalog APIs, metafields, and bulk operations every day, and these guides document the patterns we use in production.

MORE BY ADSX ENGINEERING

Ready to Dominate AI Search?

Get your free AI visibility audit and see how your brand appears across ChatGPT, Claude, and more.

Get Your Free Audit