Parent-child relationships: patterns, pitfalls and fixes. Join-like modeling in Elasticsearch.
Understanding the join field type
The Elasticsearch join field type allows you to model relationships between documents in a single index, similar to foreign keys in relational databases. When you define a join field, you establish a parent-child relationship where parent documents and their children live in the same index but remain separate documents. This gives you the flexibility to query and filter across both parent and child documents without duplicating data.
To set up a join field, you define it in your mapping with explicit parent and child relations:
{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"name": { "type": "text" },
"relation": {
"type": "join",
"relations": {
"product": "variant"
}
}
}
}
}In this example, product is the parent and variant is the child. When indexing, you specify the relationship explicitly:
{
"id": "prod_123",
"name": "Wireless Headphones",
"relation": {
"name": "product"
}
}
{
"id": "var_456",
"name": "Black - 32GB",
"relation": {
"name": "variant",
"parent": "prod_123"
}
}
Every child document must reference its parent by ID. The join field itself takes no storage but requires you to route both parent and child documents to the same shard, which Elasticsearch handles automatically when you set the routing parameter to the parent ID.
Update children without reindexing parents
One of the main attractions of parent-child relationships is the ability to update child documents independently. If you're working with a large product catalog where variants change frequently—new colors, sizes, or stock levels—you don't want to reindex the entire parent document just to update a single variant.
With join fields, updating a child is a straightforward index operation:
PUT /products/_doc/var_456?routing=prod_123
{
"id": "var_456",
"name": "Black - 32GB",
"stock": 150,
"price": 199.99,
"relation": {
"name": "variant",
"parent": "prod_123"
}
}
Notice the routing=prod_123 parameter. This ensures the child document lands on the same shard as its parent, which is required for the join field to work. Without routing, Elasticsearch will reject the indexing request.
The benefit here is real when you have millions of parent documents and updates flowing in for specific children. You avoid the cost of reindexing parents and can process bulk updates to variants concurrently. In production e-commerce systems, this translates to lower indexing load and faster inventory updates.
However, this independence comes with a catch: parent queries that aggregate or filter on child data require special handling. A simple query like "find all products with at least one variant in stock" forces Elasticsearch to check every child document individually, which we'll explore in the next section.
Slower than nested queries
Parent-child queries are significantly slower than nested queries because they cross document boundaries. When you query using a parent-child relationship, Elasticsearch must perform multiple passes: first find matching children, then look up their parents (or vice versa), then merge results. Each pass involves additional shard coordination overhead.
Consider a query that finds all products with a variant priced under $100:
GET /products/_search
{
"query": {
"has_child": {
"type": "variant",
"query": {
"range": {
"price": { "lt": 100 }
}
}
}
}
}
This has_child query scans all variant documents, identifies matches, then looks up parent IDs. On a large index with thousands of variants per product, this becomes expensive. The Elasticsearch team has documented that has_child queries can be 5-10x slower than equivalent nested queries on typical datasets.
Nested documents, by contrast, live within their parent as part of the same Lucene document. Querying them doesn't require cross-document lookups:
GET /products/_search
{
"query": {
"nested": {
"path": "variants",
"query": {
"range": {
"variants.price": { "lt": 100 }
}
}
}
}
}
The tradeoff is that nested documents can't be updated independently. To change a single variant's price with nested objects, you must reindex the entire parent document with all its nested children. For high-write scenarios, this becomes prohibitive.
Use parent-child relationships when independent updates are critical and query performance on relationships is secondary. Use nested documents when relationships are stable, queries are frequent, and you can afford the reindexing cost of updates.
Usually denormalize instead
After working through parent-child queries in production, most teams arrive at the same conclusion: denormalization is simpler and faster.
Instead of maintaining a separate parent and child, you embed relevant child fields into the parent document. For a product catalog, this means storing key variant attributes (color, size, price, stock) directly on the product:
PUT /products/_doc/prod_123
{
"id": "prod_123",
"name": "Wireless Headphones",
"category": "Audio",
"variants": [
{
"color": "Black",
"size": "32GB",
"price": 199.99,
"stock": 150
},
{
"color": "White",
"size": "32GB",
"price": 199.99,
"stock": 45
},
{
"color": "Black",
"size": "64GB",
"price": 249.99,
"stock": 0
}
]
}
Queries become fast and simple:
GET /products/_search
{
"query": {
"nested": {
"path": "variants",
"query": {
"bool": {
"must": [
{ "range": { "variants.price": { "lt": 100 } } },
{ "range": { "variants.stock": { "gt": 0 } } }
]
}
}
}
}
}
The downside is storage duplication. If you have 100 products with 20 variants each, you're storing the product fields 20 times. For most use cases with reasonable variant counts, this is acceptable. Document size rarely exceeds a few hundred KB, and Elasticsearch handles compression well.
Update overhead is the real question. If your variants change frequently and independently—say, stock levels updating every second—denormalization creates contention. Multiple workers updating different variants on the same product will compete for the document lock. In these high-write scenarios, parent-child does make sense, despite query slowness.
A hybrid approach works for some teams: keep denormalized data on the parent for fast queries, but also maintain parent-child relationships for real-time inventory updates. Write variant changes to a separate child index, and periodically sync summary stats back to the parent. This requires careful design and adds operational complexity, but it's viable when you have both high read volume and high independent write volume.
Common mistakes
- Forgetting routing: Parent and child documents must use the parent ID as routing. Omitting the
routingparameter will scatter child documents across shards and break queries. - Treating join fields like nested: You can't use nested syntax on join fields. They require
has_child,has_parent, andparent_idqueries, which have different semantics and performance. - Bulk indexing without routing: Bulk requests with parent-child documents must include routing in each line. This is easy to miss and causes silent indexing failures.
- Overestimating update independence: While you can update children independently, aggregations and scoring across parent-child boundaries remain expensive. Don't choose join fields solely for update isolation.
- Ignoring shard allocation: Parent-child relationships work best when parent and child documents are on the same shard. Monitor shard sizes to ensure routing is working correctly.
Key takeaways
- The join field type models one-to-many relationships in a single index, enabling independent child updates without reindexing parents.
- Parent-child queries are 5-10x slower than nested queries because they cross document boundaries and require multiple shard lookups.
- Denormalization—embedding child data directly in parent documents—is usually faster and simpler, trading storage for query speed.
- Use parent-child relationships only when independent, frequent child updates outweigh query performance costs. Otherwise, denormalize or use nested documents.
- Always include parent ID as routing when indexing child documents; omitting it breaks queries and creates silent failures.
- Consider hybrid approaches (denormalization plus separate child indices) for workloads with both high read and high independent write volume.






Comments
Loading comments…