The 30 second answer
My default is to denormalize. One document per product, or per sellable variant, with the attributes queries need copied in. I use nested only when an array of objects must be matched element by element, such as size together with stock. Parent-child fits when children vastly outnumber parents and change often, like offers on products, and it costs query speed and same-shard routing. Flattened is for many arbitrary keys you only filter on.
A fashion catalog has a product with many variants (colour and size) and often many offers (price and stock per merchant or warehouse). How you put that into Elasticsearch decides both correctness and speed.
Option one: denormalize. Elasticsearch's own guidance on joins is that "the key to good performance is to de-normalize your data into documents". You either index one document per product with the variant facts it needs flattened in, or one document per sellable variant (colour), with sizes as a multi-valued field. Queries are fast and simple. The cost is write amplification: one stock change rewrites a whole document, and in Lucene an update is a delete plus a new document.
Option two: nested. A plain array of objects is flattened into parallel arrays, so the document {sizes: [{size: "M", inStock: false}, {size: "L", inStock: true}]} would match a query for "size M and in stock", because M and true both appear somewhere. The nested type indexes each array element as a separate hidden document so a nested query can require both conditions on the same element. The docs call nested documents and queries "typically expensive" and guard them with limits: index.mapping.nested_fields.limit (default 100 distinct nested mappings) and index.mapping.nested_objects.limit (default 10,000 nested objects per document). Because an update in Elasticsearch rewrites the whole document, changing one variant also rewrites the parent and every hidden nested document with it.
**Option three: parent-child (the join field).** Parent and child are separate documents in the same index, so a child can be updated without touching the parent. The docs restrict it: only one join field per index, parent and children must be routed to the same shard, and each has_child or has_parent query "adds a significant tax" to query performance and can trigger global ordinals to be built. The docs give exactly the e-commerce example: the join makes sense when one entity significantly outnumbers the other, such as products and offers, with the product as parent and offers as children.
Option four: flattened. The flattened type maps an entire object as a single field, indexing leaf values as keywords. It protects you from a mapping explosion when attributes have many arbitrary keys (supplier-specific attributes, say), but every value is a keyword, so there is no full-text analysis or numeric range semantics on the contents.
How I decide.
- Does the query need per-element correlation (size and stock on the same variant)? If not, plain fields are enough.
- How often do the child facts change compared with the parent? Very frequent stock or price churn on a large parent argues for splitting documents, or for keeping volatile facts out of the search document and checking them at read time.
- What is the latency budget? Nested and join queries cost more on every request, so the budget often decides for denormalization plus a fast update path.
Alternate interview scenarios
"Stock for sizes changes thousands of times a minute. Where should it live?"
I would not reindex a heavy product document on every stock tick. Options: index only an in-stock flag or available sizes and update it on a separate fast lane with coalescing, so many ticks collapse into one write; or keep exact stock out of the index and re-check it at add-to-cart. Parent-child is possible because offers can be updated alone, but every query then pays the join cost, so I would measure before choosing it.
"Why does my nested filter return products that do not match?"
Usually the field was mapped as a plain object, not nested, so the conditions are matched across different array elements. Or the query was written as two separate nested clauses instead of one nested query with both conditions inside, which again allows the conditions to match different elements.
Sources (3)
- Elasticsearch: Nested field typeelastic.co
- Elasticsearch: Join field typeelastic.co
- Elasticsearch: Flattened field typeelastic.co