Table of contents :

Designing an Elasticsearch Index for WooCommerce

100-days-of-elasticsearch-woocommerce-004

Table of contents :

Start with the search experience

An Elasticsearch index for WooCommerce should be designed around the queries customers and merchandisers need to run, not as a direct copy of the WordPress database. WooCommerce stores products across posts, post metadata, taxonomy tables, lookup tables, and variation records. Elasticsearch performs best when the fields required for search, filtering, sorting, and display are denormalized into a document that can be read without joining those sources at query time.

Before creating a mapping, list the required operations:

  • Full-text search across product names, SKUs, descriptions, and attributes
  • Exact filtering by category, brand, stock status, and product attributes
  • Numeric range filtering and sorting by price
  • Sorting by relevance, popularity, rating, or date
  • Variant-aware filtering for size, color, and other options
  • Boosting products that are in stock, featured, or commercially important
  • Locale, currency, and catalog-visibility rules

This list determines the document shape and prevents unnecessary WordPress fields from becoming indexed fields.

Choose the document boundary

For most WooCommerce catalogs, use one Elasticsearch document per parent product. Store searchable variation information inside a nested field when a filter must match values from the same variation.

For example, a product with a red, small variation and a blue, large variation should not match a query for color = red AND size = large unless that combination actually exists. A regular object array can produce false matches because Elasticsearch flattens object fields. A nested field preserves each variation as an independent object.

A typical product document can look like this:

{
  "product_id": 4821,
  "type": "variable",
  "sku": "TSHIRT-BASE",
  "name": "Organic Cotton T-Shirt",
  "description": "A heavyweight organic cotton t-shirt.",
  "slug": "organic-cotton-t-shirt",
  "status": "publish",
  "catalog_visibility": "visible",
  "categories": [
    { "id": 12, "slug": "t-shirts", "name": "T-Shirts" }
  ],
  "brands": [
    { "id": 4, "slug": "acme", "name": "Acme" }
  ],
  "attributes": [
    { "slug": "material", "value": "Organic Cotton" }
  ],
  "price": 29.95,
  "regular_price": 34.95,
  "sale_price": 29.95,
  "currency": "USD",
  "stock_status": "instock",
  "stock_quantity": 42,
  "average_rating": 4.7,
  "review_count": 86,
  "popularity": 1832,
  "featured": true,
  "published_at": "2025-02-01T10:15:00Z",
  "variations": [
    {
      "id": 4822,
      "sku": "TSHIRT-RED-S",
      "price": 29.95,
      "stock_status": "instock",
      "attributes": [
        { "slug": "color", "value": "red" },
        { "slug": "size", "value": "small" }
      ]
    }
  ]
}

A separate document per variation can be a better choice for catalogs where shoppers search and purchase individual SKUs, or where variation-level inventory and pricing dominate the experience. That model simplifies variant filtering but requires the application to group variations under their parent product and avoid displaying duplicate product cards. Select one model deliberately; mixing parent and variation documents in the same product index usually complicates relevance, pagination, and result counts.

Use explicit mappings

Do not rely on dynamic mapping for a production WooCommerce index. Product attributes, custom fields, and third-party plugin data can introduce thousands of field names and trigger mapping explosion. Explicit mappings make field behavior predictable and keep indexing failures visible.

An example Elasticsearch 8 mapping is:

PUT products-v1
{
  "settings": {
    "analysis": {
      "normalizer": {
        "lowercase_keyword": {
          "type": "custom",
          "filter": ["lowercase", "asciifolding"]
        }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "product_id": { "type": "integer" },
      "type": { "type": "keyword" },
      "sku": {
        "type": "keyword",
        "normalizer": "lowercase_keyword",
        "fields": { "text": { "type": "text" } }
      },
      "name": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "description": { "type": "text" },
      "slug": { "type": "keyword" },
      "status": { "type": "keyword" },
      "catalog_visibility": { "type": "keyword" },
      "categories": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "slug": { "type": "keyword" },
          "name": { "type": "keyword" }
        }
      },
      "brands": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "slug": { "type": "keyword" },
          "name": { "type": "keyword" }
        }
      },
      "attributes": {
        "type": "nested",
        "properties": {
          "slug": { "type": "keyword" },
          "value": { "type": "keyword", "normalizer": "lowercase_keyword" }
        }
      },
      "price": { "type": "scaled_float", "scaling_factor": 100 },
      "regular_price": { "type": "scaled_float", "scaling_factor": 100 },
      "sale_price": { "type": "scaled_float", "scaling_factor": 100 },
      "currency": { "type": "keyword" },
      "stock_status": { "type": "keyword" },
      "stock_quantity": { "type": "integer" },
      "average_rating": { "type": "half_float" },
      "review_count": { "type": "integer" },
      "popularity": { "type": "long" },
      "featured": { "type": "boolean" },
      "published_at": { "type": "date" },
      "variations": {
        "type": "nested",
        "properties": {
          "id": { "type": "integer" },
          "sku": { "type": "keyword", "normalizer": "lowercase_keyword" },
          "price": { "type": "scaled_float", "scaling_factor": 100 },
          "stock_status": { "type": "keyword" },
          "attributes": {
            "type": "nested",
            "properties": {
              "slug": { "type": "keyword" },
              "value": { "type": "keyword", "normalizer": "lowercase_keyword" }
            }
          }
        }
      }
    }
  }
}

dynamic: strict is appropriate when the indexing pipeline controls every field. If plugins or custom integrations add optional fields, use dynamic: false at the root or on selected objects and explicitly promote only approved fields into the mapping. dynamic: false keeps unknown fields in _source without indexing them, whereas strict rejects documents containing unknown fields. Both are safer than allowing uncontrolled dynamic fields.

Separate search fields from display fields

Use text fields for analyzed full-text search and keyword fields for exact matching, aggregations, and sorting. A product name commonly needs both forms, which is why the mapping includes name and name.keyword.

SKUs generally need exact matching, but a secondary analyzed field can support searches where a customer enters only part of a SKU. Keep the original SKU in _source for display and normalize matching values consistently during indexing. Do not use analyzed text for category slugs, stock states, product IDs, or other controlled values.

WooCommerce prices should be indexed as numbers, not formatted strings. scaled_float is useful for monetary values because it stores a decimal amount using a fixed scaling factor. The scale must match the pricing precision used by the store. If a store supports multiple currencies, index the currency and maintain separate price fields or separate indices where necessary. Never compare USD and EUR values in the same numeric field without a currency conversion strategy.

Model attributes for reliable faceting

Global WooCommerce attributes such as color and size should be exported with stable slugs or term IDs rather than only their translated labels. Labels can change, contain inconsistent capitalization, or vary by locale. A useful attribute object contains both the stable identifier and the display label when the frontend needs both.

For product-level faceting, a flattened structure can be simpler and faster if each attribute has its own known field. For example, attribute_color and attribute_size can be keyword arrays. This approach is suitable for a controlled catalog with a fixed attribute set.

Use nested attributes when the relationship between an attribute name and value must be preserved, especially for variation combinations. A nested filter for a specific variation looks like this:

GET products-v1/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "status": "publish" } },
        { "term": { "catalog_visibility": "visible" } },
        {
          "nested": {
            "path": "variations",
            "query": {
              "bool": {
                "filter": [
                  { "term": { "variations.stock_status": "instock" } },
                  {
                    "nested": {
                      "path": "variations.attributes",
                      "query": {
                        "bool": {
                          "filter": [
                            { "term": { "variations.attributes.slug": "color" } },
                            { "term": { "variations.attributes.value": "red" } }
                          ]
                        }
                      }
                    }
                  }
                ]
              }
            },
            "inner_hits": {}
          }
        }
      ]
    }
  }
}

The nested path must match the mapping. If the application only needs to know whether a product has a color and does not need to preserve variation combinations, indexing a normalized product-level list may be sufficient and will reduce query complexity.

Keep catalog visibility and permissions in the index

Filter out products that should not appear in the storefront at query time. Common fields include post status, catalog visibility, publication date, stock status, sales-channel eligibility, and customer-role restrictions.

Do not assume that an unpublished or private WordPress post is excluded automatically. The index has no knowledge of WordPress permissions unless the synchronization process writes those rules into the document. For scheduled products, index the publication boundaries and apply a range filter, or remove products from the public index until they become visible.

For B2B catalogs, customer-specific pricing and permissions should usually be applied outside a shared public product index, using a secure pricing service or a customer-scoped index. Avoid indexing sensitive price lists into a public Elasticsearch endpoint.

Design the search query around filters and scoring

Use filter clauses for constraints that should not affect relevance, such as category, stock status, price range, and visibility. Use the query portion for full-text relevance. This lets Elasticsearch cache and execute non-scoring filters efficiently.

A practical product search combines name, SKU, and description matching with business rules:

GET products-v1/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "multi_match": {
                "query": "running jacket",
                "fields": ["name^4", "sku.text^3", "description", "attributes.value^2"],
                "type": "best_fields"
              }
            }
          ],
          "filter": [
            { "term": { "status": "publish" } },
            { "term": { "catalog_visibility": "visible" } },
            { "term": { "stock_status": "instock" } },
            { "range": { "price": { "gte": 50, "lte": 200 } } }
          ]
        }
      },
      "field_value_factor": {
        "field": "popularity",
        "modifier": "log1p",
        "factor": 0.2,
        "missing": 0
      },
      "boost_mode": "sum"
    }
  },
  "sort": ["_score", { "price": "asc" }]
}

Business boosts should be measurable and bounded. A large popularity multiplier can overwhelm textual relevance and cause poor matches to rank first. Test ranking with real WooCommerce search logs, including zero-result searches, SKU searches, misspellings, and category-specific queries.

Control analyzers for the store’s language

The standard analyzer is a reasonable starting point for English product names, but international stores need language-specific analyzers, stemming, stop-word policies, and possibly separate fields per locale. Do not apply English stemming to German, French, or multilingual content.

A multilingual product document might contain name.en, name.fr, and description.en fields, each mapped with the appropriate analyzer. The active storefront locale should determine which fields are queried. If one product can have many locales, define the supported locales explicitly rather than creating arbitrary field names from request data.

Use a custom analyzer only after examining actual search behavior. Lowercasing, ASCII folding, synonyms, stemming, and edge n-grams each affect relevance and index size. Search-as-you-type can be implemented with a dedicated search_as_you_type field or carefully designed prefix fields; avoid indexing n-grams across every large description field.

Handle WooCommerce synchronization as an index lifecycle

The WordPress database remains the source of truth. Elasticsearch should be treated as a rebuildable read model. A reliable integration should support:

  1. A full export that reads products, variations, taxonomies, prices, and visibility data.
  2. Incremental updates triggered by product, variation, taxonomy, inventory, price, and review changes.
  3. Deletion events for trashed products and products that become permanently unavailable.
  4. A retry queue for temporary Elasticsearch failures.
  5. Bulk indexing with bounded batches and refresh control.
  6. Reconciliation jobs that compare WooCommerce records with indexed product IDs.

WordPress hooks can initiate updates, but a queue is safer than indexing synchronously during an administrative save request. Inventory and price changes should be prioritized because stale availability can create failed purchases. If an update changes a variation, rebuild the parent product document when using the one-document-per-product model.

Use aliases so a complete rebuild does not require changing application code:

POST _aliases
{
  "actions": [
    { "remove": { "index": "products-v1", "alias": "products-read" } },
    { "add": { "index": "products-v2", "alias": "products-read" } }
  ]
}

Build and validate products-v2, run representative queries and document-count checks, then switch the alias atomically. Keep the previous index until the new index has passed verification and rollback is no longer needed. The write alias and read alias may be separate when incremental indexing continues during a migration.

Test mapping and query behavior before launch

Use representative products rather than a handful of simple test records. Include variable products, products without SKUs, long descriptions, accented names, duplicate titles, out-of-stock variations, sale prices, hidden products, products with many attributes, and products with deleted terms.

Verify that:

  • Category and attribute aggregations return expected counts
  • Price sorting is numeric and respects currency rules
  • A variation filter cannot combine attributes from different variations
  • Hidden, private, and scheduled products are excluded correctly
  • SKU searches work with the store’s capitalization and punctuation conventions
  • Search results remain relevant when filters are added
  • Bulk indexing and update retries are idempotent
  • Unknown plugin fields do not create uncontrolled mappings
  • Alias changes can be rolled back without downtime

Inspect mappings with the Elasticsearch mapping API and use the profile API on representative queries when diagnosing slow searches. Monitor shard size, refresh latency, rejected bulk requests, heap pressure, mapping growth, and the rate of synchronization failures. The right index design is the one that preserves WooCommerce’s catalog rules while making the common storefront query fast, predictable, and easy to rebuild.

Trending posts
You might also like