A WooCommerce to Shopify migration is mostly a catalogue mapping job. WooCommerce lets a product carry unlimited attributes, categories and meta keys; Shopify gives you three options per product, up to 2,048 variants, flat collections and typed metafields. Decide how each of those maps before you export a row, because no import tool will decide it for you.
What does WooCommerce actually export?
Run the built-in exporter (Products, then Export) and tick “Yes, export all custom meta”. The CSV format is one row per product and, separately, one row per variation. The Type column tells them apart: simple, variable, grouped, external and variation. A variation row points at its parent through a Parent column that holds either the parent’s SKU or id:123.
Attributes arrive as numbered column groups: Attribute 1 name, Attribute 1 value(s), Attribute 1 visible, Attribute 1 global. Values are comma-separated on the parent row and single on the variation rows. Categories are one comma-separated cell with > for hierarchy, so a product in Home goods > Audio carries that whole path. Images are a comma-separated list of URLs, first one featured. Anything a plugin stored in wp_postmeta shows up as a meta: column, meta:product_depth for instance.
That export is the input to everything below. Keep it; you will be querying it for weeks.
How do attributes and variations become options and variants?
Three limits decide this, and all three are Shopify’s.
First, a product can have up to 3 options. WooCommerce has no such limit, and a catalogue built over years usually has a few products with four or five attributes ticked as “used for variations”. Shopify’s own Store Migration app is blunt about what happens to them: products with more than three options “won’t have their options imported”. So find them first and decide, per product, which attribute becomes something else. Usually the fourth attribute was never a purchasable choice (a material, a finish, a fit note) and belongs in a metafield, or the product is two products wearing one listing.
Second, the variant ceiling is 2,048. Shopify’s changelog entry of 15 October 2025 lifted the historical 100 for every merchant, with a warning that apps not on the current GraphQL product APIs “may have a downgraded or broken experience” above 100. Few WooCommerce products get near 2,048; the ones that do are usually built from the “Any” trick below.
Third, every Shopify variant is one explicit combination of option values. WooCommerce lets a variation carry Any Colour as a value, which the variable product docs describe while recommending you define every attribute of every variation. A store that leaned on “Any” has variations that match several combinations at once, with one price and one stock level shared across them. Shopify cannot express that. You expand the combinations into real variants, each with its own inventory, or you accept that the attribute is not an option at all.
Attributes marked visible but not used for variations (the specification table on the product page) are the easy case. They are not options. Map them to product metafields with a definition per attribute, so they render in the theme and filter in collections.
Where do categories, tags and meta go?
Shopify collections are flat. Home goods > Audio becomes two collections, Home goods and Audio, with the product in both and the hierarchy rebuilt in the navigation menu rather than in the data. The alternative is a single collection per leaf and the parent path recorded in a metafield for breadcrumbs. Pick one and apply it to the whole tree; a mixed approach leaves collection pages that are empty on one branch and duplicated on another.
Tags map one to one. Vendor and Type on the Shopify side have no exact WooCommerce source, so decide whether a brand attribute or a top-level category fills them.
Everything in a meta: column needs a home or needs dropping. Most of it is plugin bookkeeping and can go. The rest, the product dimensions, the care instructions, the Advanced Custom Fields content, wants a metafield definition with a proper type (dimension, rich_text_field, list.single_line_text_field) rather than being flattened into the description. Shopify variants carry a weight through inventoryItem.measurement.weight and nothing else physical, so WooCommerce’s Length, Width and Height columns are metafields too.
Two WooCommerce product types have no Shopify equivalent. grouped products are a display grouping, not a thing you buy; they become a collection, or a bundle through a cart transform function if they were priced together. external (affiliate) products point at another site and do not belong in a Shopify catalogue at all.
Sale prices nearly map. Sale price becomes the variant price and Regular price becomes compareAtPrice, but WooCommerce’s scheduled sale dates do not carry over, so export a list of active schedules and recreate them as discounts.
CSV import, the Store Migration app, or productSet?
The Store Migration app takes the WooCommerce CSV unchanged and imports products only; customers, orders, reviews, blog posts and discount codes are out of scope, and as of October 2026 it is still labelled early access. It is the right tool for a small catalogue with nothing custom and the wrong tool the moment you want to choose what the fourth attribute becomes.
Shopify’s own product CSV is the middle path. One row per variant with the URL handle repeated, Option1 name through Option3 value, Variant image URL, Product category, Tags and metafield columns. A handle that matches an existing product overwrites it column by column, and a blank non-required column overwrites with blank, so a second import meant to patch prices will also wipe anything you left empty. That is the one that bites on the third run.
For anything you will run more than once, or that carries metafields, write the import against the Admin GraphQL API. productSet upserts a whole product, options, variants, metafields and files in one call, and takes a handle identifier so re-running is safe. The query below is written against API version 2026-07:
const PRODUCT_SET = `#graphql
mutation ImportProduct($input: ProductSetInput!, $synchronous: Boolean!) {
productSet(input: $input, synchronous: $synchronous) {
product { id handle }
productSetOperation { id status }
userErrors { field message code }
}
}
`;
type WooRow = Record<string, string>;
function toProductSetInput(parent: WooRow, variations: WooRow[], locationId: string) {
const optionNames = [1, 2, 3]
.map((n) => parent[`Attribute ${n} name`])
.filter((name): name is string => Boolean(name));
return {
handle: parent["Name"].toLowerCase().replace(/[^a-z0-9]+/g, "-"),
title: parent["Name"],
descriptionHtml: parent["Description"],
tags: parent["Tags"].split(",").map((t) => t.trim()).filter(Boolean),
status: parent["Published"] === "1" ? "ACTIVE" : "DRAFT",
productOptions: optionNames.map((name, i) => ({
name,
position: i + 1,
values: parent[`Attribute ${i + 1} value(s)`].split(",").map((v) => ({ name: v.trim() })),
})),
variants: variations.map((v) => ({
sku: v["SKU"],
price: v["Sale price"] || v["Regular price"],
compareAtPrice: v["Sale price"] ? v["Regular price"] : null,
optionValues: optionNames.map((name, i) => ({
optionName: name,
name: v[`Attribute ${i + 1} value(s)`].trim(),
})),
inventoryItem: { tracked: true },
inventoryQuantities: [{ locationId, name: "available", quantity: Number(v["Stock"] || 0) }],
})),
metafields: [
{
namespace: "custom",
key: "depth_mm",
type: "number_integer",
value: parent["meta:product_depth"] ?? "0",
},
],
};
}
export async function importProduct(
shop: string,
token: string,
parent: WooRow,
variations: WooRow[],
locationId: string,
) {
const res = await fetch(`https://${shop}/admin/api/2026-07/graphql.json`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": token },
body: JSON.stringify({
query: PRODUCT_SET,
variables: { input: toProductSetInput(parent, variations, locationId), synchronous: true },
}),
});
const { data, errors } = await res.json();
if (errors?.length) throw new Error(JSON.stringify(errors));
if (data.productSet.userErrors.length) throw new Error(JSON.stringify(data.productSet.userErrors));
return data.productSet.product;
}
The handle derivation is the part to get right, because it is the URL and the upsert key. Derive it from the WooCommerce slug if you exported it, not from the name, so that your 301 map and your import agree. The productSet reference also covers files for images and synchronous: false for large products, where the mutation returns a productSetOperation you poll instead of waiting on the request. Which list fields productSet deletes when you omit them is covered in our post on upserting with productSet by custom ID.
What should I check before cutover?
Query the WooCommerce database for the products that will break, before anyone opens an import tool. Variation counts per parent:
SELECT post_parent, COUNT(*) AS variations
FROM wp_posts
WHERE post_type = 'product_variation' AND post_status = 'publish'
GROUP BY post_parent
ORDER BY variations DESC
LIMIT 20;
Then, from the export, count the attribute columns that carry a value on variation rows for each parent. More than three and the product needs a call. Any variation whose attribute cell is empty was an “Any” variation and needs expanding.
After import, spot-check the products that had the most variations, the ones with meta: data you kept, and every product that sat in a nested category. Those three groups are where a mapping choice was made, and they are the ones a customer will find first. Keep the WooCommerce site reachable on a private hostname for a month so you can diff against it.
The call I would make: a catalogue under a few hundred simple products with no plugin meta goes through the Shopify CSV, once. Anything with variable products, custom fields or a nested category tree goes through productSet from a script you can re-run, because you will re-run it. The redirects are a separate job, covered in our WooCommerce to Shopify 301 map, and so are the customers, in migrating customers without losing consent. I would not use the Store Migration app for anything you expect to sell for more than a season.
Whoooop builds the import scripts and the metafield schema for replatforms like this, and runs the cutover alongside the redirect work. If you are weighing a WooCommerce to Shopify move, our Shopify development page covers how we scope it.