Import data with a staged upload and bulkOperationRunMutation

Run one mutation thousands of times: upload a JSONL file of variables with stagedUploadsCreate, then start bulkOperationRunMutation.

API
Admin GraphQL API
Operations
stagedUploadsCreate, bulkOperationRunMutation, bulkOperation
Access scopes
Those of the mutation you run, e.g. write_products for productSet
API version
2026-10
Last checked

In short

To import data in bulk, write one JSONL line of variables per record, reserve an upload with stagedUploadsCreate (resource BULK_MUTATION_VARIABLES, httpMethod POST), and upload the file as a multipart form to the returned url. Then call bulkOperationRunMutation with your mutation and the key parameter as stagedUploadPath. Shopify runs the mutation once per line and returns a JSONL file of results.

How to use it

  1. Write products.jsonl: one JSON object per line, holding the variables of one mutation call.
  2. Send ReserveImportUpload with resource BULK_MUTATION_VARIABLES and httpMethod POST.
  3. POST the file to stagedTargets[0].url as a multipart form: every returned parameter as a field, the file last (see upload.txt).
  4. Send StartProductImport with the mutation string and the key parameter as stagedUploadPath.
  5. Poll the operation, or subscribe to bulk_operations/finish, then download the results file. Each line carries __lineNumber, starting at 0, pointing back to your input.

Code

products.jsonl
{"input":{"handle":"linen-shirt","title":"Linen shirt","status":"ACTIVE","productOptions":[{"name":"Size","values":[{"name":"S"},{"name":"M"}]}],"variants":[{"optionValues":[{"optionName":"Size","name":"S"}],"sku":"LS-S","price":"49.00"},{"optionValues":[{"optionName":"Size","name":"M"}],"sku":"LS-M","price":"49.00"}]}}
{"input":{"handle":"wool-scarf","title":"Wool scarf","status":"DRAFT","productOptions":[{"name":"Title","values":[{"name":"Default Title"}]}],"variants":[{"optionValues":[{"optionName":"Title","name":"Default Title"}],"sku":"WS-01","price":"35.00"}]}}
staged-uploads-create.graphql
mutation ReserveImportUpload($input: [StagedUploadInput!]!) {
  stagedUploadsCreate(input: $input) {
    stagedTargets {
      url
      resourceUrl
      parameters {
        name
        value
      }
    }
    userErrors {
      field
      message
    }
  }
}
staged-uploads-create.variables.json
{
  "input": [
    {
      "resource": "BULK_MUTATION_VARIABLES",
      "filename": "products.jsonl",
      "mimeType": "text/jsonl",
      "httpMethod": "POST"
    }
  ]
}
staged-uploads-create.response.json
{
  "data": {
    "stagedUploadsCreate": {
      "stagedTargets": [
        {
          "url": "https://shopify-staged-uploads.storage.googleapis.com/",
          "resourceUrl": null,
          "parameters": [
            {"name": "key", "value": "tmp/1234567890/bulk/00000000-0000-0000-0000-000000000000/products.jsonl"},
            {"name": "Content-Type", "value": "text/jsonl"},
            {"name": "success_action_status", "value": "201"},
            {"name": "acl", "value": "private"},
            {"name": "policy", "value": "<policy from the response>"},
            {"name": "x-goog-credential", "value": "<credential from the response>"},
            {"name": "x-goog-algorithm", "value": "GOOG4-RSA-SHA256"},
            {"name": "x-goog-date", "value": "20261102T090000Z"},
            {"name": "x-goog-signature", "value": "<signature from the response>"}
          ]
        }
      ],
      "userErrors": []
    }
  }
}
upload.txt
# Send every parameter from stagedTargets[0].parameters as a form field,
# in the order returned, and the file last. The upload is a multipart POST
# to stagedTargets[0].url; a 201 response means the file is staged.
curl -X POST "https://shopify-staged-uploads.storage.googleapis.com/" \
  -F "key=tmp/1234567890/bulk/00000000-0000-0000-0000-000000000000/products.jsonl" \
  -F "Content-Type=text/jsonl" \
  -F "success_action_status=201" \
  -F "acl=private" \
  -F "policy=<policy from the response>" \
  -F "x-goog-credential=<credential from the response>" \
  -F "x-goog-algorithm=GOOG4-RSA-SHA256" \
  -F "x-goog-date=20261102T090000Z" \
  -F "x-goog-signature=<signature from the response>" \
  -F "file=@products.jsonl"

# Pass the value of the "key" parameter as stagedUploadPath to bulkOperationRunMutation.
bulk-operation-run-mutation.graphql
mutation StartProductImport($mutation: String!, $stagedUploadPath: String!) {
  bulkOperationRunMutation(mutation: $mutation, stagedUploadPath: $stagedUploadPath) {
    bulkOperation {
      id
      status
    }
    userErrors {
      code
      field
      message
    }
  }
}
bulk-operation-run-mutation.variables.json
{
  "mutation": "mutation SetProduct($input: ProductSetInput!) { productSet(input: $input) { product { id handle } userErrors { code field message } } }",
  "stagedUploadPath": "tmp/1234567890/bulk/00000000-0000-0000-0000-000000000000/products.jsonl"
}
bulk-operation-run-mutation.response.json
{
  "data": {
    "bulkOperationRunMutation": {
      "bulkOperation": {
        "id": "gid://shopify/BulkOperation/1234567891",
        "status": "CREATED"
      },
      "userErrors": []
    }
  }
}
result.jsonl
{"data":{"productSet":{"product":{"id":"gid://shopify/Product/1111111111","handle":"linen-shirt"},"userErrors":[]}},"__lineNumber":0}
{"data":{"productSet":{"product":{"id":"gid://shopify/Product/1111111112","handle":"wool-scarf"},"userErrors":[]}},"__lineNumber":1}

Written by Lintel. MIT license: free to use in your own store and in client work.

Limits and edge cases

  • The JSONL file can be at most 100 MB, and the operation must finish within 24 hours.
  • Any Admin mutation works except bulkOperationRunMutation and bulkOperationRunQuery themselves. The mutation can contain only one connection field.
  • From 2026-01, an app can run up to five bulk mutation operations per store at the same time.
  • Errors are reported per line in the results file; the operation only fails as a whole on a system error. Test the mutation once on its own before running it in bulk.
  • productSet always runs synchronously inside a bulk operation, so the synchronous argument is ignored there.

Questions

How do I bulk import products into Shopify with GraphQL?
Put one productSet input per line in a JSONL file, upload it with stagedUploadsCreate, then run bulkOperationRunMutation with the productSet mutation and the uploaded file’s key.
Is bulkOperationRunMutation rate limited?
No. The bulkOperationRunMutation request isn’t subject to the normal Admin API rate limits. You only make a few cheap requests to start, poll or cancel the operation.
What goes in stagedUploadPath?
The value of the parameter named key from the stagedUploadsCreate response, not the full upload url.
How do I know which lines failed?
Download the results file. Each line has __lineNumber and either the mutation’s data, including its userErrors, or an errors array for that line.
  • Validated against the Admin or Storefront API schema for version 2026-10 on the date shown. Check the version in your request URL.
  • IDs and store addresses in the examples are placeholders. Replace them with your own.
  • Admin API calls need an access token from your app. Never put an Admin API token in a storefront or theme.
  • MIT license. Use the code in your own store or in client work.

Need this built, tested and maintained for your store? See API integrations.

Have something to build? Let’s talk it through.

Taking a limited number of projects at a time. Every brief is read by an engineer, not a salesperson.

Gencer KarakayaShopify Engineer

An engineer reads your brief, and we reply by email.