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.
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 OKresponse 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::LoadSyncWindowMetafields::UpsertDefinitionWebhooks::RegisterSubscriptionOrders::PageThroughUpdatedRangeCatalog::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
endThat 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
endNotice 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]
)
endAlso 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 case | Good starting page size | Why | Main caution |
|---|---|---|---|
| Merchant-facing admin list | 20 to 50 | Fast enough to render, cheap to retry | Do not overfetch nested edges |
| Incremental sync job | 50 to 100 | Reasonable throughput with manageable retries | Persist cursor or watermark after durable writes |
| Heavy nested query | 10 to 25 | Keeps requested cost under control | Large nested selections explode cost quickly |
| Full export | Do not start here | Usually a bulk-ops problem instead | Cursor 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
endMore importantly, do not flatten all failures into one “Shopify said no” bucket. There are at least four materially different layers:
| Layer | Where it appears | Meaning | Typical action |
|---|---|---|---|
| Transport failure | HTTP status or client exception | Network, auth, service availability, malformed request | Retry selectively, alert when persistent |
| Top-level GraphQL errors | errors | Query invalid, throttled, max cost exceeded, internal error | Classify by extensions.code |
| Mutation user errors | userErrors | Validation or business-rule failure | Do not blind-retry, surface details |
| Domain-level partial success | Payload-specific | Some work succeeded before failure handling kicked in | Require 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
endThe 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
UserErrorobjects...”
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
endIdempotency 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
endThe 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:
| Situation | Prefer | Why | Main caution |
|---|---|---|---|
| User is waiting on a page of data now | Cursor pagination | Predictable, interactive, small scope | Keep field selection lean |
| Incremental background sync | Cursor pagination or bulk | Depends on data volume and freshness target | Checkpoint carefully either way |
| Full catalog or order-history export | Bulk query | Made for large-volume async extraction | Parse JSONL and store durable progress |
| Mass create or update workflow | Bulk mutation | Line-level async execution beats client loops | Input 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
endclass 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
endBulk 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
partialDataUrlexists
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
endIn 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 oldercurrentBulkOperationpattern 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
endOne 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
Shopify Dev: API limits
Shopify Dev: Paginating results with GraphQL
Shopify Dev: GraphQL Admin API reference and status codes
Shopify Dev: UserError
Shopify Dev: orders query
Shopify Dev: customers query
Shopify Dev: Perform bulk operations with the GraphQL Admin API
Shopify Dev: Bulk import data with the GraphQL Admin API
shopify-api-ruby: Make a GraphQL API call
shopify-api-ruby: Performing OAuth
Shopify API Library for Ruby: REST deprecation note
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
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.
When to use Bulk Operations instead of pagination in Shopify apps
A decision guide for Shopify developers choosing between synchronous GraphQL pagination and Bulk Operations, with practical thresholds based on workload shape rather than generic 'large dataset' advice.
Calling a Rails API from a Shopify customer account extension
A practical guide to calling Rails endpoints from a Shopify Customer Account UI Extension, including session-token verification, endpoint design, and the requests that should not go through your backend at all.