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

LimitValueWhat it means in practice
Variants per product2,048Only through these GraphQL mutations - products written through legacy paths stay at 100
Products per mutation call1Cross-catalog updates become one call per product - plan for rate limits
Inventory quantities per mutation50,000 across all variantsVery 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.

Learn more