The migration consists of the following steps:
Key differences
| Feature | Product Projection Search | Product Search |
|---|---|---|
| Query interface | GET or POST with URL-encoded query parameters | POST with a JSON body using the search query language |
| Response format | Full ProductProjection objects | Product IDs only, with matching Product Variants on request |
| Indexed projection | Current and staged | Current only |
| Full-text search | Fixed set of fields through one text.{language} parameter | Explicit per field, with optional boosting |
| Price selection | During the search phase through query parameters | During data retrieval (see Retrieve full Product data) |
| Prices considered for filtering and sorting | First Embedded Price of each Product Variant | One valid Price per scope of the Product Variant (Embedded or Standalone according to the Product's priceMode) |
Maximum results per page (limit) | 500 | 100 |
| Default facet counting | Product Variants | Products |
| Facet results | Object keyed by attribute path or alias | Array of named objects |
Five of these changes require design decisions rather than a mechanical rewrite. Read them before you start.
- The response contains IDs, not Products. You need a second step to retrieve the data you render. See Retrieve full Product data.
- Full-text search is explicit per field. The
text.{language}parameter searched a fixed set of fields withnameweighted heavier. You now compose the fields and their weights yourself. See Migrate full-text search. - Only the current Product representation is indexed. No equivalent of
staged=trueexists. For staged data, use the Query ProductProjections endpoint withstaged=true. - Price selection happens after the search. Use the
variants.prices.*fields to filter and sort, and apply price selection during data retrieval. - Product Search has no GET method. All requests go to Search Products. If you cache Product Listing Page responses on a CDN keyed by URL, that strategy needs to change.
Inventory your current usage
List every Product Projection Search call in your integration and record the following for each. This list is both your migration checklist and your test matrix.
- The query parameters used:
text.{locale},fuzzy,fuzzyLevel,filter.query,filter,filter.facets,facet,sort,expand,staged,markMatchingVariants, the price selection parameters,localeProjection, andstoreProjection. - The fields your storefront reads from each response.
- Whether the caller needs staged data.
- Whether the caller relies on facet counts being Product Variant counts. This default changes; see Set the facet counting level.
isSearchable set to true in its AttributeDefinition.Activate the Product Search API
Product Search is not active by default, and activating it triggers indexing. Activate it early so the index is ready before you start testing.
mode: ProductsSearch.curl -sH "Authorization: Bearer {access_token}" -X POST \
-H "Content-Type: application/json" \
-d '{
"version": {version_number},
"actions": [
{
"action": "changeProductSearchIndexingEnabled",
"enabled": true,
"mode": "ProductsSearch"
}
]
}' https://api.{region}.commercetools.com/{projectKey}
ProductsSearch does not deactivate ProductProjectionsSearch. Both indexes are maintained independently while you migrate.ObjectNotFound error.view_published_products:{projectKey}. If you access it with an external OAuth token, include view_products:{projectKey} explicitly in the token scope instead of relying on implied permissions.Migrate the query
Move each Product Projection Search parameter to its Product Search equivalent.
| Product Projection Search | Product Search | Notes |
|---|---|---|
filter.query | query in ProductSearchRequest | Applied before facets are calculated. Affects results and facet counts. |
filter | postFilter in ProductSearchRequest | Applied after facets are calculated. Affects results only. |
filter.facets | filter inside each facet expression | Scope changes from global to per-facet. See Rebuild multi-select faceting. |
text.{language} | fullText expression per field | See Migrate full-text search. |
fuzzy, fuzzyLevel | fuzzy expression with level | Per field instead of global. |
facet | facets array | See Migrate facets. |
sort | sort array | Structured objects instead of strings. |
limit, offset | limit, offset | Maximum limit drops from 500 to 100. |
markMatchingVariants | markMatchingVariants | Response shape differs. See Read matching variants. |
expand | - | Use the data retrieval step. |
staged | - | Only current Products are indexed. |
priceCurrency, priceCountry, priceCustomerGroup, priceCustomerGroupAssignments, priceChannel, priceRecurrencePolicy | variants.prices.* fields for filtering and sorting | Price selection moves to the data retrieval step. |
localeProjection | - | Use localeProjection in the data retrieval step. |
storeProjection | stores field for filtering | Use storeProjection in the data retrieval step for Store-specific data. |
productProjectionParameters option on Product Search. It is deprecated and will be removed in a future release.Choose between and, or, and filter
filter parameters with AND, and comma-separated values within one parameter with OR. Product Search makes this explicit with compound expressions:and: all sub-expressions must match, and all contribute to the relevance score.or: at least one sub-expression must match.not: none of the sub-expressions match.filter: behaves likeand, but calculates no relevance score. Use it for anything that should not influence ranking, such as Category and Attribute selections. It performs faster.
values array of a single exact expression rather than several or-combined expressions. This keeps you within the expression limit.{
"query": {
"exact": {
"field": "variants.attributes.color.key",
"fieldType": "enum",
"values": ["black", "grey"]
}
}
}
Respect field-level validation
400 InvalidInput error.| Level | Searchable Product fields | Rank |
|---|---|---|
| Context | stores, productSelections | 1 |
| Product | all Product and Product Variant fields except prices | 2 |
| Price | variants.prices.* | 3 |
Keep each compound expression on a single level, and combine levels at the top of the query, ordered from context to price.
{
"query": {
"and": [
{ "exact": { "field": "stores", "value": "{{store-id}}" } },
{
"filter": [
{ "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } },
{ "exact": { "field": "variants.attributes.color", "fieldType": "text", "value": "red" } }
]
},
{
"and": [
{ "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } },
{ "range": { "field": "variants.prices.currentCentAmount", "gte": 1000, "lte": 5000 } }
]
}
]
}
}
Migrate full-text search
This step has the largest effect on which results your storefront returns, so treat it as a redesign rather than a translation.
text.{language} parameter searched a fixed set of fields in one call. Those fields were name, description, slug, sku, and searchKeywords, plus searchable Attributes. The name field was weighted heavier than the others. The metaTitle and metaKeywords fields were not indexed for full-text search.GET /{projectKey}/product-projections/search?text.en=red+shoes
or compound expression. Use boosting to reproduce the heavier weighting of name.{
"query": {
"or": [
{ "fullText": { "field": "name", "language": "en", "value": "red shoes", "boost": 3 } },
{ "fullText": { "field": "searchKeywords", "language": "en", "value": "red shoes", "boost": 2 } },
{ "fullText": { "field": "description", "language": "en", "value": "red shoes" } },
{ "fullText": { "field": "slug", "language": "en", "value": "red shoes" } },
{ "exact": { "field": "variants.sku", "value": "red shoes" } }
]
}
}
Note the following differences in behavior:
fullTextrequires all provided terms to match by default. SetmustMatchtoanyto match any term, which is closer to the behavior of a broad storefront search box.boostvalues above1raise relevance, values between0and1lower it.variants.skuis a keyword field, so it supportsexact,prefix,wildcard, andfuzzyexpressions, but notfullText.- Product Search only analyzes Locales configured in the Project's
languages. A query for an unconfigured Locale returns no results. - A prefix expression on
nameis usually the better choice for type-ahead than a wildcard expression, which performs less efficiently.
Migrate fuzzy search
fuzzy and fuzzyLevel query parameters.GET /{projectKey}/product-projections/search?text.en=shoes&fuzzy=true&fuzzyLevel=1
fullText expression in an or gives you precision plus tolerance for typos.{
"query": {
"or": [
{ "fullText": { "field": "name", "language": "en", "value": "shoes", "boost": 3 } },
{ "fuzzy": { "field": "name", "language": "en", "value": "shoes", "level": 1 } }
]
}
}
level field replaces fuzzyLevel. As before, the API caps the level by term length:0for terms of 1-2 characters.1for terms of 3-5 characters.2for longer terms.
fuzzy only to short fields that users type into directly, such as name or a color Attribute. Use no more than around 10 fuzzy expressions per query.Migrate filters
Map the field paths
| Product Projection Search | Product Search |
|---|---|
categories.id | categories |
categories.id: subtree("{id}") | categoriesSubTree |
key | key |
productType.id | productType |
taxCategory.id | taxCategory |
state.id | state |
variants.sku | variants.sku |
variants.key | variants.key |
variants.price.centAmount | variants.prices.centAmount |
variants.scopedPrice.value.centAmount | variants.prices.centAmount |
variants.scopedPrice.currentValue.centAmount | variants.prices.currentCentAmount |
variants.scopedPriceDiscounted | variants.prices.discounted |
variants.attributes.{name} | variants.attributes.{name}, with fieldType |
variants.availability.isOnStock | variants.availability.isOnStock |
variants.availability.channels.{id}.isOnStock | variants.availability.isOnStockForChannel |
variants.availability.availableQuantity | variants.availability.availableQuantity |
searchKeywords.{language}.text | searchKeywords, with language |
reviewRatingStatistics.* | reviewRatingStatistics.* |
createdAt, lastModifiedAt | createdAt, lastModifiedAt |
| - | attributes.{name} for Product-level Attributes BETA |
| - | stores, variants.stores |
| - | productSelections, variants.productSelections |
| - | variants.prices.currencyCode, .country, .customerGroup, .channel, .id |
.id suffix, enum Attributes keep .key, and Money Attributes keep .centAmount or .currencyCode, as before.Map the filter operators
| Product Projection Search | Product Search |
|---|---|
field:"{value}" | exact with value |
field:"{value-1}","{value-2}" | exact with values |
field:range ({from} to {to}) | range with gte and lte |
field:range (* to {to}) | range with lte only |
field:exists | exists |
field:missing | not wrapping exists |
field:true | exact with a boolean value |
Repeated filter parameters | and or filter compound expression |
Three behavior changes to plan for:
- Range bounds are now explicit. The
:range ({from} to {to})filter included both bounds. Usegteandlteto keep that behavior;gtandltexclude the bound. Note that range facet buckets include the lower bound and exclude the upper bound in both APIs. - Boolean Attribute values are no longer quoted. Custom Boolean Attributes required
variants.attributes.{name}:"true". Pass a real boolean with"fieldType": "boolean"instead. notcombined withexistsis not supported on thevariants,variants.prices, andvariants.skufields; such a query always returns an empty result. If you filter onvariants.prices:missingorvariants.sku:missing, you need a different approach, such as maintaining a Boolean Attribute for the condition. For details, see evaluation of 'not' expressions.
Example: filter by Category and Attribute
GET /{projectKey}/product-projections/search
?filter.query=categories.id:subtree("{{category-id}}")
&filter=variants.attributes.color.key:"black","grey"
&filter=variants.availability.isOnStock:true
{
"query": {
"filter": [
{ "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } }
]
},
"postFilter": {
"filter": [
{ "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "values": ["black", "grey"] } },
{ "exact": { "field": "variants.availability.isOnStock", "value": true } }
]
}
}
Example: filter by Price
priceMode). The same intent can therefore match more Products. Verify affected Product Listing Pages explicitly.GET /{projectKey}/product-projections/search
?priceCurrency=EUR&priceCountry=DE
&filter=variants.scopedPrice.currentValue.centAmount:range (1000 to 5000)
{
"query": {
"and": [
{ "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } },
{ "exact": { "field": "variants.prices.country", "value": "DE" } },
{ "range": { "field": "variants.prices.currentCentAmount", "gte": 1000, "lte": 5000 } }
]
}
}
variants.prices.currentCentAmount when discounted Prices should be taken into account, and variants.prices.centAmount when only the original Prices should count.Migrate facets
facet parameters to objects in the facets array.| Product Projection Search | Product Search |
|---|---|
Term facet, such as variants.attributes.color.key | distinct facet |
Range facet, such as variants.price.centAmount:range (...) | ranges facet |
Filtered facet, such as variants.attributes.color.key:"red" | distinct facet with includes, or a count facet with a filter |
counting products extension | level: "products", which is the default |
as {alias} | name |
filter.facets parameter | filter inside the facet expression |
categories.id: subtree("{id}") facet | distinct facet on categoriesSubTree |
| - | stats facets |
| - | missing for a bucket of Products without a value |
| - | scope: "all" for facets that ignore the query |
| - | sort and limit on buckets |
includes.Set the facet counting level
counting products extension. Product Search counts Products by default and counts Product Variants when you set level to variants.level explicitly rather than relying on the default. Otherwise the numbers next to your filter options change silently.Rebuild multi-select faceting
filter.facets was a single global parameter: it narrowed every facet's aggregation except the facet it applied to. In Product Search, each facet expression carries its own filter. To reproduce multi-select faceting on a Product Listing Page:- Put non-facet criteria, such as the Category and the search text, in
query. Both the results and every facet inherit them. - Put the union of all selected facet values in
postFilter. This narrows the results only. - For each facet, set its
filterto the selections of all other facets, excluding its own.
black and m selected. The size facet is filtered by color, and the color facet is filtered by size.{
"query": {
"filter": [
{ "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } }
]
},
"postFilter": {
"filter": [
{ "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "value": "black" } },
{ "exact": { "field": "variants.attributes.size.key", "fieldType": "enum", "value": "m" } }
]
},
"facets": [
{
"distinct": {
"name": "color",
"field": "variants.attributes.color.key",
"fieldType": "enum",
"level": "products",
"limit": 50,
"filter": { "exact": { "field": "variants.attributes.size.key", "fieldType": "enum", "value": "m" } }
}
},
{
"distinct": {
"name": "size",
"field": "variants.attributes.size.key",
"fieldType": "enum",
"level": "products",
"limit": 50,
"filter": { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "value": "black" } }
}
}
],
"limit": 20,
"offset": 0
}
Read the facet results
name you assigned. Product Projection Search returned an object keyed by attribute path or alias.{
"total": 148,
"offset": 0,
"limit": 20,
"facets": [
{ "name": "color", "buckets": [{ "key": "black", "count": 37 }] },
{ "name": "size", "buckets": [{ "key": "m", "count": 42 }] }
],
"results": [{ "id": "8fde2af0-6a2f-4633-9ba4-83566f769a7f" }]
}
facets["variants.attributes.size"].dataType, total, other, and missing fields of the Product Projection Search term facet result do not exist in Product Search. Use the missing option to get a bucket for Products without a value. Use limit (up to 200) to control how many buckets are returned.Migrate sorting
Sort strings become objects, and the sort mode moves out of the sort direction.
| Product Projection Search | Product Search |
|---|---|
name.en asc | { "field": "name", "language": "en", "order": "asc" } |
price desc | { "field": "variants.prices.centAmount", "order": "desc", "mode": "max" } |
variants.scopedPrice.currentValue.centAmount asc | { "field": "variants.prices.currentCentAmount", "order": "asc", "mode": "min", "filter": { ... } } |
variants.attributes.color.label.en asc.max | { "field": "variants.attributes.color.label", "language": "en", "fieldType": "lenum", "order": "asc", "mode": "max" } |
categoryOrderHints.{id} asc | { "field": "categoryOrderHints.{id}", "order": "asc" } |
score desc | { "field": "score", "order": "desc" } |
createdAt, lastModifiedAt, id | unchanged as field values |
asc.min becomes order: "asc" with mode: "min", desc.max becomes order: "desc" with mode: "max", and so on. Product Search adds the avg and sum sort modes.sort array with a field that has distinct values across all Products, usually id.{
"sort": [
{ "field": "name", "language": "en", "order": "asc" },
{ "field": "id", "order": "asc" }
]
}
Replace scoped price sorting
variants.scopedPrice.* relied on the price selection parameters of the search request. In Product Search, express the scope in the sort's own sort filter.{
"sort": [
{
"field": "variants.prices.centAmount",
"order": "asc",
"mode": "min",
"filter": {
"and": [
{ "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } },
{ "exact": { "field": "variants.prices.channel", "value": "{{channel-id}}" } }
]
}
}
]
}
query and postFilter. The sort filter must therefore repeat any scoping the ranking depends on. A sort filter must also stay on the same field level as the sort field. A price sort therefore cannot be scoped by a product-level field such as an Attribute value. If your current implementation sorts by scoped price while filtering by an Attribute, the ranking scope becomes wider than before. Products whose non-matching Variants carry lower Prices can then appear earlier than expected. This is a known constraint, not a paging problem.Migrate pagination
limit and offset fields keep their names and meaning, and you can still retrieve the first 10 000 results. Three changes apply:- The maximum
limitdrops from 500 to 100. If you request pages of more than 100 results, reduce the page size or fetch several pages. - The maximum usable
offsetdepends on the limit, because the 10 000-result ceiling still applies. With alimitof 100 the maximumoffsetis 9 900; with the defaultlimitof 20 it is 9 980. - Exceeding the ceiling returns an InvalidInput error instead of a
SearchExecutionFailureerror. Update any error handling that matches on the old error code.
limit to 0 to retrieve facets only works in both APIs.Retrieve full Product data
Product Search returns Product IDs. Choose one of the following approaches to retrieve the data you render. This is also where price selection, locale projection, and Store projection now happen.
With GraphQL
product field inside the productsSearch query to fetch Product data and apply price selection in the same request. For details, see use Product Search with GraphQL.{
productsSearch(
query: { fullText: { field: "name", value: "skirt", language: "en" } }
limit: 20
) {
total
offset
limit
results {
product {
masterData {
current {
name(locale: "en")
slug(locale: "en")
masterVariant {
sku
images { url }
price(currency: "EUR", country: "DE") {
value { centAmount }
}
attributesRaw(includeNames: "designer") { name value }
}
}
}
}
}
}
}
This is the closest equivalent to the single-call model of Product Projection Search and usually the smallest change to a storefront.
With the Product Projections API
GET /{projectKey}/product-projections
?where=id in ("{{id-1}}","{{id-2}}","{{id-3}}")
&staged=false
&priceCurrency=EUR&priceCountry=DE&priceCustomerGroup={{customer-group-id}}
&localeProjection=en
&storeProjection={{store-key}}
filter[attributes] to reduce the response to the Attributes you render.To improve performance, add a caching layer to persist frequently requested product data between sessions.
Read matching variants
markMatchingVariants field exists in both APIs, but the response differs. Product Projection Search added an isMatchingVariant boolean to each returned Product Variant. Product Search returns a separate ProductSearchMatchingVariants object per result.{
"id": "{{product-id}}",
"matchingVariants": {
"allMatched": false,
"matchedVariants": [
{ "id": 1, "sku": "CSKW-093" },
{ "id": 5, "sku": "CSKP-0932" }
]
}
}
allMatched is true, all Variants match and matchedVariants is empty. Handle that case explicitly; it is a common cause of empty variant lists after migration.New capabilities
Product Search offers capabilities that Product Projection Search does not. Consider whether they let you simplify your implementation, or remove workarounds you built for their absence.
- Prefix matching: match the beginning of a field value with prefix expressions.
- Wildcard matching: match patterns within field values with wildcard expressions.
- Field-level boosting: control relevance scoring per field with boosting.
- Product-level Attributes: search by Attributes defined at the Product level with the
attributes.{name}field, not only at the Variant level. You can removeSameForAllworkarounds built for Product Projection Search. - Standalone Prices: filter, sort, and facet on Standalone Prices.
- Stores and Product Selections: filter on the
storesandproductSelectionsfields. - Stats facets: calculate minimum, maximum, mean, sum, and count for number and date fields with stats facets.
Indexing limits and validation
- Up to
50Variant Attributes. - Up to
50Product Attributes.
isSearchable to false on Attributes you never query. Either way, make sure the Attributes you do query fall within the limit.InvalidInput error.The following soft limits also apply to the search index:
- 15 000 Stores per Product.
- 15 000 Product Selections per Product.
- 10 000 Standalone Prices per Product.
Exceeding a soft limit produces non-deterministic results for expressions on the affected fields.
Verify and roll out
Migrate one entry point at a time. Product Listing Pages, search result pages, Category pages, and internal tools have different requirements. Running both APIs in parallel lets you compare them safely.
- Run both APIs in shadow mode. For a sample of live traffic, issue the old and the new request and log both result sets. Do not change what customers see yet.
- Compare result sets, not just counts. Check
total, the ID sets, their order, and the facet counts. Account for the indexing delay. Both indexes are eventually consistent and update independently. Small differences in the minutes after a catalog change are expected, so compare against a stable catalog. - Expect deliberate differences. Price filters now consider all Prices. Facet counts changed from Variants to Products. Relevance ranking depends on your new full-text composition. Confirm that each difference is one you intended.
- Tune relevance. Adjust
boostvalues,mustMatch, and the set of searched fields against real queries from your search logs before you switch traffic. - Roll out behind a feature toggle. Route a small share of traffic to Product Search, monitor response times and conversion, then increase the share. For the broader pattern, see Migrate checkout.
- Keep the fallback alive. Keep the Product Projection Search path working until Product Search carries all traffic. Keep calling it as well, so its index is not deactivated automatically.
Decommission Product Projection Search
Once all traffic uses Product Search and you have validated the results:
- Remove the Product Projection Search calls from your integration, including any fallback path.
- Remove client code that reads
isMatchingVariant, keyed facet objects, or thedataType,total, andotherfacet result fields. - Review
isSearchableon your AttributeDefinitions. Attributes that remained searchable only for Product Projection Search can be set tofalse, which also frees capacity within the Attribute indexing limits. - Deactivate Product Projection Search, either in the Merchant Center or with the Change Product Search Indexing Enabled update action.
curl -sH "Authorization: Bearer {access_token}" -X POST \
-H "Content-Type: application/json" \
-d '{
"version": {version_number},
"actions": [
{
"action": "changeProductSearchIndexingEnabled",
"enabled": false,
"mode": "ProductProjectionsSearch"
}
]
}' https://api.{region}.commercetools.com/{projectKey}
ProductProjectionsSearch active, or replace the auto-complete with a prefix expression on name in Product Search.Troubleshooting
| Symptom | Likely cause |
|---|---|
ObjectNotFound: Product Search API is not enabled | Product Search is not activated, or the initial indexing has not finished. |
400 InvalidInput with "Expressions nesting level are incompatible" | A compound expression combines field levels incorrectly. See Respect field-level validation. |
400 MalformedQuery | An or expression with a single sub-expression, or a fieldType that does not match the Attribute's type. |
| Empty results for an Attribute query | isSearchable is false, the Attribute is beyond the indexing limit, or the fieldType is wrong. |
| Empty results for a localized query | The Locale is not configured in the Project's languages. |
Empty results from not combined with exists | Not supported on variants, variants.prices, and variants.sku. |
| Facet counts changed after migration | The default counting level changed from Product Variants to Products. Set level explicitly. |
| More Products match a price filter than before | Product Search indexes all Prices, not only the first Embedded Price. |
matchedVariants is empty | allMatched is true, meaning all Variants match. |
| Products appear in an unexpected order across pages | The sort array has no tie-breaker, or a sort filter does not match the query. |
403 Forbidden | The token lacks view_published_products, or an external OAuth token does not list view_products explicitly. |