Software Clever

Insight

Shopify GraphQL connections: the plural fields that look like arrays but aren't

David Fraser 7 min read

The first time you query a Shopify product through the GraphQL Admin API, you might assume that asking for variants gives you an array of variants. It doesn’t. You get a ProductVariantConnection. Same for media. Same for metafields. Same for almost every plural field on almost every entity in the schema.

This trips up developers new to the Admin API often enough that it’s worth a written reference, especially since the naming gives no clue. This post is that reference: a list of the common plural fields that are Connections rather than arrays, what to do with them in queries, and how to handle them safely in TypeScript.


What a Connection actually is

Connections come from the Relay GraphQL specification. They exist so a schema can describe a paginated list without committing to a fixed page size at the field level.

A ProductVariantConnection doesn’t directly contain variants. It contains a list of ProductVariantEdge objects, each of which wraps a ProductVariant node. So getting from a Connection to the actual data is two levels of indirection:

ProductVariantConnection
  └── edges
        └── node    (this is the ProductVariant)

Shopify exposes a nodes field on most Connections that returns the underlying nodes directly, hiding the edge layer when you don’t need it. So although the schema is structurally a Connection of edges of nodes, in practice you’ll often query it as a flat list.

Why have the edges at all? They carry a cursor field on each individual item, separate from the page-level cursors that pageInfo (startCursor and endCursor) provides on the Connection itself. For typical “load the next page” pagination you only need pageInfo, which is available with either form. Per-item cursors are useful in narrower situations, like remembering a specific item’s position to resume from later. Either way you have to go through a wrapper (nodes { ... } or edges { node { ... } }) to access the actual node data. The next section covers both forms in detail.


Common Connection fields in the Admin API

These are the plural fields you’re most likely to hit when working with products and inventory. There are many more across the schema; treat the list as common cases rather than exhaustive.

On Product:

  • variants returns a ProductVariantConnection
  • media returns a MediaConnection
  • metafields returns a MetafieldConnection
  • collections returns a CollectionConnection

On ProductVariant:

  • metafields returns a MetafieldConnection
  • media returns a MediaConnection

On InventoryItem:

  • countryHarmonizedSystemCodes returns a CountryHarmonizedSystemCodeConnection
  • inventoryLevels returns an InventoryLevelConnection

On Collection:

  • products returns a ProductConnection
  • metafields returns a MetafieldConnection

On Order:

  • lineItems returns a LineItemConnection
  • metafields returns a MetafieldConnection

The pattern holds across the schema. If a field name is plural and represents “things related to this thing,” it’s almost certainly a Connection. Confirm against the schema docs if you’re unsure: the type name will end in Connection.

A small number of plural fields are non-null lists rather than Connections. Product.options is the most commonly hit example, typed as [ProductOption!]!. That’s because Shopify caps options per product, so pagination doesn’t apply. When you see this pattern, it’s the exception rather than the rule.


Querying through a Connection

Where you would write this for an array field:

product(id: "...") {
  options {
    name
    values
  }
}

You have to write this for a Connection:

product(id: "...") {
  variants(first: 250) {
    nodes {
      id
      title
      sku
    }
  }
}

A few things to notice.

Specifying how many items you want is required. Shopify accepts first: N for forward pagination or last: N for reverse, and rejects queries that include neither. The maximum is 250, applied uniformly across every Connection in the API. For larger result sets you paginate with cursors, or move to bulk operations.

To get to the actual node fields, you go through one of two wrappers. The nodes field, added platform-wide in 2022 and what Shopify’s docs now recommend in almost all cases, gives you a clean list of objects:

variants(first: 250) {
  nodes {
    id
    title
    sku
  }
}

The older edges { node { ... } } form is still fully supported and shows up across older code and Shopify’s own examples:

variants(first: 250) {
  edges {
    cursor
    node { id title sku }
  }
}

The structural difference is that edges exposes a cursor field on each individual item. Page-level pagination doesn’t need this: pageInfo.hasNextPage and pageInfo.endCursor (returned alongside edges and nodes on every Connection) handle the standard “load next page” loop with either form. Per-edge cursors are useful in narrower situations, like remembering a specific item’s position in the list to resume from later, or when working with code that already assumes the edges shape.

Mapping back to a plain array of nodes from the edges form is a one-liner:

const variants = response.data.product.variants.edges.map(e => e.node);

With nodes, that mapping is already done for you.


Bulk operations flatten Connections

If you’re using Shopify’s bulk operations to export large datasets, the Connection structure doesn’t survive intact. The bulk runner flattens parent-child relationships into separate JSONL rows linked by a __parentId field, rather than nesting children inside their parents.

So when bulk-querying products with variants, you don’t get the nested Connection shape back. You get a stream of rows where some are products, some are variants, and the variants carry the __parentId of their parent product. Reassembling the hierarchy is your job.

Bulk queries use the same Connection syntax as regular queries. Shopify’s bulk operation examples typically use the edges { node } form, so that’s what you’ll see most often in documentation. The output format is different from what regular queries return. This catches people out separately from the basic Connection-vs-array question, so worth being aware of.


Safe handling in TypeScript

If you generate TypeScript types from Shopify’s GraphQL schema using a tool like graphql-codegen, the Connection shape comes through correctly. Read responses through the generated types and the structure is honest about what you’re getting.

The risk is using as to assert a shape that doesn’t match the schema:

const variants = product.variants as ProductVariant[];   // wrong, silent

That asserts an array where the actual shape is a Connection wrapper. The compiler trusts you, the runtime gives you an object with nodes (or edges) and pageInfo depending on what the query asked for, and any code treating the result as an array of variants returns undefined. No type error to flag the mistake.

The version that works:

const variants = product.variants.edges.map(e => e.node);

When boundary-casting an entity, for example reading bulk JSONL rows whose static type is unknown, anchor the cast to the codegen type rather than hand-rolling a shape:

type ProductFields = Partial<
  Omit<GetProductDetailsQuery["product"], "__typename">
>;

const product = row.properties as ProductFields;
const variants = product.variants?.edges?.map(e => e.node) ?? [];

The cast is still an assertion. But because the asserted shape is derived from the GraphQL query that defines the wire format, the next time the schema changes, read sites that no longer match fail to compile. The compiler is back in the loop.


Why this reference exists

I wrote this because InventoryItem.countryHarmonizedSystemCodes caught me out, in a Product Save & Sync importer. Same Connection pattern as everything above. Once I’d seen it I went looking, and realised the reference I wanted didn’t quite exist as a single page. This is that page. If it spares you the same moment, the time was well spent.


Takeaway

Three things, increasing in scope.

If you’re new to the Shopify GraphQL Admin API, plural-sounding fields are almost always Connections. Check the schema before treating them as arrays. The naming gives you no clue.

If you’re working in TypeScript, anchor your types to whatever codegen produces from the schema. Hand-rolled type assertions for external API shapes will drift from reality.

If you’re building anything that reads or writes Shopify product data at scale, expect to spend time on the Connection layer. It’s pervasive enough that the abstraction is worth fluency in, not just survival of.


Need a Shopify app, or a TypeScript codebase audited by someone who works in this layer every day? See our Shopify app development and custom software development services.