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


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 nested after 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_child with score_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.