Shopify Admin GraphQL
How to ask Shopify for products and orders, how to change them, and the three silent failures that make beginners think their code works when it does not.
GraphQL in one minute
A normal REST API gives you fixed URLs that return fixed data. GraphQL gives you one URL where you describe exactly what you want, and get back exactly that — no more.
You send
{
product(id: "...") {
title
handle
}
}
You get
{
"data": {
"product": {
"title": "T-Shirt",
"handle": "t-shirt"
}
}
}
The shape of the answer matches the shape of the question. Ask for three fields, get three fields.
A query reads data. A mutation changes data. That is the whole vocabulary.
Making a call
authenticate.admin hands you an admin client. It returns a normal HTTP response, so you call .json() on it.
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(
`#graphql
query GetProduct($id: ID!) {
product(id: $id) {
id
title
status
}
}`,
{ variables: { id: "gid://shopify/Product/123" } },
);
const json = await response.json();
const product = json.data.product;
return { product };
};
Three details worth noticing:
#graphqlat the start is a comment that turns on syntax highlighting and validation in your editor. Always include it.$id: ID!declares a variable. The!means required.- Your data is always nested under
json.data.
// WRONG — breaks on quotes, and is unsafe
admin.graphql(`{ product(id: "` + userInput + `") { title } }`);
// RIGHT — pass it as a variable
admin.graphql(query, { variables: { id: userInput } });
GIDs: Shopify's ids
Shopify's GraphQL API does not use plain numbers. Everything has a global id:
gid://shopify/Product/8379080704066
gid://shopify/ProductVariant/45123456789
gid://shopify/Order/5544332211
The number at the end is the id you see in admin URLs. You will convert between the two constantly:
// number → GID
const gid = `gid://shopify/Product/${id}`;
// GID → number
const id = gid.split("/").pop();
When saving Shopify ids in your own database, save the whole GID string. It is unambiguous, it is what every API call wants, and you can always strip it when you need the number.
Mutations: changing data
Same shape, but you also ask for userErrors — and this is the most important thing in this note.
const response = await admin.graphql(
`#graphql
mutation UpdateProduct($product: ProductUpdateInput!) {
productUpdate(product: $product) {
product { id title }
userErrors { field message }
}
}`,
{ variables: { product: { id: productGid, title: "New title" } } },
);
const json = await response.json();
const errors = json.data?.productUpdate?.userErrors ?? [];
if (errors.length > 0) {
console.error(errors);
return { error: errors[0].message };
}
When a mutation fails because of a business rule — a bad value, a missing permission, a duplicate — Shopify still returns HTTP 200. No exception is thrown. Your try/catch never fires.
The reason sits in userErrors, and if you did not ask for that field, or never read it, your code happily reports success while nothing was saved.
Rule: every mutation requests userErrors { field message }, and every mutation checks it before moving on. No exceptions.
Pagination: the "where are the rest?" problem
Lists in GraphQL are connections, and they never return everything. You must say how many you want, and the maximum is 250.
query {
products(first: 50) {
nodes { id title }
pageInfo { hasNextPage endCursor }
}
}
nodes is your list. pageInfo tells you whether more exist and gives you a cursor — a bookmark marking where you stopped.
Write first: 50, test on a shop with 20 products, and everything looks perfect. Ship it to a shop with 400 products and you silently show the first 50.
Worse, it looks like a display bug rather than a data bug, so people hunt in the wrong place for hours. This is one of the most common real-world Shopify app bugs.
Rule: if a shop could ever have more than a page of something, write the loop.
The loop you will copy repeatedly
async function getAllProducts(admin) {
const all = [];
let cursor = null;
do {
const response = await admin.graphql(
`#graphql
query GetProducts($cursor: String) {
products(first: 100, after: $cursor) {
nodes { id title }
pageInfo { hasNextPage endCursor }
}
}`,
{ variables: { cursor } },
);
const json = await response.json();
const page = json.data.products;
all.push(...page.nodes);
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
return all;
}
Nested lists paginate too. A product's variants are their own connection — a product with 300 variants will not return them all in one go, which is exactly how "only some colours show up" bugs happen.
Rate limits
Shopify does not count your requests. It counts how expensive they are.
Think of a bucket of points that refills steadily. Every call costs points based on how much data it asks for. Ask for too much too fast and the bucket empties.
When you are throttled, Shopify returns HTTP 200, not 429. The problem is described inside the response, with extensions.code set to THROTTLED.
Code that only checks the HTTP status sees "200, fine" and carries on with missing data.
Two practical defences:
- Ask for less. Request only the fields you use. Deeply nested queries cost far more than flat ones.
- Retry with a wait. For loops and background jobs, catch the throttle and try again after a pause.
async function withRetry(fn, tries = 5) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
const throttled = JSON.stringify(err).includes("THROTTLED");
if (!throttled || i >= tries) throw err;
await new Promise((r) => setTimeout(r, 1000 * (i + 1))); // back off
}
}
}
Also: process things one at a time in loops. Firing 200 requests at once with Promise.all is the fastest way to get throttled.
Metafields: storing your data on Shopify
A metafield is extra data attached to a product, order or customer — your own field on Shopify's record. Useful when the storefront needs to read it, because your database is not reachable from a theme.
Each one has a namespace (your grouping), a key (the name), a type, and a value.
Reading
query GetMetafield($id: ID!) {
product(id: $id) {
metafield(namespace: "custom", key: "my_config") { value }
}
}
Writing
mutation SetMetafield($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id }
userErrors { field message }
}
}
variables: {
metafields: [{
ownerId: productGid,
namespace: "custom",
key: "my_config",
type: "json",
value: JSON.stringify({ color: "blue" }), // always a string
}],
}
The value is always a string, even for type json — stringify it yourself.
Metafields are not private. A metafield exposed to the storefront can be read by anyone viewing the shop, so never put costs, supplier details or internal notes in one.
API versions
Shopify releases a new API version every three months, named by date — 2026-01. Your app pins one:
apiVersion: ApiVersion.October25, // in shopify.server.js
Pinning means Shopify will not change under you. Versions are supported for about a year, so plan to move up roughly annually.
Fields get added, renamed and removed between versions. A snippet from a blog post may target a version you are not on. Check it against the docs for your version, or test it in GraphiQL first.
Write queries the easy way
Do not write GraphQL blind in your editor. Run:
shopify app graphiql
It opens a browser tool connected to your dev store with autocomplete, inline documentation, and instant results. Build the query there, confirm the response shape, then paste it into your code.
Every "why is this undefined?" moment is usually the response being shaped differently than you assumed. Thirty seconds in GraphiQL settles it.
Common errors
Cannot read properties of undefined (reading 'product')
Your data is not where you think. Log the whole response first:
console.log(JSON.stringify(json, null, 2));
Common causes: you forgot json.data, the query had an error (look for a top-level errors array), or the record does not exist.
Access denied / missing scope
Your app lacks the permission for that resource. Add the scope to the TOML and your .env, deploy, and have the merchant re-approve.
The mutation returns success but nothing changed
You did not read userErrors. Add it to the query, log it, and the real reason will be sitting there.
Field 'x' doesn't exist on type 'y'
The field belongs to a different API version, or a different type than you think. Check it in GraphiQL against your pinned version.
Updating thousands of records times out
Do not do it in one web request. Either process it in small batches driven by the page, or use Shopify's bulk operations, which run the job in the background and give you a file of results.
Checklist for every API call
- Values passed as
variables, never concatenated into the string. - Only the fields you actually use are requested.
- Mutations ask for
userErrors— and the code checks it. - Any list that could exceed one page is paginated with a cursor loop.
- Loops run sequentially, with a retry on throttling.
- Tested in GraphiQL before being pasted into code.
Cheat sheet
# call
const res = await admin.graphql(query, { variables });
const json = await res.json();
json.data.thing
# ids
gid://shopify/Product/123 always store the full GID
# mutation — non-negotiable
userErrors { field message } ask for it, then CHECK it
# pagination
first: 100, after: $cursor
pageInfo { hasNextPage endCursor } loop until hasNextPage is false
# silent failures (all return HTTP 200)
userErrors populated → your write did nothing
THROTTLED → rate limited, not an error code
only 50 results → you forgot to paginate
# tool
shopify app graphiql build and test queries here first