Joins & relationships

This section documents the current Elasticsearch 9.x line (with 8.19 as the final 8.x release) as published at the Elasticsearch documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Kibana-only UIs, the ML/NLP model-management workflow, cross-cluster replication, and parts of the paid / serverless-only surface) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Elasticsearch iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Elasticsearch stores each document as a flat set of fields and has no server-side join across documents the way SQL does. When data is one-to-many or many-to-many you choose one of four models — plain object fields, nested, the join field, or denormalization — each trading query power against write cost and how well it scales.

Elasticsearch is flat by default

Internally every document is flattened to field.subfield paths, and an array of objects flattens field by field: comments.author becomes one list, comments.stars another, and the link between a given author and that author’s star rating is lost. The models below differ in what they do about that:

Model Use it when

object (default)

one-to-one data, or an array of objects where correlation between the sub-fields does not matter

nested

an array of objects you must query with its fields correlated; the child set is bounded and rarely changes

join field

parent and children are queried and updated independently; the child set is large or churny; both fit on one shard

denormalize

the default at scale — copy the fields you filter and aggregate on into each document and join in the application

A fifth, lighter option — terms lookup — fetches an array of values from one document and uses it as a terms filter against another index: a read-time join with no mapping change.

Diagram: one blog post with three comments

For the modelling trade-off behind this choice — embed vs. reference — see SQL Relations, MongoDB Schema Design and Couchbase Data Modeling rather than a re-statement here.

Object fields and lost array correlation

An object field needs no declaration — any JSON object is mapped as one by default (see Object field type). Querying two sub-fields of an object array together matches if different array elements satisfy each clause:

PUT /blog
{
  "mappings": {
    "properties": {
      "title":    { "type": "text" },
      "comments": {
        "properties": {
          "author": { "type": "keyword" },
          "stars":  { "type": "integer" }
        }
      }
    }
  }
}

PUT /blog/_doc/1
{
  "title": "Sharding",
  "comments": [
    { "author": "alice", "stars": 5 },
    { "author": "bob",   "stars": 1 }
  ]
}

# Internally comments.author = ["alice","bob"], comments.stars = [5,1].
# This matches doc 1 even though bob gave 1 star, not 5 -- the per-element
# link is gone. Use "nested" when that link must hold.
GET /blog/_search
{
  "query": {
    "bool": {
      "must": [
        { "term": { "comments.author": "bob" } },
        { "term": { "comments.stars": 5 } }
      ]
    }
  }
}

The flattened type is the opposite extreme: it maps an entire object — keys and all — as a single field of keywords, which suits objects with many or unknown keys (labels, arbitrary metadata) at the cost of no analysis, no numeric or date handling, and only exact-term queries. See Flattened field type.

PUT /events
{ "mappings": { "properties": { "labels": { "type": "flattened" } } } }

PUT /events/_doc/1
{ "labels": { "env": "prod", "team": "search", "region": "eu" } }

GET /events/_search
{ "query": { "term": { "labels.team": "search" } } }

Nested fields

Mapping a field as nested makes Elasticsearch index each object in the array as a hidden Lucene document on the same shard as its parent, so the sub-fields of one element stay together. See Nested field type.

PUT /blog-nested
{
  "mappings": {
    "properties": {
      "title":    { "type": "text" },
      "comments": {
        "type": "nested",
        "properties": {
          "author": { "type": "keyword" },
          "stars":  { "type": "integer" },
          "text":   { "type": "text" }
        }
      }
    }
  }
}

PUT /blog-nested/_doc/1
{
  "title": "Sharding",
  "comments": [
    { "author": "alice", "stars": 5, "text": "clear and useful" },
    { "author": "bob",   "stars": 1, "text": "too short" }
  ]
}

A nested query steps into that scope; the clauses inside it must all match the same sub-document, and inner_hits reports which one(s) did. See Nested query and Retrieve inner hits.

GET /blog-nested/_search
{
  "query": {
    "nested": {
      "path": "comments",
      "query": {
        "bool": {
          "must": [
            { "term":  { "comments.author": "bob" } },
            { "match": { "comments.stars": 5 } }
          ]
        }
      },
      "inner_hits": { "size": 3 }
    }
  }
}
# => 0 hits: no single comment is by bob AND worth 5 stars.

Aggregations must also cross into nested scope explicitly, with a nested aggregation. See Nested aggregation and Aggregations.

GET /blog-nested/_search
{
  "size": 0,
  "aggs": {
    "comments": {
      "nested": { "path": "comments" },
      "aggs": { "avg_stars": { "avg": { "field": "comments.stars" } } }
    }
  }
}

Cost:

  • Each nested value is a separate Lucene document, so a post with 100 comments indexes as 101 documents. index.mapping.nested_objects.limit (default 10000) caps the total per document.

  • Updating one comment reindexes the whole parent document — nested children are not addressable on their own.

  • A nested field is invisible to an ordinary query, sort, or aggregation; every access must go through nested / inner_hits.

The join field

A join field defines named parent/child relations within one index; every document carries the field to say which side it is on. This replaces the _parent field removed after 2.x — there is no separate parent type or index. See Join field type and Joining queries.

PUT /blog-join
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "text":  { "type": "text" },
      "post_comment": {
        "type": "join",
        "relations": { "post": "comment" }
      }
    }
  }
}

A parent and all of its children must live on the same shard, so every child write is routed with routing=<parent id> (the parent’s own _id is its default routing value). See Cluster, nodes & shards and Indexing: CRUD & bulk for routing.

# Parent.
PUT /blog-join/_doc/post-1?refresh
{ "title": "Sharding", "post_comment": "post" }

# Child: name the relation and its parent, and route to the parent's shard.
PUT /blog-join/_doc/comment-1?routing=post-1&refresh
{ "text": "clear and useful", "post_comment": { "name": "comment", "parent": "post-1" } }

PUT /blog-join/_doc/comment-2?routing=post-1&refresh
{ "text": "too short", "post_comment": { "name": "comment", "parent": "post-1" } }

has_child finds parents by their children, has_parent finds children by their parent, and parent_id is the cheap direct lookup of one parent’s children. See has_child, has_parent and parent_id query.

# Posts that have a matching comment; inner_hits returns the comments.
GET /blog-join/_search
{
  "query": {
    "has_child": {
      "type": "comment",
      "query": { "match": { "text": "useful" } },
      "inner_hits": {}
    }
  }
}

# Comments whose parent post matches.
GET /blog-join/_search
{
  "query": {
    "has_parent": {
      "parent_type": "post",
      "query": { "match": { "title": "sharding" } }
    }
  }
}

# All comments of one known parent -- direct, no join evaluation.
GET /blog-join/_search
{ "query": { "parent_id": { "type": "comment", "id": "post-1" } } }

Cost and constraints:

  • One join field per index. A parent may have several child relations ("post": ["comment", "vote"]) and you can chain grandchildren, but each extra level multiplies query cost.

  • has_child / has_parent join at query time by loading the join field’s global ordinals into heap on first use after a refresh; set eager_global_ordinals on the join field to pay that at refresh instead (see eager_global_ordinals). They are markedly slower than nested and do not suit high query rates — covered further in Performance tuning.

  • Get, update and delete of a child all need its routing value.

  • The payoff: parent and child documents are indexed, updated and deleted independently.

Terms lookup: a read-time join

A terms query can read its list of values from a field of another document instead of an inline array, which joins two independently-written data sets at query time with no mapping change. See Terms lookup and Term-level queries.

# follows/1 lists the authors user 1 follows.
PUT /follows/_doc/1
{ "following": ["alice", "cara", "dan"] }

# Fetch that array with one internal GET by id and filter posts with it.
GET /posts/_search
{
  "query": {
    "terms": {
      "author": { "index": "follows", "id": "1", "path": "following" }
    }
  }
}

The lookup document is fetched in realtime by id (supply its routing if it has one), and the resolved list is still bound by index.max_terms_count (default 65536), so keep the referenced array modest.

Denormalize and join in the application

At scale the recommended model is to denormalize: copy the handful of fields you filter, sort or aggregate on into each document, accept the duplication, and reconcile copies with a background job or _update_by_query when the source changes. For genuine ad-hoc joins over large data sets, run two queries from the application and intersect the id sets there, or reshape the data so the join is unnecessary.

# Each post carries the author's name, not just an id -- no lookup at query time.
PUT /posts/_doc/1
{ "title": "Sharding", "author_id": "u1", "author_name": "Ada Lovelace" }

# Author renamed: fix every denormalized copy in one pass.
POST /posts/_update_by_query
{
  "query": { "term": { "author_id": "u1" } },
  "script": { "source": "ctx._source.author_name = params.name", "params": { "name": "Ada L." } }
}

Elasticsearch has no cross-document or cross-index transactions, so a source document and its denormalized copies can be briefly inconsistent while such a pass runs — contrast MongoDB transactions. See also Mapping & field types for the object, nested and flattened mappings.