Learn how to migrate an existing Project from the Classic catalog model to the Modular catalog model.
productCatalogModel setting of a Project determines how Product Variants are stored:Classic: Variants are embedded within the Product. A Product can have up to 100 Product Variants.Modular: Variants are standalone Variant resources linked to a Product. A Product can have up to 10,000 Variants, 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
x-catalog-model header forces a single request to resolve against a specific catalog model, regardless of the Project setting. You control it from your own application, so you decide which traffic uses it: one endpoint, one service, or a percentage of requests. Removing the header instantly restores the Project default behavior, with no Project-level change and no coordination required.
Use it to validate your integration against Modular resolution before committing any traffic to it.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.
/productsand/product-projectionscontinue to return embedded Variant data, so integrations that read them keep working.- Product Search continues to index embedded Variants.
- 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:
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 consistency report
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.
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.Switch the Project 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 /products and /product-projections continue to return embedded Variant data. Variant update actions on the Product API remain accepted, and Product Search continues to index embedded Variants.productCatalogModel 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.Switch the Project to Modular
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": "Modular"
}
]
}'
Modular mode, Variant update actions on the Product API are rejected. Use the /variants endpoint 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:
/productsand/product-projectionsno 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.
Your migration is complete. Variant data is managed through the Variants API, read through the Variant Projections and Variant Attributes APIs, and priced through Standalone Prices.