# 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.

Page: https://lintelagency.com/resources/graphql/bulk-import-with-staged-upload

## 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.

## Facts

- **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:** 2 November 2026

## 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

```text
{"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

```graphql
mutation ReserveImportUpload($input: [StagedUploadInput!]!) {
  stagedUploadsCreate(input: $input) {
    stagedTargets {
      url
      resourceUrl
      parameters {
        name
        value
      }
    }
    userErrors {
      field
      message
    }
  }
}
```

### staged-uploads-create.variables.json

```json
{
  "input": [
    {
      "resource": "BULK_MUTATION_VARIABLES",
      "filename": "products.jsonl",
      "mimeType": "text/jsonl",
      "httpMethod": "POST"
    }
  ]
}
```

### staged-uploads-create.response.json

```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

```text
# 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

```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

```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

```json
{
  "data": {
    "bulkOperationRunMutation": {
      "bulkOperation": {
        "id": "gid://shopify/BulkOperation/1234567891",
        "status": "CREATED"
      },
      "userErrors": []
    }
  }
}
```

### result.jsonl

```text
{"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}
```

## 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.

## Shopify documentation

- [Bulk import data with the GraphQL Admin API](https://shopify.dev/docs/api/usage/bulk-operations/imports)
- [stagedUploadsCreate](https://shopify.dev/docs/api/admin-graphql/latest/mutations/stagedUploadsCreate)
- [bulkOperationRunMutation](https://shopify.dev/docs/api/admin-graphql/latest/mutations/bulkOperationRunMutation)

## Related

- [Create or update a whole product with productSet](https://lintelagency.com/resources/graphql/product-set)
- [Add tags to orders, customers or products in bulk](https://lintelagency.com/resources/graphql/bulk-add-tags)
- [Migrating to Shopify: what breaks and how to plan for it](https://lintelagency.com/journal/migrating-to-shopify-what-breaks)

License: MIT. Free to use in your own store and in client work.
