Why GraphQL, not REST
Shopify announced REST Product endpoints deprecated for new apps on February 1, 2025. All new variant CRUD work should use GraphQL. Existing REST integrations continue to function but won't receive new features. If you're starting a project in 2026, only GraphQL. The 2025-07 Admin API version was the last to add REST-only fields; nothing new since.
The upside: GraphQL lets you request exactly the fields you need, chain related data (product + variants + inventory levels + metafields) in one request, and dramatically reduce the round-trips of a REST equivalent.
Authentication + endpoint
All Admin GraphQL calls hit:
https://{shop}.myshopify.com/admin/api/2025-07/graphql.json
Headers:
X-Shopify-Access-Token: your app's session token (obtained via OAuth)Content-Type: application/json
For custom apps installed on a single store, generate the token in the store admin (Settings → Apps → Develop apps → API credentials). For public apps distributed on the App Store, tokens come from the OAuth flow; scope your app to write_products + read_products + write_inventory as needed.
Query variants on a product
query ProductVariants($id: ID!) {
product(id: $id) {
id
title
options { name values }
variants(first: 100) {
nodes {
id
title
sku
barcode
price
compareAtPrice
inventoryQuantity
inventoryPolicy
weight
weightUnit
selectedOptions { name value }
image { url altText }
inventoryItem {
id
tracked
inventoryLevels(first: 5) {
nodes { location { name } quantities(names: ["available"]) { quantity } }
}
}
}
pageInfo { hasNextPage endCursor }
}
}
}
Variables: { "id": "gid://shopify/Product/1234567890" }. GIDs (globally unique IDs) are the standard identifier in Admin API — the numeric ID from the URL wrapped in gid://shopify/Product/{id}.
Create a product with variants
The modern pattern uses productCreate + productSet. As of 2025-01 API, productSet is the recommended mutation for defining a full product state (variants, options, media) in one atomic operation:
mutation ProductSet($input: ProductSetInput!) {
productSet(input: $input) {
product {
id
variants(first: 20) { nodes { id sku price selectedOptions { name value } } }
}
userErrors { field message code }
}
}
Variables:
{
"input": {
"title": "Cotton T-Shirt",
"productOptions": [
{ "name": "Size", "values": [{"name":"S"},{"name":"M"},{"name":"L"},{"name":"XL"}] },
{ "name": "Color", "values": [{"name":"Red"},{"name":"Blue"},{"name":"Black"}] }
],
"variants": [
{ "optionValues": [{"optionName":"Size","name":"S"},{"optionName":"Color","name":"Red"}], "price":"25.00", "sku":"TSHIRT-S-RED", "inventoryQuantities":[{"locationId":"gid://shopify/Location/123","name":"available","quantity":50}] },
{ "optionValues": [{"optionName":"Size","name":"M"},{"optionName":"Color","name":"Red"}], "price":"25.00", "sku":"TSHIRT-M-RED", "inventoryQuantities":[{"locationId":"gid://shopify/Location/123","name":"available","quantity":50}] }
]
}
}
Fields to know:
productOptions: the option definitions (max 3 per product)variants: each withoptionValuesnaming the option + value combinationinventoryQuantities: per-location initial stock
The response includes userErrors — always check this before assuming success. Common validation errors: duplicate SKU (uniqueness enforced per shop), missing option value, exceeding the 2,048 variants per product cap.
Update a variant's price
mutation UpdatePrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id price }
userErrors { field message }
}
}
{
"productId": "gid://shopify/Product/1234567890",
"variants": [
{ "id": "gid://shopify/ProductVariant/40123456789", "price": "27.99" },
{ "id": "gid://shopify/ProductVariant/40123456790", "price": "27.99" }
]
}
Update up to 250 variants per mutation call. For bulk price changes across all products, prefer Bulk Operations (below).
Update inventory
Inventory sits on inventoryItem, one per variant. Adjustments happen per location:
mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!) {
inventoryAdjustQuantities(input: $input) {
inventoryAdjustmentGroup { reason changes { name delta } }
userErrors { field message }
}
}
{
"input": {
"reason": "correction",
"name": "available",
"changes": [
{ "delta": -5, "inventoryItemId": "gid://shopify/InventoryItem/44123456", "locationId": "gid://shopify/Location/123" }
]
}
}
For absolute set (not delta), use inventorySetQuantities. Reason codes: correction, cycle_count_available, damaged, movement_created, received, restock, safety_stock, shrinkage.
Bulk operations for 1,000+ variants
Any operation that returns or mutates more than 250 records at once belongs in Bulk Operations — async, no rate limit, better throughput. Two-step pattern:
Step 1: submit bulk query
mutation {
bulkOperationRunQuery(query: """
{
products {
edges {
node {
id
title
variants { edges { node { id sku price inventoryQuantity } } }
}
}
}
}
""") {
bulkOperation { id status }
userErrors { field message }
}
}
Step 2: poll for completion + download the JSONL result
query { currentBulkOperation { id status url errorCode objectCount } }
When status: COMPLETED, download from url — it's a newline-delimited JSON file with one product/variant per line. Parse with any streaming JSONL parser.
For bulk mutations (mass import, mass update), use bulkOperationRunMutation with a JSONL file uploaded to a staged upload target. Full round-trip in Shopify's docs.
Rate limits — the cost bucket
Admin GraphQL is cost-based, not request-count-based. Each query has a computed cost; you have a per-shop bucket that refills over time:
| Plan | Bucket size | Restore rate |
|---|---|---|
| Standard | 1,000 points | 50/sec |
| Advanced | 2,000 points | 100/sec |
| Plus | 10,000 points | 500/sec |
| Enterprise | Custom | Custom |
Every response includes extensions.cost:
"extensions": {
"cost": {
"requestedQueryCost": 51,
"actualQueryCost": 22,
"throttleStatus": {
"maximumAvailable": 1000,
"currentlyAvailable": 978,
"restoreRate": 50
}
}
}
On THROTTLED error, wait Math.ceil((cost - currentlyAvailable) / restoreRate) seconds before retrying. Real production apps: wrap your GraphQL client with automatic throttle handling. The official @shopify/shopify-api Node library does this out of the box.
Webhooks for variant changes
Subscribe to PRODUCTS_UPDATE to get pushed variant changes in real-time (no polling required):
mutation {
webhookSubscriptionCreate(
topic: PRODUCTS_UPDATE,
webhookSubscription: {
callbackUrl: "https://your-app.example.com/webhooks/products",
format: JSON
}
) {
webhookSubscription { id }
userErrors { field message }
}
}
Verify the HMAC signature on incoming webhooks (SHA-256 with your app secret). Return 200 within 5 seconds or Shopify retries — offload heavy processing to a background queue.
For inventory-only changes, subscribe to INVENTORY_LEVELS_UPDATE instead — smaller payload, faster processing.
Testing without a live store
Use a Shopify Partners development store — free, unlimited, populated with sample data. Create at partners.shopify.com. Full Admin API access, no plan required. Perfect for CI test suites — you can spin up a dev store per test run if needed.
Common errors
| Error | Cause | Fix |
|---|---|---|
Field 'productVariantCreate' doesn't exist | Deprecated in favor of productVariantsBulkCreate | Switch to bulk variant create + productSet |
422 Unprocessable Entity: SKU already taken | SKU uniqueness enforced per shop | Prepend product ID or unique suffix |
THROTTLED | Cost bucket empty | Wait based on throttleStatus; add caching |
ACCESS_DENIED on inventory mutation | Missing write_inventory scope | Update app scopes + re-authorize |
Bulk operation stuck in CREATED | Another bulk op running | Only one bulk op per shop at a time; wait or cancel |
Best Node.js library
Use @shopify/shopify-api (official). Handles OAuth, GraphQL, webhook verification, session storage, and throttle backoff automatically:
import { shopifyApi } from '@shopify/shopify-api';
const shopify = shopifyApi({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET,
scopes: ['write_products', 'read_inventory'],
hostName: 'your-app.example.com',
apiVersion: '2025-07',
});
const client = new shopify.clients.Graphql({ session });
const response = await client.query({ data: { query, variables } });
Frequently asked questions
Should I use GraphQL or REST for Shopify variants?
GraphQL. REST product endpoints are deprecated for new apps since Feb 2025. Same capabilities, better efficiency, ongoing feature investment.
How do I create a product with variants via API?
Use productSet mutation with productOptions + variants arrays. Atomic — all variants created in one request. Response includes userErrors array to check.
How do I bulk import 1,000+ variants?
Bulk Operations API. Submit bulkOperationRunMutation with a JSONL file uploaded to staged upload target. Async — poll for completion. No rate limit but slower per-item throughput.
How do I update variant price?
productVariantsBulkUpdate — up to 250 variants per call. Pass productId + array of {id, price} objects.
How do I update inventory?
inventoryAdjustQuantities for delta changes, inventorySetQuantities for absolute values. Include valid reason code. Per-location per-variant.
What's the Admin API rate limit?
Cost-based bucket. Standard: 1,000 points, 50/sec restore. Plus: 10,000 points, 500/sec restore. Check extensions.cost.throttleStatus in every response.
How do I subscribe to variant change webhooks?
webhookSubscriptionCreate mutation with topic PRODUCTS_UPDATE (variants included) or INVENTORY_LEVELS_UPDATE (inventory only, smaller payload).
Best library for Shopify Admin API in Node?
@shopify/shopify-api — official, handles OAuth, GraphQL, webhooks, session, throttle backoff.
How do I test without a live store?
Shopify Partners dev store — free, unlimited, full API access. Create at partners.shopify.com.
How do I handle throttling errors?
Wait based on throttleStatus.currentlyAvailable vs required cost. Wrap client in exponential backoff. Use bulk operations for large jobs.
Related developer references
- Storefront API for Variants — Client-side reference
- Shopify Webhook for Variant Changes
- Shopify Line Item Properties — Complete Guide
- Shopify 2,048 Variants Per Product Limit
Building a Shopify app with variants?
Print It My Way is an example of a Shopify app doing exactly this — variants + line-item properties + Cart Transform Functions. Free plan available for merchants.
Install Print It My Way