Shopify products and variants from Python
Upserts with productSet, granular edits with productVariantsBulkUpdate and the 2,048-variant product model.
Overview
Shopify's product model splits products from their variants and gives each its own GraphQL mutations. Three of them cover all catalog work, each for a different job:
- productSet is an upsert - it creates the product if it does not exist and updates it if it does, variants included, which makes it the right tool for syncing from an external catalog such as a PIM or an ERP
- productCreate makes a new product, without variants beyond the default one
- productVariantsBulkUpdate edits variants of one existing product
All of them run through the same outgoing GraphQL connection that the Admin API guide sets up - conn = self.out.graphql['Shopify'], then conn.execute(mutation, params=params).
A note on history you will run into when searching the web: until 2024 most integrations used Shopify's REST endpoints, where a product and its variants were written in one request. That API is legacy now, and products created through it are capped at 100 variants - the GraphQL model below, with its 2,048-variant ceiling, is the only current way.
One important nuance: the word Bulk in productVariantsBulkUpdate means many variants, not many products - every call is scoped to a single product ID. Updating variants across 5,000 products means 5,000 calls, which is where rate limits and bulk operations enter the picture.
Syncing a product with productSet
Build a ProductSetInput with the product fields, its options and its variants, keyed by whatever your source system knows - typically SKUs. Because productSet is an upsert, the same service handles both the first sync and every later one, and re-running it after a failure is safe.
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class SyncProduct(Service):
name = 'shopify.sync-product'
def handle(self):
conn = self.out.graphql['Shopify']
mutation = """
mutation Upsert($input: ProductSetInput!) {
productSet(input: $input) {
product {
id
variants(first: 5) {
edges { node { id sku } }
}
}
userErrors { field message }
}
}
"""
product_input = {
'title': 'Winter Hat',
'productOptions': [
{'name': 'Size', 'values': [{'name': 'S'}, {'name': 'M'}, {'name': 'L'}]},
],
'variants': [
{'optionValues': [{'optionName': 'Size', 'name': 'S'}], 'sku': 'HAT-001-S', 'price': '19.99'},
{'optionValues': [{'optionName': 'Size', 'name': 'M'}], 'sku': 'HAT-001-M', 'price': '19.99'},
{'optionValues': [{'optionName': 'Size', 'name': 'L'}], 'sku': 'HAT-001-L', 'price': '21.99'},
],
}
result = conn.execute(mutation, params={'input': product_input})
user_errors = result['productSet']['userErrors']
if user_errors:
raise Exception(f'Product sync failed: {user_errors}')
self.response.payload = result['productSet']['product']
By default, productSet treats the variants list as the complete desired state of the product - variants absent from the input are removed, which is exactly the behavior a catalog sync wants.
Updating variants of an existing product
Use productVariantsBulkUpdate - pass the product's GID and a list of variant inputs, each identified by its variant GID. This is the granular tool for price changes and similar edits when you do not want to restate the whole product.
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class UpdateVariantPrices(Service):
name = 'shopify.update-variant-prices'
input = 'product_id', 'price_updates'
def handle(self):
conn = self.out.graphql['Shopify']
mutation = """
mutation UpdatePrices($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id price }
userErrors { field message }
}
}
"""
# price_updates is a list of {'id': variant_gid, 'price': '24.99'}
params = {
'productId': self.request.input.product_id,
'variants': self.request.input.price_updates,
}
result = conn.execute(mutation, params=params)
user_errors = result['productVariantsBulkUpdate']['userErrors']
if user_errors:
raise Exception(f'Variant update failed: {user_errors}')
To add variants to an existing product without restating all of them, use productVariantsBulkCreate with the product's GID and only the new variants.
Limits that shape catalog code
| Limit | Value | What it means in practice |
|---|---|---|
| Variants per product | 2,048 | Only through these GraphQL mutations - products written through legacy paths stay at 100 |
| Products per mutation call | 1 | Cross-catalog updates become one call per product - plan for rate limits |
| Inventory quantities per mutation | 50,000 across all variants | Very large productSet calls fail with INVENTORY_QUANTITIES_LIMIT_EXCEEDED - split them |
For a full catalog import or a nightly sync of thousands of products, do not loop over per-product mutations at all - hand the whole job to Shopify with a bulk operation and let a scheduler job collect the result.
Where inventory fits in
Quantities can ride along in variant inputs, but ongoing stock updates belong to the inventory APIs and a reconciliation loop - the ERP and inventory sync guide covers that end to end.