Guides

Developer guide

Shopify Admin GraphQL patterns in Rails

Production patterns for using the Shopify Admin GraphQL API from Rails, including service boundaries, pagination strategy, throttling, partial failure handling, and when to switch to bulk operations.

Updated March 12, 2026
18 min read
Editorial note: The dangerous part of Shopify GraphQL is not writing a query. It is believing the demo query is the architecture.

What this article is really about

The beginner mistake with Shopify Admin GraphQL in Rails is focusing on query syntax. That part is easy. You write a heredoc, send it, get JSON back, and everything appears to be under control.

Then production arrives wearing steel-toe boots. A job times out halfway through a catalog sync. A mutation returns HTTP 200 but still failed. A query that worked on a tiny dev store gets throttled on a Plus merchant with enough variants to stress your job queue. Somebody retries a “mostly successful” mutation and accidentally creates a second thing that was only supposed to exist once.

That is the real topic here. Not “how do I send a GraphQL request from Ruby?”, but “what patterns survive when the store is large, the data is messy, operators need understandable failure modes, and your future self would like to sleep tonight?”

“The GraphQL API can return a 200 OK response code...”

The working rule

Treat Shopify Admin GraphQL as an integration boundary, not a convenience helper. Your Rails code should make failure shape, retry shape, and data-volume shape obvious.

Build explicit GraphQL services

In Rails, the first architecture decision is whether Shopify calls are allowed to leak everywhere. If controllers, models, background jobs, and random utility modules all get to build raw query strings and poke the API directly, you do not have a GraphQL integration. You have a distributed accident.

Shopify’s Ruby client requires a valid session and supports either passing a session directly or relying on an active session from context. Rails apps can lean on global session state, but explicit session passing is usually the safer production default for background jobs and service objects. It makes the shop boundary visible in code instead of smearing it across thread-local magic and vibes.

The right shape is a small shared client wrapper plus narrow task-specific services. Think in verbs and bounded responsibilities:

  • Products::LoadSyncWindow
  • Metafields::UpsertDefinition
  • Webhooks::RegisterSubscription
  • Orders::PageThroughUpdatedRange
  • Catalog::StartBulkExport

Each service should own five things that teams otherwise forget in five different places:

  • query text and variable normalization
  • shop session selection
  • response inspection and error classification
  • cost and throttle logging
  • retry and idempotency policy

A good base client does not try to be a magical abstraction over all of GraphQL. It just standardizes the boring but essential parts. Boring is good here. Boring is how you avoid inventing a whole second bug surface.

# app/services/shopify_admin/graphql_client.rb
class ShopifyAdmin::GraphqlClient
  Result = Struct.new(
    :data,
    :errors,
    :user_errors,
    :requested_cost,
    :actual_cost,
    :throttle_status,
    :http_code,
    :raw_body,
    keyword_init: true
  ) do
    def ok?
      http_code.to_i == 200 && errors.blank? && user_errors.blank?
    end
 
    def throttled?
      errors.any? { |e| e.dig("extensions", "code") == "THROTTLED" }
    end
  end
 
  def initialize(session:, api_version: nil)
    @client = ShopifyAPI::Clients::Graphql::Admin.new(
      session: session,
      api_version: api_version
    )
  end
 
  def query(query:, variables: {})
    response = @client.query(query: query, variables: variables)
    body = response.body
 
    extensions = body["extensions"] || {}
    cost = extensions["cost"] || {}
 
    Result.new(
      data: body["data"],
      errors: Array(body["errors"]),
      user_errors: extract_user_errors(body["data"]),
      requested_cost: cost["requestedQueryCost"],
      actual_cost: cost["actualQueryCost"],
      throttle_status: cost["throttleStatus"] || {},
      http_code: response.code,
      raw_body: body
    )
  rescue ShopifyAPI::Errors::HttpResponseError => e
    raise ShopifyAdmin::TransportError.new(e.full_messages.join(", "))
  end
 
  private
 
  # Pulls mutation-level userErrors from the first payload object that exposes them.
  # Keep this dumb and predictable rather than clever and haunted.
  def extract_user_errors(data)
    return [] unless data.is_a?(Hash)
 
    payload = data.values.find { |value| value.is_a?(Hash) && value.key?("userErrors") }
    Array(payload&.dig("userErrors"))
  end
end

That wrapper gives the rest of your app a stable shape. Your task-specific services can then focus on business meaning instead of spelunking response hashes.

# app/services/shopify_admin/products/load_sync_window.rb
class ShopifyAdmin::Products::LoadSyncWindow
  QUERY = <<~GRAPHQL
    query($first: Int!, $after: String, $query: String!) {
      products(first: $first, after: $after, query: $query, sortKey: UPDATED_AT) {
        nodes {
          id
          title
          updatedAt
          status
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  GRAPHQL
 
  def initialize(session:, updated_since:, cursor: nil, page_size: 50)
    @client = ShopifyAdmin::GraphqlClient.new(session: session)
    @updated_since = updated_since
    @cursor = cursor
    @page_size = page_size
  end
 
  def call
    result = @client.query(
      query: QUERY,
      variables: {
        first: @page_size,
        after: @cursor,
        query: "updated_at:>=#{@updated_since.iso8601}"
      }
    )
 
    raise ShopifyAdmin::GraphqlError, result.errors if result.errors.present?
 
    connection = result.data.fetch("products")
 
    {
      nodes: connection.fetch("nodes"),
      next_cursor: connection.dig("pageInfo", "hasNextPage") ? connection.dig("pageInfo", "endCursor") : nil,
      requested_cost: result.requested_cost,
      actual_cost: result.actual_cost,
      throttle_status: result.throttle_status
    }
  end
end

Notice what is missing. No controller-level query strings. No model callback deciding it feels like talking to Shopify today. No “generic admin GraphQL helper” that accepts any query and therefore owns nothing.

Generic wrappers are great right up until you need observability, retries, auditing, shop-aware job arguments, or differentiated operator errors. Then suddenly “generic” just means “now the mess is centralized”.

Use cursor pagination deliberately

Shopify uses cursor-based pagination for connections, with first and after for forward pagination and PageInfo carrying endCursor and hasNextPage. The hard limit is 250 resources per page. That last number matters because it kills a lot of heroic nonsense early. You are not going to “just make the page huge” and be done with it.

“You can retrieve up to a maximum of 250 resources.”

Cursor loops are good for operational reads:

  • loading the next chunk in an admin sync job
  • incremental import by update window
  • merchant-facing views where the user is actually waiting
  • bounded datasets with sane field selection

Cursor loops are bad for “give me the entire store because I lack self-control.” That is where teams end up paginating thousands of pages through a shape that was never meant to be a full-data export strategy.

Two rules matter more than people expect.

1. Use a stable query shape

Store the last successful cursor or the last successfully committed sync watermark, not some fuzzy “we were probably around page 43-ish” sentiment. If a job retries, it should know exactly where it can resume without duplicating persistence work.

2. Align sort key with the search field when possible

Shopify’s query docs repeatedly note that if a query is slow or errors, you should try a sort key that matches the field used in the search. That means if you page on an updated_at filter, use an UPDATED_AT sort key where the connection supports it. Otherwise you are asking the backend to filter on one dimension and walk on another, which is how you end up staring at slow queries like they have personally insulted your family.

class ShopifyAdmin::CursorPager
  def initialize(fetch_page:)
    @fetch_page = fetch_page
  end
 
  def each_page(cursor: nil)
    loop do
      page = @fetch_page.call(cursor)
      yield page
 
      cursor = page.fetch(:next_cursor)
      break if cursor.blank?
    end
  end
end
 
pager = ShopifyAdmin::CursorPager.new(
  fetch_page: lambda do |cursor|
    ShopifyAdmin::Products::LoadSyncWindow.new(
      session: shop_session,
      updated_since: 2.hours.ago,
      cursor: cursor,
      page_size: 50
    ).call
  end
)
 
pager.each_page do |page|
  ProductSyncUpsert.call(shop: shop, nodes: page[:nodes])
  SyncCheckpoint.record!(
    shop: shop,
    cursor: page[:next_cursor],
    actual_cost: page[:actual_cost]
  )
end

Also keep pages cheap to retry. Developers love setting first: 250 because it feels efficient. Sometimes it is. Sometimes it just means every retry is now a larger blast radius, each page costs more, and your failure recovery has the grace of a fridge falling down stairs.

Use caseGood starting page sizeWhyMain caution
Merchant-facing admin list20 to 50Fast enough to render, cheap to retryDo not overfetch nested edges
Incremental sync job50 to 100Reasonable throughput with manageable retriesPersist cursor or watermark after durable writes
Heavy nested query10 to 25Keeps requested cost under controlLarge nested selections explode cost quickly
Full exportDo not start hereUsually a bulk-ops problem insteadCursor pagination becomes fake bulk export

One more subtle point: use nodes when you only need objects, and edges when you actually need the per-edge cursor or relationship context. Rails codebases often pull both because nobody wanted to decide. The API will not thank you for this expression of openness.

Handle cost, throttling, and GraphQL error layers

Shopify Admin GraphQL is cost-limited, not request-count-limited. That distinction should shape your Rails logging and retry strategy. Shopify calculates requested cost before execution and actual cost after execution, refunding the difference when actual cost is lower. The single-query cap is 1,000 cost points regardless of plan, and plan tier changes the restore rate over time.

“A single query may not exceed a cost of 1,000 points.”

That gives you three separate things to monitor:

  • the query shape you asked for
  • the actual cost Shopify charged
  • the remaining throttle budget and restore rate

Shopify returns this under extensions.cost, including requestedQueryCost, actualQueryCost, and throttleStatus. For debugging ugly queries, Shopify also supports the Shopify-GraphQL-Cost-Debug=1 header to return a field-level breakdown of requested cost.

class ShopifyAdmin::ThrottleAwareRunner
  MIN_BUFFER = 100
 
  def self.call(max_attempts: 5)
    attempts = 0
 
    begin
      attempts += 1
      result = yield
 
      if result.throttled?
        sleep backoff_for(result.throttle_status)
        raise RetryableThrottleError
      end
 
      if result.errors.present?
        raise ShopifyAdmin::GraphqlError, result.errors
      end
 
      result
    rescue RetryableThrottleError
      raise if attempts >= max_attempts
      retry
    end
  end
 
  def self.backoff_for(throttle_status)
    currently_available = throttle_status["currentlyAvailable"].to_i
    restore_rate = throttle_status["restoreRate"].to_i
 
    return 1.0 if restore_rate <= 0
 
    deficit = MIN_BUFFER - currently_available
    deficit <= 0 ? 0.25 : (deficit.to_f / restore_rate).ceil
  end
end

More importantly, do not flatten all failures into one “Shopify said no” bucket. There are at least four materially different layers:

LayerWhere it appearsMeaningTypical action
Transport failureHTTP status or client exceptionNetwork, auth, service availability, malformed requestRetry selectively, alert when persistent
Top-level GraphQL errorserrorsQuery invalid, throttled, max cost exceeded, internal errorClassify by extensions.code
Mutation user errorsuserErrorsValidation or business-rule failureDo not blind-retry, surface details
Domain-level partial successPayload-specificSome work succeeded before failure handling kicked inRequire idempotency and reconciliation

This matters because Shopify can return HTTP 200 and still include top-level GraphQL errors. A response can therefore be “transport-successful” and still “application-failed”. If your logger only records non-200 responses, you are basically measuring whether the envelope arrived, not whether the letter inside says your house is on fire.

def classify_graphql_result!(result)
  if result.errors.present?
    code = result.errors.first.dig("extensions", "code")
 
    case code
    when "THROTTLED"
      raise RetryableThrottleError, result.errors
    when "MAX_COST_EXCEEDED"
      raise QueryShapeError, result.errors
    when "INTERNAL_SERVER_ERROR"
      raise RetryableRemoteError, result.errors
    else
      raise ShopifyAdmin::GraphqlError, result.errors
    end
  end
 
  if result.user_errors.present?
    raise ShopifyAdmin::UserError.new(result.user_errors)
  end
end

The practical logging minimum for every non-trivial request is:

  • shop domain
  • service name
  • API version
  • requested cost
  • actual cost
  • maximum available
  • currently available
  • restore rate
  • top-level error codes
  • mutation userErrors
  • job ID or request ID

Without that, your postmortem will become interpretive dance.

Design mutations for partial failure and idempotency

The classic GraphQL mutation tutorial teaches you to send a mutation and inspect userErrors. That is fine as far as it goes. The problem is that production code also needs a policy for what happens after you inspect them.

Shopify documents UserError as mutation input or business-logic failure. That means these are not usually “retry until the clouds part” events. They are application-relevant failures that should be attached to a job record, operator-facing message, audit log, or retry queue with corrected inputs.

“Mutations return UserError objects...”

Your mutation wrapper should therefore separate three outcomes cleanly:

  • retryable remote failure
  • non-retryable user or validation failure
  • success with returned domain object
class ShopifyAdmin::Metafields::UpsertDefinition
  MUTATION = <<~GRAPHQL
    mutation($definition: MetafieldDefinitionInput!) {
      metafieldDefinitionCreate(definition: $definition) {
        createdDefinition {
          id
          name
          namespace
          key
        }
        userErrors {
          field
          message
        }
      }
    }
  GRAPHQL
 
  def initialize(session:, definition:)
    @client = ShopifyAdmin::GraphqlClient.new(session: session)
    @definition = definition
  end
 
  def call
    result = ShopifyAdmin::ThrottleAwareRunner.call do
      @client.query(
        query: MUTATION,
        variables: { definition: @definition }
      )
    end
 
    classify_graphql_result!(result)
 
    payload = result.data.fetch("metafieldDefinitionCreate")
    payload.fetch("createdDefinition")
  rescue ShopifyAdmin::UserError => e
    Rails.logger.info(
      event: "shopify.user_error",
      service: self.class.name,
      errors: e.message
    )
    raise
  end
 
  private
 
  def classify_graphql_result!(result)
    if result.errors.present?
      code = result.errors.first.dig("extensions", "code")
      raise RetryableRemoteError, result.errors if code == "THROTTLED" || code == "INTERNAL_SERVER_ERROR"
      raise ShopifyAdmin::GraphqlError, result.errors
    end
 
    raise ShopifyAdmin::UserError.new(result.user_errors) if result.user_errors.present?
  end
end

Idempotency matters even more for background jobs. If the network dies after Shopify committed the mutation but before your app stored the result, a retry can create duplicates unless you designed a reconciliation path.

The safest practical pattern is:

  • persist an internal operation record before calling Shopify
  • include a deterministic external reference when the mutation shape allows it
  • on retry, look up whether the target object already exists
  • make “already exists” a reconciled success path, not a panic path
class ShopifyOperation < ApplicationRecord
  enum status: {
    pending: "pending",
    succeeded: "succeeded",
    failed: "failed"
  }
end
 
class CreateDefinitionJob < ApplicationJob
  def perform(shop_id, operation_id)
    shop = Shop.find(shop_id)
    op = ShopifyOperation.find(operation_id)
    return if op.succeeded?
 
    definition = build_definition(op)
 
    created = ShopifyAdmin::Metafields::UpsertDefinition.new(
      session: shop.offline_session,
      definition: definition
    ).call
 
    op.update!(
      status: :succeeded,
      remote_gid: created.fetch("id")
    )
  rescue ShopifyAdmin::UserError => e
    op.update!(
      status: :failed,
      error_payload: { type: "user_error", errors: e.message }
    )
    raise
  end
end

The point is not that Shopify is weird. The point is that distributed systems are weird, and Shopify is one of the distributed systems currently living in your application. Pretending otherwise does not make it less true. It just makes your incident review shorter and sadder.

Know when to hand off to bulk operations

Cursor pagination is not a moral virtue. It is a tool. Use it until the job stops being a paginated read and starts being a data pipeline. At that point, hand off to Shopify bulk operations and stop trying to bench-press the whole catalog through synchronous page loops because you saw one nice demo once.

Shopify’s docs are explicit here. Bulk operations exist to reduce the complexity of paginating large volumes. Bulk query results come back as JSONL. Bulk queries can run for days, which is why Shopify recommends offline access tokens. In current docs, 2026-01 and higher also raise concurrency limits to up to five bulk query operations and up to five bulk mutation operations per shop, whereas earlier versions were far more restrictive.

“You should use offline access tokens...”

The decision framework is simple:

SituationPreferWhyMain caution
User is waiting on a page of data nowCursor paginationPredictable, interactive, small scopeKeep field selection lean
Incremental background syncCursor pagination or bulkDepends on data volume and freshness targetCheckpoint carefully either way
Full catalog or order-history exportBulk queryMade for large-volume async extractionParse JSONL and store durable progress
Mass create or update workflowBulk mutationLine-level async execution beats client loopsInput ordering is not guaranteed

A particularly underused production pattern is to make the handoff explicit in code. Do not let one service “sometimes paginate forever, sometimes bulk, depending on moonlight and caffeine.” Decide based on clear thresholds.

class ShopifyAdmin::SyncStrategy
  def self.for(record_estimate:, interactive:)
    return :cursor if interactive
    return :bulk if record_estimate > 5_000
 
    :cursor
  end
end
class ShopifyAdmin::CatalogExport
  def initialize(shop:, record_estimate:, interactive: false)
    @shop = shop
    @record_estimate = record_estimate
    @interactive = interactive
  end
 
  def call
    case ShopifyAdmin::SyncStrategy.for(
      record_estimate: @record_estimate,
      interactive: @interactive
    )
    when :cursor
      enqueue_cursor_sync
    when :bulk
      start_bulk_export
    end
  end
 
  private
 
  def enqueue_cursor_sync
    CatalogCursorSyncJob.perform_later(@shop.id)
  end
 
  def start_bulk_export
    CatalogBulkExportJob.perform_later(@shop.id)
  end
end

Bulk operations also deserve their own Rails pipeline rather than being bolted onto a normal query service:

  • start operation
  • persist returned operation ID
  • track status by polling or webhook
  • download JSONL result
  • stream-parse lines into durable storage
  • reconcile partial data if partialDataUrl exists
class ShopifyAdmin::Bulk::StartProductExport
  MUTATION = <<~GRAPHQL
    mutation {
      bulkOperationRunQuery(
        query: """
        {
          products {
            edges {
              node {
                id
                title
                updatedAt
                status
                variants {
                  edges {
                    node {
                      id
                      sku
                      updatedAt
                    }
                  }
                }
              }
            }
          }
        }
        """
      ) {
        bulkOperation {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }
  GRAPHQL
 
  def initialize(session:)
    @client = ShopifyAdmin::GraphqlClient.new(session: session)
  end
 
  def call
    result = @client.query(query: MUTATION)
 
    raise ShopifyAdmin::GraphqlError, result.errors if result.errors.present?
    raise ShopifyAdmin::UserError.new(result.user_errors) if result.user_errors.present?
 
    payload = result.data.fetch("bulkOperationRunQuery")
    payload.fetch("bulkOperation")
  end
end

In newer bulk docs, Shopify also notes two things many teams miss:

  • the query is limited to a single top-level field, with up to five total connections and up to two nested-connection levels

  • bulkOperation(id:) replaces the older currentBulkOperation pattern for direct lookup in newer API versions

That has a very practical implication for Rails architecture. Keep your status polling behind a tiny adapter. Do not spray currentBulkOperation calls throughout the codebase, because version-specific migration work is much less fun when it is everywhere.

class ShopifyAdmin::Bulk::StatusReader
  QUERY_BY_ID = <<~GRAPHQL
    query($id: ID!) {
      bulkOperation(id: $id) {
        id
        status
        errorCode
        objectCount
        url
        partialDataUrl
      }
    }
  GRAPHQL
 
  QUERY_CURRENT = <<~GRAPHQL
    query {
      currentBulkOperation(type: QUERY) {
        id
        status
        errorCode
        objectCount
        url
        partialDataUrl
      }
    }
  GRAPHQL
 
  def initialize(session:, api_version:)
    @client = ShopifyAdmin::GraphqlClient.new(
      session: session,
      api_version: api_version
    )
    @api_version = api_version
  end
 
  def call(operation_id:)
    if bulk_operation_lookup_supported?
      @client.query(query: QUERY_BY_ID, variables: { id: operation_id }).data["bulkOperation"]
    else
      @client.query(query: QUERY_CURRENT).data["currentBulkOperation"]
    end
  end
 
  private
 
  def bulk_operation_lookup_supported?
    Gem::Version.new(@api_version) >= Gem::Version.new("2026-01")
  end
end

One final bulk warning, because it bites people: Shopify says grouped bulk JSONL output is slower and more likely to time out. So unless you truly depend on grouped parent structure, keep the output flat and do the reconstruction yourself. Computers are good at this. They yearn for the JSONL mines.

Best internal links

Sources and further reading

FAQ

Should a Rails app centralize Shopify GraphQL access?

Yes. A thin client wrapper plus task-specific services gives you one place to normalize variables, inspect response cost, capture errors, and apply retry policy.

When should I stop paginating and switch to bulk operations?

Switch when you need most or all records from a large connection, the user is not waiting synchronously, or page-by-page retries have become an operational burden.

Is HTTP 200 enough to treat a Shopify GraphQL request as success?

No. Shopify can return HTTP 200 while still including top-level GraphQL errors, throttle-related information, or mutation userErrors that your application must handle explicitly.

Should bulk operations use online or offline tokens?

Offline tokens. Shopify explicitly recommends them for bulk queries because online tokens can expire before long-running operations finish.

Related resources

Keep exploring the playbook

Guides

Shopify Bulk Operations in Rails

A Rails implementation guide for Shopify Bulk Operations covering job orchestration, JSONL downloads, polling versus webhooks, and the service boundaries that make large syncs maintainable.

guidesShopify developerbulk operations