05b — Modeling relationships
Elasticsearch is a document store, not a relational database. But real data has relationships. This part covers the three options —
nested,join(parent-child), and denormalization — with concrete trade-offs so you can make the right call for each use case.
Goals for this part
- Understand why flat object arrays cause false positives and when to
use
nested - Write
nestedqueries withinner_hitsand nested sort - Know when
join(parent-child) is justified and how to usehas_child/has_parent - Choose the right relationship strategy for a given workload
5.2 Modeling
relationships: nested queries
5.2.1 Why flat object arrays cause cross-object false matches
When items is a plain object array,
Elasticsearch internally flattens all values into parallel arrays:
items.category → ["Elektronika", "Clothing"]
items.unit_price → [1200.00, 49.99]
A query asking for “category=Elektronika AND unit_price>1000 in
the same item” will match a document where one item has
category=Elektronika (unit_price=49.99) and another
item has unit_price=1200 (category=Clothing) — a false
positive.
nested type stores each array element as a hidden,
separately indexed document that retains its own field grouping,
eliminating cross-object correlation.
5.2.2 The nested
query with inner_hits
GET /shop-orders-demo/_search
{
"query": {
"nested": {
"path": "items",
"query": {
"bool": {
"must": [
{ "term": { "items.category": "Elektronika" } },
{ "range": { "items.unit_price": { "gt": 1000 } } }
]
}
},
"inner_hits": {
"name": "matching_items",
"size": 5,
"_source": ["items.name", "items.unit_price", "items.quantity"]
}
}
},
"_source": ["order_id", "customer.name", "order_status"]
}
inner_hits returns the specific nested items that
matched — without it, you only know the parent document matched. Each
inner hit also has its own _score relative to the nested
query.
5.2.3 Sorting and filtering by nested fields
Sort orders by the maximum unit price of any matching item:
{
"sort": [
{
"items.unit_price": {
"order": "desc",
"mode": "max",
"nested": {
"path": "items",
"filter": {
"term": { "items.category": "Elektronika" }
}
}
}
}
]
}The nested sort context is required — Elasticsearch
needs to know which nested path to navigate and optionally which subset
of nested docs to consider.
5.2.4 Trade-offs
| Concern | Detail |
|---|---|
| Write amplification | Updating any field in the parent document rewrites the entire document including all nested docs. High-frequency partial updates are expensive. |
| Query cost | Each nested query does a join at query time — more
expensive than flat queries. Avoid deeply nested structures. |
| Index size | Each nested object is a hidden Lucene document. A 50-item order generates 50 hidden docs. |
| Max nested depth | index.mapping.nested_fields.limit (default: 50 nested
types) and index.mapping.nested_objects.limit (default:
10,000 nested objects per document). |
Pitfall: Declaring a field as
nestedafter data is already indexed does nothing — nested documents are written at index time. You must reindex existing data after changing the mapping.
5.3 Modeling
relationships: join / parent-child
5.3.1 The join field type
join implements a parent-child relationship within the
same index. The child documents are independent Lucene
documents (unlike nested, which are hidden docs). This means children
can be updated without rewriting the parent.
Define the join field in mappings:
PUT /orders-with-events
{
"mappings": {
"properties": {
"join_field": {
"type": "join",
"relations": {
"order": "event"
}
},
"order_id": { "type": "keyword" },
"event_type": { "type": "keyword" },
"event_at": { "type": "date" }
}
}
}
Index a parent (order):
PUT /orders-with-events/_doc/order-1?routing=order-1
{
"order_id": "order-1",
"join_field": "order"
}
Index a child (event) — must use the same routing as its parent:
PUT /orders-with-events/_doc/event-1?routing=order-1
{
"order_id": "order-1",
"event_type": "shipped",
"event_at": "2024-03-15T10:00:00Z",
"join_field": {
"name": "event",
"parent": "order-1"
}
}
Critical: Parent and child must reside on the same shard. This is enforced by routing. Forgetting
?routing=is the most common operational mistake with join fields.
5.3.2 has_child
and has_parent queries
Find orders that have a “shipped” event:
GET /orders-with-events/_search
{
"query": {
"has_child": {
"type": "event",
"query": {
"term": { "event_type": "shipped" }
},
"inner_hits": {}
}
}
}
Find all events for orders with
order_status = completed:
{
"query": {
"has_parent": {
"parent_type": "order",
"query": {
"term": { "order_status": "completed" }
}
}
}
}Tip:
has_childwithscore_mode: "max"lets child scores influence the parent’s ranking — useful when you want orders ranked by how relevant their events are.
5.3.3 Nested vs join vs denormalization vs application-side join
| Strategy | Best when | Performance | Consistency | Flexibility |
|---|---|---|---|---|
| Flat object (no nested) | Array items are always queried as a unit; no cross-field correlation needed | Fastest reads | — | Limited |
nested |
Array items need cross-field correlation; items are written together with parent; item count is bounded | Moderate; join at query time | Written atomically with parent | Good for structured line items |
join (parent-child) |
Children update independently and frequently; parent:child ratio is high (e.g. 1:1000) | Slowest; join at query time, same-shard constraint | Children independent | High; children evolve separately |
| Denormalization | Data changes infrequently; read performance is paramount; duplicated data is acceptable | Fastest reads | Must sync on updates | Limited by doc size |
| Application-side join | Relationships are ad-hoc; data volumes are small; latency is not critical | Two round trips | Application manages | Maximum |
General rule of thumb: Denormalize. Embed items in
the order document (as nested if you query across item
fields, as plain objects if you don’t). Use join only when
children are truly independent entities updated at very different rates
(e.g., audit events appended to orders, where you never want to reindex
the order to add an event).
Lab — Nested queries, inner hits, and join
Part A: nested query with inner_hits and sorting
Goal: Experience the difference between a flat query
and a nested query in terms of false positives.
Step 1 — First, run a naïve bool query
(without nested) to find orders with both
category = Elektronika and
unit_price > 500. Note the result count:
GET /shop-orders-demo/_search
{
"query": {
"bool": {
"must": [
{ "term": { "items.category": "Elektronika" } },
{ "range": { "items.unit_price": { "gt": 500 } } }
]
}
},
"_source": ["order_id"],
"size": 5
}
Step 2 — Now run the correct nested
query. Compare the result count with Step 1:
GET /shop-orders-demo/_search
{
"query": {
"nested": {
"path": "items",
"query": {
"bool": {
"must": [
{ "term": { "items.category": "Elektronika" } },
{ "range": { "items.unit_price": { "gt": 500 } } }
]
}
},
"inner_hits": {
"name": "matching_items",
"size": 3,
"_source": ["items.name", "items.unit_price", "items.category"]
}
}
},
"_source": ["order_id", "customer.name", "order_status"],
"size": 5
}
Questions: 1. Are the result counts the same? If not, which query
returned more results and why? 2. In the inner_hits, which
specific items matched in each order?
Step 3 — Sort the nested query results by the
maximum unit_price of the matching Elektronika items
(descending):
GET /shop-orders-demo/_search
{
"query": {
"nested": {
"path": "items",
"query": {
"bool": {
"must": [
{ "term": { "items.category": "Elektronika" } },
{ "range": { "items.unit_price": { "gt": 500 } } }
]
}
},
"inner_hits": {
"name": "matching_items",
"size": 3,
"_source": ["items.name", "items.unit_price"]
}
}
},
"sort": [
{
"items.unit_price": {
"order": "desc",
"mode": "max",
"nested": {
"path": "items",
"filter": { "term": { "items.category": "Elektronika" } }
}
}
}
],
"_source": ["order_id", "customer.name"],
"size": 5
}
Takeaway: The flat query matches documents where one
item has Elektronika and a different item has
unit_price > 500 — a false positive. The
nested query correctly correlates fields within the same
item.
Part B: join field (parent-child)
Goal: Build a small parent-child index to understand the join model.
Step 1 — Create the index:
PUT /orders-with-events
{
"settings": { "number_of_replicas": 0 },
"mappings": {
"properties": {
"join_field": {
"type": "join",
"relations": { "order": "event" }
},
"order_id": { "type": "keyword" },
"event_type": { "type": "keyword" },
"event_at": { "type": "date" }
}
}
}
Step 2 — Index two parent orders:
PUT /orders-with-events/_doc/order-1?routing=order-1
{ "order_id": "order-1", "join_field": "order" }
PUT /orders-with-events/_doc/order-2?routing=order-2
{ "order_id": "order-2", "join_field": "order" }
Step 3 — Index child events (note
?routing must match the parent’s ID):
PUT /orders-with-events/_doc/event-1?routing=order-1
{
"order_id": "order-1",
"event_type": "payment_received",
"event_at": "2024-03-15T08:00:00Z",
"join_field": { "name": "event", "parent": "order-1" }
}
PUT /orders-with-events/_doc/event-2?routing=order-1
{
"order_id": "order-1",
"event_type": "shipped",
"event_at": "2024-03-15T14:00:00Z",
"join_field": { "name": "event", "parent": "order-1" }
}
PUT /orders-with-events/_doc/event-3?routing=order-2
{
"order_id": "order-2",
"event_type": "payment_received",
"event_at": "2024-03-16T09:00:00Z",
"join_field": { "name": "event", "parent": "order-2" }
}
Step 4 — Find all orders that have a “shipped” event:
GET /orders-with-events/_search
{
"query": {
"has_child": {
"type": "event",
"query": { "term": { "event_type": "shipped" } },
"inner_hits": {}
}
}
}
Step 5 — Update a child event without touching the parent (a key advantage of join over nested):
POST /orders-with-events/_update/event-2?routing=order-1
{
"doc": { "event_type": "delivered" }
}
Step 6 — Confirm the update worked; verify the
parent document was NOT updated (doc count and _seq_no
unchanged):
GET /orders-with-events/_doc/order-1
GET /orders-with-events/_doc/event-2?routing=order-1
Step 7 — Clean up:
DELETE /orders-with-events
Takeaway: join lets children evolve
independently without reindexing the parent — the key trade-off vs
nested. The same-shard routing constraint is
non-negotiable; forgetting ?routing is the most common
mistake.