David Lewin David Lewin
E-Commerce, Research & Ads

Syncing 50,000 products: BigCommerce vs Shopify API rate limits, variants and B2B pricing

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

For a full catalog export, Shopify's GraphQL bulk operations are the better tool: one query, no rate limit, a JSONL file you rebuild with __parentId. Paged GraphQL queries are a poor fit at this size because nested variants make each query expensive and Shopify caps pagination at 25,000 objects. BigCommerce's REST catalog is simpler to reason about: 250 products per page with variants included, and a request quota per 30 seconds (150 on Core and Growth, 450 on Scale) that parallel workers can share using the rate limit headers. For frequent writes and B2B pricing, BigCommerce is ahead on lower plans: batch variant updates of 50, price lists with bulk upserts of 1,000 records, and customer groups from Growth up, while Shopify's points budget grows tenfold only on Plus.

At 500 products, any API works and the choice of platform barely matters to the integration. At 50,000 products with several variants each, the sync job becomes a capacity problem: how many requests the platform lets you make, how much data each one returns, and what happens when an ERP, a PIM and a repricer all share the same budget. BigCommerce and Shopify answer this with different models. This guide compares them where large catalogs feel it, with two tested exporters and our view on which platform fits which catalog.

How this guide was made

Limits and endpoints come from the BigCommerce developer and help center documentation, Shopify's developer documentation and both vendors' pricing pages, checked in October 2026. Both scripts were tested against local mocks that reproduce the documented behavior: Shopify's bulk operation flow and throttling errors, and BigCommerce's pagination, rate limit headers and 429 responses, with a synthetic catalog of 50,000 products and 206,093 variants. They weren't run against live stores. Durations are our estimates from published limits, not measurements.

1. The short answer

BigCommerceShopify
Rate limit modelRequests per 30-second window, per store, shared by all appsQuery cost in points per second, per app and store
Budget by planCore and Growth: 150 per 30 s (20,000 an hour). Scale: 450 per 30 s. Performance: by plan, some unlimitedStandard: 100 points/s. Advanced: 200. Plus: 1,000. Enterprise: 2,000
Full catalog exportParallel page requests: 250 products per page, variants includedBulk operation: one query, not rate limited, JSONL result
Pagination limitsPage numbers; responses capped at 8 MB250 per page, cursor-based; pagination capped at 25,000 objects
Batch writes10 products or 50 variants per requestAll variants of one product per mutation; bulk mutations from a JSONL file up to 100 MB
Variants per productUp to 600 SKUs; up to 250 values per optionUp to 2,048 variants; 3 options
B2B pricingPrice lists by customer group, bulk upsert of 1,000 records; customer groups from Growth, Price Lists on PerformanceB2B catalogs and price lists, built for Plus; price inputs up to 250 per call

2. Two rate limit models

Shopify: exporting 50,000 products as one bulk job
Shopify: exporting 50,000 products as one bulk jobStart one bulk query → Poll or webhook → Download and rebuildStart one bulk query: bulkOperationRunQuery starts a background job; the job itself isn't rate limitedStart one bulk querybulkOperationRunQuery starts a background job; the job itselfisn't rate limitedPoll or webhook: bulkOperation(id:) every few seconds, or subscribe to bulk_operations/finishPoll or webhookbulkOperation(id:) every few seconds, or subscribe tobulk_operations/finishDownload and rebuild: variants arrive as separate JSONL lines with __parentIdDownload and rebuildvariants arrive as separate JSONL lines with __parentId
Shopify moves the heavy work into an asynchronous job: a handful of cheap requests, whatever the catalog size.
BigCommerce: exporting 50,000 products as parallel pages
BigCommerce: exporting 50,000 products as parallel pagesRead page 1 → Worker 1 / Worker 2 / Worker 3 / Worker 4 → One shared quotaRead page 1: total_pages: 200 pages of 250 products, variants embeddedRead page 1total_pages: 200 pages of 250 products, variants embeddedWorker 1: takes the next free pageWorker 1takes thenext freepageWorker 2: takes the next free pageWorker 2takes thenext freepageWorker 3: takes the next free pageWorker 3takes thenext freepageWorker 4: takes the next free pageWorker 4takes thenext freepageOne shared quota: X-Rate-Limit-Requests-Left and -Time-Reset-Ms pause every worker togetherOne shared quotaX-Rate-Limit-Requests-Left and -Time-Reset-Ms pause everyworker together
BigCommerce keeps the work in ordinary requests, so throughput depends on your plan's quota, the store's other apps, and how well your workers share it.

BigCommerce counts requests. Every REST call costs one unit of a quota that refills every 30 seconds. The quota belongs to the store, not to your app: the docs state that all apps accessing the store share it. Every response tells you where you stand in X-Rate-Limit-Requests-Left, X-Rate-Limit-Time-Reset-Ms, X-Rate-Limit-Requests-Quota and X-Rate-Limit-Time-Window-Ms. Over the quota you get HTTP 429, and you wait the reset time. A 429 without those headers means the platform itself is under load, and you should back off harder.

Shopify counts query cost. Each GraphQL query has a requested cost computed before it runs: objects cost 1, mutations 10, and a connection costs in proportion to its first argument. A single query may not exceed 1,000 points on any plan. Points refill at the plan's rate, and the response's extensions.cost.throttleStatus reports what's left. The consequence for catalogs: nesting variants inside products multiplies the cost. By our reading of the cost rules, products(first: 250) with variants(first: 100) requests roughly 25,000 points, far over the cap, so paged queries with variants are forced down to a handful of products per page.

3. Exporting the Shopify catalog: use a bulk operation

Paging through 50,000 products on Shopify runs into two documented walls: the per-query cost cap above, and a pagination limit of 25,000 objects. Shopify's own docs point large datasets to bulk operations. You submit one query, Shopify runs it in the background (up to 10 days for a query), and you download a JSONL file. Since API version 2026-01, an app can run up to five bulk queries per shop at once and poll each by ID. Bulk queries allow at most five connections, nested at most two levels deep, which products with variants fits.

#!/usr/bin/env python3
"""Export every Shopify product with its variants through a GraphQL bulk operation,
then rebuild product -> variants from the JSONL result. Python 3.9+, standard library only.

  export SHOP=your-store.myshopify.com SHOPIFY_TOKEN=shpat_...   # read_products scope
  python3 shopify_bulk_export.py products.jsonl
  python3 shopify_bulk_export.py changed.jsonl --since 2026-10-18T00:00:00Z

Output: one product per line, with a "variants" list.
"""
import argparse
import json
import os
import sys
import time
import urllib.request

API = os.environ.get('SHOPIFY_API') or 'https://%s/admin/api/2026-07/graphql.json' % os.environ.get('SHOP')

QUERY = '''{ products%s { edges { node {
  id handle title status updatedAt
  variants { edges { node { id sku price compareAtPrice inventoryQuantity
    selectedOptions { name value } } } } } } } }'''


def gql(query, variables=None):
    body = json.dumps({'query': query, 'variables': variables or {}}).encode()
    headers = {'X-Shopify-Access-Token': os.environ['SHOPIFY_TOKEN'], 'Content-Type': 'application/json'}
    for attempt in range(6):
        with urllib.request.urlopen(urllib.request.Request(API, body, headers), timeout=60) as r:
            res = json.load(r)
        throttled = any(e.get('extensions', {}).get('code') == 'THROTTLED' for e in res.get('errors', []))
        if not throttled:
            if res.get('errors'):
                sys.exit('GraphQL error: %s' % res['errors'])
            return res['data']
        time.sleep(2 ** attempt)  # bulk operations aren't rate limited, but these small calls are
    sys.exit('still throttled after 6 attempts')


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('out')
    ap.add_argument('--since', help="only products updated after this ISO time, e.g. 2026-10-18T00:00:00Z")
    a = ap.parse_args()
    query = QUERY % ('(query: "updated_at:>\'%s\'")' % a.since if a.since else '')

    started = gql('''mutation($q: String!) { bulkOperationRunQuery(query: $q) {
        bulkOperation { id status } userErrors { field message } } }''', {'q': query})
    run = started['bulkOperationRunQuery']
    if run['userErrors']:
        sys.exit('could not start: %s' % run['userErrors'])
    op_id, t0 = run['bulkOperation']['id'], time.time()
    print('started', op_id)

    while True:  # bulkOperation(id:) needs API 2026-01+; a bulk_operations/finish webhook saves polling
        time.sleep(5)
        op = gql('query($id: ID!) { bulkOperation(id: $id) { status errorCode objectCount url } }',
                 {'id': op_id})['bulkOperation']
        print('%-9s %8s objects  %4.0fs' % (op['status'], op['objectCount'], time.time() - t0))
        if op['status'] not in ('CREATED', 'RUNNING'):
            break
    if op['status'] != 'COMPLETED':
        sys.exit('bulk operation %s: %s' % (op['status'], op['errorCode']))
    if not op['url']:  # completed with no matching products
        open(a.out, 'w').close()
        return print('0 products')

    # Variants arrive as their own lines with __parentId, after their product.
    # Collect them in memory: about 1-2 KB per variant, so 500k variants fit in under 1 GB.
    products, order = {}, []
    with urllib.request.urlopen(op['url'], timeout=300) as r:
        for line in r:
            row = json.loads(line)
            parent = row.pop('__parentId', None)
            if parent:
                products[parent]['variants'].append(row)
            else:
                row['variants'] = []
                products[row['id']] = row
                order.append(row['id'])
    with open(a.out, 'w', encoding='utf-8') as f:
        for pid in order:
            f.write(json.dumps(products[pid]) + '\n')
    variants = sum(len(p['variants']) for p in products.values())
    print('%d products, %d variants -> %s' % (len(order), variants, a.out))


if __name__ == '__main__':
    main()
  • Rebuild from __parentId. Variants are separate lines that point to their product. Shopify guarantees children come after their parent, not directly after it, so the script keeps products in a dictionary until the file ends. Our mock interleaved them on purpose.
  • Retry THROTTLED. The bulk job isn't rate limited, but the calls that start and poll it are. The mock throttled the first call; the script waited and retried.
  • Use --since for deltas. The same job with an updated_at filter exports only changed products, which is how a nightly sync should run between full exports.
  • Prefer the webhook in production. Subscribing to bulk_operations/finish replaces polling, but Shopify notes that webhook delivery isn't guaranteed, so keep a slow poll as a fallback.
started gid://shopify/BulkOperation/1
RUNNING      20000 objects     5s
RUNNING      40000 objects    10s
COMPLETED   256093 objects    15s
50000 products, 206093 variants -> products.jsonl

That's the mock's output. A real job's duration depends on Shopify's queue and the catalog, and Shopify doesn't publish a throughput figure; plan for minutes, not seconds, and don't block a web request on it.

4. Exporting the BigCommerce catalog: parallel pages on a shared quota

BigCommerce's catalog endpoint returns up to 250 products per page, and include=variants embeds each product's variants, so 50,000 products is about 200 requests. On Core or Growth that's at least 40 seconds of quota; on Scale at least 14. The work is in sharing the quota between parallel workers without tripping 429s, and leaving room for the store's other apps.

#!/usr/bin/env python3
"""Export a BigCommerce catalog (products with variants, optionally a B2B price list)
with parallel page requests that respect the store's shared rate limit.
Python 3.9+, standard library only.

  export BC_STORE_HASH=abc123 BC_TOKEN=...      # API account with Products read-only scope
  python3 bigcommerce_export.py products.jsonl --workers 4
  python3 bigcommerce_export.py prices.jsonl --price-list 3   # price list records (Price Lists scope)
"""
import argparse
import concurrent.futures
import itertools
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request

API = os.environ.get('BC_API') or 'https://api.bigcommerce.com/stores/%s/v3' % os.environ.get('BC_STORE_HASH')


class Limiter:
    """Shares one view of the quota headers between all threads. All apps on a store share
    its quota, so leave headroom for the others instead of draining it to zero."""

    def __init__(self, headroom):
        self.headroom, self.lock, self.pause_until = headroom, threading.Lock(), 0.0

    def wait(self):
        while True:
            with self.lock:
                delay = self.pause_until - time.time()
            if delay <= 0:
                return
            time.sleep(delay)

    def update(self, headers):
        left, reset = headers.get('X-Rate-Limit-Requests-Left'), headers.get('X-Rate-Limit-Time-Reset-Ms')
        if left is not None and reset is not None and int(left) <= self.headroom:
            with self.lock:
                self.pause_until = max(self.pause_until, time.time() + int(reset) / 1000)


def get(limiter, path, params):
    url = '%s%s?%s' % (API, path, '&'.join('%s=%s' % kv for kv in params.items()))
    req = urllib.request.Request(url, headers={'X-Auth-Token': os.environ['BC_TOKEN'],
                                               'Accept': 'application/json'})
    for attempt in range(8):
        limiter.wait()
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                limiter.update(r.headers)
                return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code == 429:
                reset = e.headers.get('X-Rate-Limit-Time-Reset-Ms')
                # No headers on a 429 means the store is under platform load: back off harder
                delay = int(reset) / 1000 if reset else 2 ** attempt
                with limiter.lock:
                    limiter.pause_until = max(limiter.pause_until, time.time() + delay)
            elif e.code >= 500:
                time.sleep(2 ** attempt)
            else:
                sys.exit('%s -> HTTP %d: %s' % (url, e.code, e.read()[:300]))
    sys.exit('%s: gave up after 8 attempts' % url)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('out')
    ap.add_argument('--workers', type=int, default=4)
    ap.add_argument('--limit', type=int, default=250,
                    help='items per page (max 250); lower it if products have hundreds of variants')
    ap.add_argument('--price-list', type=int, help='export this price list instead of products')
    a = ap.parse_args()

    if a.price_list:
        path, params = '/pricelists/%d/records' % a.price_list, {'limit': a.limit}
    else:
        # sort=id keeps pages stable while the export runs: new products get higher IDs
        path, params = '/catalog/products', {'limit': a.limit, 'sort': 'id', 'include': 'variants',
                                             'include_fields': 'name,sku,price,sale_price,inventory_level,'
                                                               'is_visible,date_modified'}
    limiter, t0 = Limiter(headroom=a.workers * 2), time.time()
    first = get(limiter, path, dict(params, page=1))
    pages = first['meta']['pagination']['total_pages']
    print('%d items on %d pages' % (first['meta']['pagination']['total'], pages))

    seen, rows = set(), 0
    with open(a.out, 'w', encoding='utf-8') as f, \
            concurrent.futures.ThreadPoolExecutor(a.workers) as pool:
        jobs = [pool.submit(get, limiter, path, dict(params, page=p)) for p in range(2, pages + 1)]
        for page in itertools.chain([first], (j.result() for j in jobs)):  # write in page order
            for item in page['data']:
                key = (item.get('id'), item.get('variant_id'), item.get('price_list_id'))
                if key not in seen:  # a deletion mid-export can shift one item onto the next page
                    seen.add(key)
                    f.write(json.dumps(item) + '\n')
                    rows += 1
    print('%d rows -> %s in %.0fs' % (rows, a.out, time.time() - t0))


if __name__ == '__main__':
    main()
  • One limiter for all threads. When the remaining quota drops to the headroom, every worker pauses until the window resets. Per-thread backoff would let four workers stampede the quota together.
  • Headroom for other apps. Your ERP connector, reviews app and repricer draw from the same quota. Draining it to zero makes their requests fail, not yours.
  • Stable pages. Sorting by ID means products created during the export land on the last page instead of shifting every page. A deletion can still shift one product across a page boundary, so the script de-duplicates and a delta run catches the rest.
  • Watch the 8 MB response cap. 250 products with dozens of variants each can exceed it. Lower --limit for variant-heavy catalogs.
  • Price lists use the same pager. --price-list exports a B2B price list's records instead of products.
Mock testResult
50,000 products, 4 workers, quota scaled down to 30 requests per 2 seconds200 pages, 50,000 unique products with 206,093 variants, written in ID order
Quota exhausted and random 429s without rate limit headers10 responses with HTTP 429 across both runs, all retried; no gaps or duplicates
Price list with 150,000 records600 pages, 150,000 records

5. Writing changes back: where the plans differ most

Reads are a nightly job; writes are where a repricer or ERP sync lives all day. Take a price update for 200,000 variants across 50,000 products:

  • BigCommerce: the batch variants endpoint takes 50 variants per request, so 4,000 requests. At 150 per 30 seconds that's about 13 minutes; on Scale about 4.5.
  • Shopify, one mutation per product: productVariantsBulkUpdate updates all variants of one product at a time, so 50,000 mutations of at least 10 points each, 500,000 points or more.
  • Shopify, bulk mutation: bulkOperationRunMutation runs the same mutation over a JSONL file of up to 100 MB in the background, isn't subject to the rate limit, and must finish within 24 hours. For a full repricing, this is the way.
Minimum time to update prices on 200,000 variants, request by request
Minimum time to update prices on 200,000 variants, request by requestBigCommerce Core or Growth: 13.3 min; BigCommerce Scale: 4.4 min; Shopify Standard plans: 83 min; Shopify Advanced: 42 min; Shopify Plus: 8.3 minBigCommerce, batch of 50Shopify, one mutation per productBigCommerce Core or Growth: 13.3 minBigCommerce Core or Growth13.3 minBigCommerce Scale: 4.4 minBigCommerce Scale4.4 minShopify Standard plans: 83 minShopify Standard plans83 minShopify Advanced: 42 minShopify Advanced42 minShopify Plus: 8.3 minShopify Plus8.3 min
Our estimate from the published limits, assuming the quota is the only bottleneck and no other app uses it. Shopify assumes the minimum mutation cost of 10 points; real costs are often higher. Shopify bulk mutations aren't rate limited and aren't shown.

The point isn't that Shopify is slow: with bulk mutations it isn't. It's that on BigCommerce's lower plans, plain request-by-request updates are fast enough for intraday changes, while on Shopify below Plus you design around bulk jobs from the start.

6. Variants and B2B pricing

LimitBigCommerceShopify
Variants per product600 SKUs2,048 variants
OptionsNo documented cap; 250 values per option3 options per product
Catalog growthNo throttle documentedAfter 500,000 variants, at most 10,000 new variants a day (not on Plus)
Customer-specific pricesPrice lists assigned to customer groups; cascading price lists; bulk upsert of 1,000 records, one at a time per storeB2B catalogs with price lists assigned to companies; price inputs capped at 250 per call
Plan requiredCustomer groups: Growth and up. Price Lists: Performance (from $1,499 a month billed yearly) or B2B EditionB2B was built for Plus; third-party sources report limited B2B catalogs on other plans since 2026, so check your plan

For apparel with sizes, colors and fits, Shopify's 2,048 variants are generous but its three options can be the tighter limit; BigCommerce's 600 SKUs per product can bind first for configurable industrial parts. For B2B, the deciding factor is usually the plan, not the API: tiered prices for a few customer groups on BigCommerce sit in a mid-tier plan, while per-company catalogs and price lists on Shopify mean Plus.

One BigCommerce detail that catches parallel sync jobs: only one price list bulk upsert can run per store at a time, and a second one in parallel fails with 429. Queue price list writes in a single worker even if everything else runs in parallel.

7. The sync architecture we'd use on either platform

Full export, deltas, reconciliation
Full export, deltas, reconciliationNightly full export → Webhooks for changes / Delta export → ReconcileNightly full export: bulk query on Shopify, parallel pages on BigCommerce; the source of truth for the next dayNightly full exportbulk query on Shopify, parallel pages on BigCommerce; thesource of truth for the next dayWebhooks for changes: Shopify products/update; BigCommerce store/product/updated and store/product/inventory/updatedWebhooks for changesShopify products/update;BigCommercestore/product/updated andstore/product/inventory/updatedDelta export: --since on Shopify, date_modified:min on BigCommerce, every 15–60 minutesDelta export--since on Shopify,date_modified:min onBigCommerce, every 15–60minutesReconcile: compare counts and a checksum of SKU, price and stock per product; re-fetch only what differsReconcilecompare counts and a checksum of SKU, price and stock perproduct; re-fetch only what differs
Webhooks are fast but not guaranteed, deltas are reliable but delayed, the full export catches everything both missed.

Don't poll date_modified on BigCommerce for stock: its docs note that inventory changes made through the Inventory API don't update it. Use the inventory webhook for stock, and the delta for everything else.

8. Our opinion: trade-offs

  • Large B2B catalogs on a mid-market budget: BigCommerce. Request-based limits that are easy to plan, batch writes that are fast enough without bulk jobs, and customer-group pricing without moving to an enterprise plan. The weak spots: the quota is shared with every app on the store, and price lists proper need Performance or B2B Edition.
  • DTC catalogs with deep variants and an app-heavy stack: Shopify. Bulk operations are the best full-export tool of the two, and 2,048 variants cover most apparel. Plan for bulk jobs from day one, and budget for Plus if you need per-company B2B pricing.
  • Don't pick a platform on API speed alone. Both can export 50,000 products in minutes when used the right way. The difference is in how much engineering the "right way" takes on your plan.
  • Measure the shared budget, not just yours. On BigCommerce, log X-Rate-Limit-Requests-Left for a day before adding a sync: if other apps already use most of the quota, your job's speed is theirs to decide.

Related: Amazon SP-API price and inventory sync and monitoring competitor Shopify prices.

9. Tools

Top picks

ToolRoleWhy it fits
BigCommerceSaaS commerce platform for large and B2B catalogs250 products per page with variants embedded, batch variant writes, per-window quotas you can read from every response, and customer-group pricing from the Growth plan. Plans from $29 a month billed yearly (Core); Scale at $299 triples the API quota.

Alternatives: free and open-source tools

We don't earn anything from these links.

10. Pitfalls

  • Paging Shopify with nested variants. The query cost forces tiny pages, and the 25,000-object pagination limit stops you halfway through 50,000 products. Use a bulk query.
  • Per-thread backoff on BigCommerce. Each worker backing off on its own still hits the quota together. Share one view of the headers.
  • Parallel price list upserts. BigCommerce allows one at a time per store; the second fails with 429.
  • Trusting one webhook. Both platforms can miss or delay deliveries. Always have a delta export and a reconciliation behind them.
  • Forgetting API versions. Shopify releases a version every quarter and supports each for at least 12 months. Pin a version in the URL and schedule upgrades; bulkOperation(id:) needs 2026-01 or later.
  • Testing on a tiny store. Rate limit behavior only shows at scale. Generate a test catalog of realistic size in a sandbox before launch.
Bottom line

On Shopify, export large catalogs with bulk operations and write with bulk mutations; paged GraphQL with nested variants doesn't scale to 50,000 products below Plus. On BigCommerce, page through 250 products at a time with variants embedded and share one quota across your workers, leaving room for the store's other apps. For B2B catalogs with customer-group pricing and frequent price changes on a mid-market budget, BigCommerce is our pick; for DTC catalogs with deep variants, Shopify's bulk tooling is excellent if you design for it.

Tools mentioned in this guide · affiliate links

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.