Learn how to migrate an existing Project from the Classic catalog model to the Modular catalog model.
Classic: Variants are embedded within the Product. A Product can have up to100Product Variants.Modular: Variants are standalone Variant resources linked to a Product. A Product can have up to10 000Variants, each with its own lifecycle. Pricing is handled exclusively through Standalone Prices.
How the migration works
Classic to Modular means changing two things:- Your data: your Variants must be written as standalone Variant resources, not only as embedded data inside your Products.
- Your integration: your code must create, update, and read Variants through the Variant APIs.
InMigration state.Per-request override
The InMigration state
InMigration state changes that default to Modular for everything, while keeping the Classic path open:- Variants resolve from standalone Variants by default, with no header needed. This affects resources that reference a Variant, including Cart and Order Line Items, discount matching, and Product Selections.
- The Products API and Product Projections API continue to return embedded Variant data, so integrations that read them keep working.
- Product Search continues to index embedded Variants by default. However, it can be configured to index products using only standalone Variants in a dedicated search index copy.
- Variant update actions on the Product API are still accepted, so dual-write continues to work.
This lets you migrate reads and writes on different schedules. Systems that write catalog data can keep their existing write path and add the standalone Variant write alongside it.
- In the
InMigrationstate, a Variant written only as embedded data is not visible to Carts, Orders, or discount matching. Every Variant write must reach both representations for as long as your Project is in this state. - Throughout the migration, Product Search continues to index embedded Variant data.
What each stage provides
| Stage | Default resolution | Embedded reads | Embedded Variant writes | Reversible |
|---|---|---|---|---|
Classic | Classic | Yes | Yes | No |
Classic with header | Modular for selected requests | Yes | Yes | Yes, remove the header |
InMigration | Modular | Yes | Yes | Yes, set Classic |
Modular | Modular | Yes | No | Yes, set InMigration; resynchronize embedded Variants before setting Classic |
| After cleanup | Modular | No | No | No |
Classic requires a separate reverse migration, which is not supported.The Classic catalog model limit
Classic limit of 100 Product Variants per Product.Modular limit of 10 000 Variants per Product becomes available once you stop writing embedded Variants.Migrate in stages
To complete the migration successfully, follow these stages:
- Meet all the prerequisites.
- Start dual-writing Variants.
- Request the Variant copy job and Product Search support for
InMigrationmode. - Roll out Modular reads.
- Change Product Catalog Model to
InMigration. - Stop writing Variant updates through the Product API.
- Change Product Catalog Model to
Modular. - Request the cleanup job.
Prerequisites
Before you begin, your Project must meet the following conditions.
All Products use Standalone Prices
Modular model does not support Embedded Prices. Migrate your prices from Embedded to Standalone Prices before you begin this migration.prices field on masterVariant and variants, for example, masterData.current.masterVariant.prices on /products, or masterVariant.prices on /product-projections. The following example fetches matching Product IDs with limit=500. If your catalog has more results than that, paginate through using the offset field to find them all.curl --get "https://api.{region}.commercetools.com/{projectKey}/products" \
--data-urlencode 'where=masterData(current(masterVariant(prices is not empty))) or masterData(current(variants(prices is not empty))) or masterData(staged(masterVariant(prices is not empty))) or masterData(staged(variants(prices is not empty)))' \
--data-urlencode 'limit=500' \
-H "Authorization: Bearer {access_token}" \
| jq -r '.results[] | .id'
If the query returns Product IDs, set their price mode after migrating their prices:
curl -X POST "https://api.{region}.commercetools.com/{projectKey}/products/{id}" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"version": {version_number},
"actions": [
{ "action": "setPriceMode", "priceMode": "Standalone" }
]
}'
API Client scopes
manage_products:{projectKey}: to create and update Products and Variants.manage_project_settings:{projectKey}: to change the catalog model of the Project.manage_orders:{projectKey}: to create and update Carts and Orders.view_products:{projectKey}: to query standalone Variants and staged Variant Projections.view_published_products:{projectKey}: to query Variant Projections.
Test on a non-production Project first
Run the full sequence on a sandbox or staging Project with representative data before migrating your production Project.
Start dual-writing Variants
The copy job copies only the initial Variant data. You are responsible for keeping the embedded and standalone representations in sync throughout the migration. Apply every Variant operation, including create, update, and delete, to both representations.
curl -X POST "https://api.{region}.commercetools.com/{projectKey}/products/{product_id}" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"version": {version_number},
"actions": [
{ "action": "addVariant", "key": "outdoor-jacket-red-m", "sku": "JACKET-RED-M" }
]
}'
curl -X POST "https://api.{region}.commercetools.com/{projectKey}/variants" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"product": { "typeId": "product", "id": "{product_id}" },
"key": "outdoor-jacket-red-m",
"sku": "JACKET-RED-M"
}'
Updating a Variant through the Variants API only works once that Variant exists as a standalone Variant. Before the copy job runs, queue updates for embedded-only Variants and replay them after the copy job completes.
Request the Variant copy job and Product Search support for InMigration mode
InMigration mode in Product Search also. Include the Region, Project key, preferred time window, and confirmation that all Products use Standalone Prices.product, variantId, sku, and key, sets masterVariant as the default Variant for the Product, and sets the Product Variant counter to the highest variantId.The job fails if there is any Embedded Price left on any Variant. It also drops existing standalone Variants before copying, so any Variant that exists only as a standalone Variant is permanently lost. Create Variants in both representations during dual-write.
Only one job runs per Project at a time. When it completes, the commercetools support team shares a consistency report that identifies Products whose embedded and standalone Variant counts differ.
InMigration mode is enabled, the service starts maintaining two separate copies of your search index: one based on embedded Variants and the other on standalone Variants.Roll out Modular reads
x-catalog-model header. Implement the header as a feature toggle so you can turn it off immediately if you see a problem.curl -X POST "https://api.{region}.commercetools.com/{projectKey}/carts/{cart_id}" \
-H "Authorization: ******" \
-H "Content-Type: application/json" \
-H "x-catalog-model: Modular" \
-d '{
"version": {version_number},
"actions": [
{ "action": "addLineItem", "sku": "JACKET-RED-M", "quantity": 1 }
]
}'
The line item's Variant is resolved from standalone Variants, and its price is selected from the Standalone Price for that SKU. Send the same request without the header to compare against Classic resolution.
The header is supported on requests for Shopping Lists, Carts, Product Tailoring, and Orders, both through the HTTP API and the GraphQL API. It is not supported for Product Discounts matching, Inventory, Standalone Prices, or Product Selections.
The header applies to the entire HTTP request. A GraphQL document that mixes a Cart query with a Product query resolves everything under one catalog model. You cannot scope it to an individual GraphQL selection.
Modular model, specify an sku, or both productId and variantId. A productId alone is not sufficient.x-ab-test-id: modular-catalog header BETA. When the header is omitted, the search index based on embedded Variants is queried instead.curl -X POST "https://api.{region}.commercetools.com/{projectKey}/products/search" \
-H "Authorization: ******" \
-H "Content-Type: application/json" \
-H "x-ab-test-id: modular-catalog" \
-d '{
"query": {
"exact": { "field": "variants.sku", "value": "JACKET-RED-M" }
}
}'
InMigration mode in Product Search must be enabled by the commercetools support team before you can use this header. If not done yet, contact the commercetools support team to request enabling it.Change Product Catalog Model to InMigration
InMigration state:curl -X POST "https://api.{region}.commercetools.com/{projectKey}" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"version": {version_number},
"actions": [
{
"action": "setProductCatalogModel",
"productCatalogModel": "InMigration"
}
]
}'
InMigration, Variants resolve from standalone Variants by default, while the Products API and Product Projections API continue to return embedded Variant data. Variant update actions on the Product API remain accepted, and Product Search continues to index embedded Variants.Roll back to Classic
Classic model.
To roll back, set Product Catalog Model to Classic.InMigration as a transition window with a defined end date. Dual-write carries maintenance overhead and creates a risk of drift between the two representations.Stop writing Variant updates through the Product API
Modular model and consistency checks are clean, stop the embedded half of your dual-write. From this point, manage Variants only through the Variants API. This also removes the Classic limit of 100 Variants per Product.Change Product Catalog Model to Modular
Modularcurl -X POST "https://api.{region}.commercetools.com/{projectKey}" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"version": {version_number},
"actions": [
{
"action": "setProductCatalogModel",
"productCatalogModel": "Modular"
}
]
}'
Modular mode, Variant update actions on the Product API are rejected. Use the Variants API instead.InMigration directly because it resolves Variants from standalone resources. Before returning to Classic, synchronize all changes made after embedded writes stopped.Request the cleanup job
The cleanup job permanently removes embedded Variant data. Do not request it until your Project has run on the Modular model in production long enough for you to be confident in the migration.
Modular mode.After cleanup:
- The Products API and Product Projections API no longer return embedded Variant data.
- The
variantsarray is empty on the current and staged representations. masterVariantis reset to a bare shape, with no SKU, attributes, or prices.