Skip to Content

Product mapping

A product is one StagingProduct shape per source row. If the product has variants, nest them in input_variants as more StagingProduct shapes — variants reuse the same staging fields as the parent.

Quick reference — minimum loadable product

{ "original_id": entity_id, "title": name, "location_id": $$.config.default_location_gid, "price": $string(price), "sku": sku, "inventoryQuantity": qty }

That’s the smallest viable mapping. location_id is the only non-obvious required field — Graftport needs it to know where to assign inventory. Pull it from your migration’s location config.

status defaults to DRAFT. Loaded products start as drafts unless you override. Set "status": "ACTIVE" once you’re ready for them to be visible on the storefront.

Identity & display

The fields customers see and the ones that route URLs.

titlestringrequired

Product name customers see. Used to construct the URL handle if you don’t set one explicitly.

"title": name
handlestring

URL slug. Letters, hyphens, numbers only. Auto-derived from title if omitted.

"handle": $lowercase(url_key)
vendorstring

Brand or supplier name. Indexed for storefront filters.

"vendor": manufacturer
statusenumdefault DRAFT

One of ACTIVE, ARCHIVED, DRAFT. Defaults to DRAFT so migrated products don’t accidentally go live mid-migration.

"status": status = "enabled" ? "ACTIVE" : "DRAFT"
descriptionHtmlstring

Product description with HTML markup (<strong>, <em>, lists, etc.). Plain text works too.

"descriptionHtml": description
product_tagsstring[]default []

Searchable tags. The staging model deduplicates and sorts before emitting to Shopify (so ["red", "RED", "red"] becomes ["RED", "red"]).

"product_tags": $append(categories, ["migrated"])
positioninteger

Sort position within collections. Lower = earlier.

Pricing

All money values are strings — Shopify's API requires it.

pricestring

The price customers pay. Always a string, even if your source has it as a number — wrap with $string().

"price": $string(price)
compareAtPricestring

The “compare-at” / strikethrough price for showing a discount. String, like price.

"compareAtPrice": rrp > 0 ? $string(rrp) : null
coststringdefault 0

Unit cost (what the merchant pays for the product). Used for margin reporting in Shopify, not shown to customers.

"cost": $string(cost_per_unit)
taxablebooleandefault true

Whether the product is taxable.

taxCodestring

Tax classification code. Use only if the merchant has a custom tax service that requires specific codes.

price_listarray

Optional list of price entries for multi-currency or per-market pricing. Each entry shape depends on the merchant’s market setup.

Inventory

One product, one inventory quantity, at one location.

location_idstringrequired

The Shopify location GID where this product’s inventory is held. Get it from your migration’s location config (run Resync locations on the migration if it’s not populated yet).

"location_id": $$.config.default_location_gid

For multi-location merchants, pick the location based on the source row.

inventoryQuantityintegerdefault 0

Available quantity at location_id.

"inventoryQuantity": qty_in_stock
inventoryPolicyenumdefault CONTINUE

What happens when stock hits zero:

  • DENY — block customers from ordering.
  • CONTINUE — allow ordering anyway (oversell).
"inventoryPolicy": allow_backorder ? "CONTINUE" : "DENY"
stockLowintegerdefault 0

Low-stock threshold for the merchant’s internal alerts. Doesn’t affect Shopify’s own low-stock notifications.

minAmountintegerdefault 1

Minimum purchase quantity. Stored as a metafield, not a hard constraint — surface this in the theme if the merchant cares.

Identifiers

The codes that link the product back to inventory and barcode systems.

skustring

Stock keeping unit. Should be unique per shop. On a product without variants, this is the variant SKU.

"sku": sku
barcodestring

UPC, EAN, GTIN, ISBN — whichever the merchant uses.

weightnumberdefault 0

Product weight in the merchant’s chosen unit (set on the shop, not per product).

"weight": weight_kg
supplierNumberstring

Supplier’s internal product number. Stored as a metafield.

giftCardboolean

Set true if this product is a gift card SKU (rather than a gift card balance — for that, use the gift card resource).

Variants & options

Two ways to express variants: nested products or numbered option columns. Use one consistently.

input_variantsStagingProduct[]default []

Array of nested product shapes — each describes one variant. The nested shapes use the same fields as the parent (price, sku, inventoryQuantity, optionValues, etc.).

If input_variants is empty, Graftport auto-creates one variant from the parent product itself with the synthetic option Title = Default Title.

"input_variants": children.{ "original_id": child_id, "sku": sku, "price": $string(price), "inventoryQuantity": qty, "location_id": $$.config.default_location_gid, "options": [ { "optionName": "Size", "name": size }, { "optionName": "Color", "name": color } ] }
optionsobject[]

Variant-level options as an explicit list of { optionName, name } pairs. Used inside input_variants to declare which option values that variant has.

"options": [ { "optionName": "Size", "name": size } ]
option_name_1, option_value_1, …_2, …_3string

Alternative way to declare options on a variant: up to three pairs of option_name_N + option_value_N. Equivalent to options but flatter — sometimes easier when the source already has columns like color, size, length.

{ "option_name_1": "Color", "option_value_1": color, "option_name_2": "Size", "option_value_2": size }

Pick one form per variant — don’t mix options and option_name_N. Mixing is allowed (they’re concatenated) but harder to reason about.

product_idstring

The parent product’s external ID. Set on variants (entries inside input_variants) so Graftport knows which product they belong to. The parent’s original_id is the natural value.

variant_metafieldsobject

Variant-scoped metafields. Same shape as dict_metafields, but only set on variants — the parent’s dict_metafields describe the product itself.

"variant_metafields": { "warehouse_bin": bin_location }

Images & files

One product, one variant, two different file shapes.

filesFileSetInput[]default []

Array of images attached to the product (not a specific variant). Each entry needs at minimum originalSource (the URL Shopify will download from).

"files": gallery.{ "originalSource": "https://cdn.example.com/" & path, "alt": label, "contentType": "IMAGE" }

contentType is one of IMAGE, VIDEO, MODEL_3D. Defaults to IMAGE.

Graftport auto-matches files to variants by checking if the option value (e.g. "red") appears in the file URL — variants that match get the file assigned automatically. So if your file URLs follow product-red.jpg, product-blue.jpg, etc., the right image lands on the right variant.

fileFileSetInput

A single file attached to a variant. Set this inside an input_variants entry to pin one specific image to one variant.

"input_variants": children.{ "sku": sku, "file": { "originalSource": image_url, "contentType": "IMAGE" } }

If you set the parent’s files and let Graftport’s filename matching kick in, you usually don’t need to set per-variant file manually.

SEO

Flat fields that compose into the Shopify SEO object.

meta_titlestring

The <title> tag on the product page. Defaults to the product title if omitted.

"meta_title": seo.title
meta_descriptionstring

The meta description shown in search results.

"meta_description": seo.description

Bundles

Products whose variants are composed from other products. Emit `bundle_components` and Graftport switches to the bundle load path.

The destination store must have the Shopify Bundles app installed. Bundles load through Shopify’s productBundleCreate mutation, which is only available when the free Shopify Bundles app  is enabled on the destination store. Install it before running a migration that contains bundles — otherwise every bundle record fails with SHOPIFY_BUNDLES_NOT_ENABLED.

bundle_componentsobject[]

Set this on a product to turn it into a Shopify bundle. The product’s variants are derived from the components — you don’t write input_variants or options on a bundle, Shopify generates them.

Each entry is { source_product_id, quantity, optionSelections? }:

  • source_product_id — the source platform’s id of the component product. Graftport resolves it to a Shopify product GID at load time, the same way order line-item product references work. The component must be migrated in the same run, or an earlier run.
  • quantity — how many of the component go into one unit of the bundle. Defaults to 1.
  • optionSelections — optional per-option variant filter for the component. Omit to include every variant of the component in the bundle. When set, it’s passed through to Shopify as ProductBundleComponentOptionSelectionInput[].
{ "original_id": packet_id, "title": name, "descriptionHtml": description, "status": "ACTIVE", "bundle_components": packet_products.{ "source_product_id": product_id, "quantity": quantity } }

Bundles dequeue after regular products in the same run. The load worker orders bundle records last so every component’s GID is available by the time the bundle references it. If a component hasn’t been loaded yet (e.g. it errored earlier), the bundle record fails with MISSING_REFERENCE; fix the component and re-run.

Don’t set input_variants on a bundle. Variants on a bundle product are synthesized by Shopify from the components — anything you put in input_variants is ignored. The same goes for productOptions and per-variant sku, price, barcode. Fields that apply to the product as a whole (description, status, vendor, tags, SEO, files, metafields) work as normal and get applied via a follow-up productUpdate.

Migrated bundles work correctly but do not appear in the Shopify Bundles app.

The Shopify Bundles app only lists bundles created through its own interface. Bundles migrated by Graftport are fully functional — they show the Bundle badge in your Shopify product list, decrement component inventory when an order is placed, and sell correctly at checkout — but Shopify has no mechanism to register externally-created bundles into the Bundles app’s management list. This is a Shopify platform constraint, not a Graftport limitation.

Because the Bundles app does not list migrated bundles, its editing UI is unavailable for them. To change a migrated bundle’s components or quantities, update the mapping and re-run the load — Graftport updates the bundle in place without duplicating it.

Unexpected variant count usually means a configurable bundle. Omitting optionSelections creates a configurable bundle: buyers choose one variant per component, and Shopify generates a variant for every combination. A bundle with three components that each have four variants produces 4³ = 64 variants. To create a fixed bundle — a single SKU where each component is pre-selected — supply optionSelections for every component so Shopify pins each component to one specific variant.

Collections

Manual collection membership. Smart collections live on the collection resource.

collectionsstring[]

Array of source-platform collection IDs (or Shopify GIDs) this product belongs to in manual collections. Graftport resolves source IDs to Shopify GIDs at load time using the collection migration’s results.

"collections": category_ids

Patterns

Single-variant product

A simple product with no variant axis — just one SKU, one price.

{ "original_id": entity_id, "title": name, "handle": $lowercase(url_key), "vendor": brand, "status": status = "1" ? "ACTIVE" : "DRAFT", "descriptionHtml": description, "sku": sku, "price": $string(price), "compareAtPrice": rrp > price ? $string(rrp) : null, "weight": weight, "barcode": ean, "inventoryQuantity": qty, "location_id": $$.config.default_location_gid, "files": images.{ "originalSource": url, "alt": alt }, "product_tags": tags, "meta_title": seo_title, "meta_description": seo_description }

Configurable product → multi-variant Shopify product

Source has a parent + N children with size + color. Flatten:

{ "original_id": parent_id, "title": name, "handle": $lowercase(url_key), "status": "ACTIVE", "descriptionHtml": description, "vendor": brand, "files": images.{ "originalSource": url, "alt": label }, "input_variants": children.{ "original_id": child_id, "product_id": $$.parent_id, "sku": sku, "price": $string(price), "inventoryQuantity": qty, "location_id": $$.config.default_location_gid, "options": [ { "optionName": "Color", "name": color }, { "optionName": "Size", "name": size } ] } }

location_id is required on the parent and on each variant.

Custom attributes → metafields

Pour every source-side custom attribute into dict_metafields and let auto-typing handle it:

{ "original_id": entity_id, "title": name, "location_id": $$.config.default_location_gid, "dict_metafields": $reduce( custom_attributes, function($acc, $attr) { $merge([$acc, { $attr.code: $attr.value }]) }, {} ), "metafield_prefix": "magento" }

Forcing a metafield type

When auto-detection picks the wrong type — e.g. you want color instead of single_line_text_field:

"dict_metafields": { "primary_color": { "value": color_hex, "type": "color" }, "launch_date": { "value": released_at, "type": "date" } }

Skipping draft / test products

{ "original_id": entity_id, "exclude": status = "draft" or sku ~> /^TEST/, }

Gotchas

Money is always strings. "price": 19.99 fails; "price": "19.99" works. Use $string(...).

  • Status defaults to DRAFT. Migrated products are invisible on the storefront until you set status: "ACTIVE" (or change them manually after the migration).
  • location_id is required. Run Resync locations on the migration before mapping inventory; without it, the variant load fails.
  • Three options max. Shopify rejects products with more than 3 options. The staging model raises OPTIONS_OVER_LIMIT early so you see the error in transform, not load.
  • Variant option values must match the parent’s option set. If a variant has Color = Red but no other variant introduces Red to the Color option, the staging model raises OPTION_DOES_NOT_EXIST. Check for typos and stray whitespace.
  • No two variants can share the exact same option combination. DUPLICATE_VARIANT fires if two variants both have e.g. Color=Red, Size=M.
  • Image-to-variant matching is fuzzy: the staging layer matches by checking if the option value appears (case-insensitive) in the file URL. If your file URLs don’t include option values, set file explicitly on each variant.
  • original_id becomes a metafield, so future runs can match. Don’t drop it.
  • Migrated bundles don’t appear in the Shopify Bundles app. The app only lists bundles created through its own UI — this is a Shopify platform limit, not a bug. The bundle still shows the “Bundle” badge, decrements inventory, and sells correctly. To update the bundle after migration, change the mapping and re-run the load.
  • Large variant count on a bundle = configurable, not fixed. If you see far more variants than expected, optionSelections was omitted, creating a configurable bundle. Add optionSelections for each component to pin the bundle to a single SKU per component (fixed bundle).
Last updated on