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
- Write products.jsonl: one JSON object per line, holding the variables of one mutation call.
- Send ReserveImportUpload with resource BULK_MUTATION_VARIABLES and httpMethod POST.
- POST the file to stagedTargets[0].url as a multipart form: every returned parameter as a field, the file last (see upload.txt).
- Send StartProductImport with the mutation string and the key parameter as stagedUploadPath.
- 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.graphqlmutation 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.graphqlmutation 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.
Shopify documentation
- 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.
