Friday, December 5, 2025

Software engineering flipped on its head.

Evolve your thinking into its optimal form: the sloth.

Home OpenSearch/ElasticsearchOpenSearch in JavaOpenSearch Nested Field Type Java

OpenSearch Nested Field Type Java

by Trent
0 comments
opensearch nested field data type

OpenSearch Nested Field Type in Java: A Practical Guide

Are you struggling to search arrays of objects where you need to match multiple properties from the same object? Perhaps you’ve tried flattened fields and discovered they can’t reliably distinguish between values from different objects in an array? Don’t stress! The OpenSearch Nested Field Type Java implementation (OS nested, ES nested) is OpenSearch’s solution for preserving object boundaries within arrays, and we’ll explore how to use it effectively with Java in today’s blog post.

This article continues from our previous post on OpenSearch Flattened Field Type in Java, where we explored how flattened fields prevent mapping explosion and work great for single objects. However, we discovered that flattened fields lose object boundaries in arrays, making it impossible to reliably match multiple properties from the same array object. This article picks up where that left off by introducing nested fields as the solution for collection searching scenarios where object boundaries matter.

This blog post demonstrates the nested field type (OS nested, ES nested) using code examples from the Code Sloth Code Samples repository. You can find the complete samples in GitHub via our Code Sloth Code Samples page.

Note: other than some small syntax differences, the OpenSearch and Elasticsearch implementations are very similar. This article will refer to OpenSearch but link to both implementations, so feel free to adapt to Elasticsearch if that is your Engine of choice!

Let’s dive in! 🦥

What’s to Come

In this post, we’ll explore:

  • What is the Nested Field Type? – Understanding how nested fields preserve object boundaries
  • Why Use Nested Fields? (Collection Searching Advantages) – The problems nested fields solve, especially for arrays
  • The Basics: Simple Nested Field Mapping – Creating your first nested field mapping
  • Searching on Nested Fields – How to search on nested field properties using nested queries
  • Matching Multiple Properties from the Same Nested Object – Combining search conditions within a single object
  • The Key Advantage: Matching Multiple Properties from the Same Array Object – Where nested fields truly shine
  • Multiple Nested Fields in a Single Document – Working with multiple nested fields
  • Nested Nested Fields: Multi-Level Structures – Handling deeply nested structures
  • Nested vs. Join: Understanding the Trade-offs – When to choose nested fields versus join fields for relationships
  • Nested vs. Flattened: When to Use Each – Choosing the right approach for your use case
  • Sloth Summary – Key takeaways and recommendations

What is the Nested Field Type?

The nested field type (OS nested, ES nested) in OpenSearch is a specialized mapping type that preserves object boundaries within arrays. Unlike the default object type which flattens arrays and loses relationships between properties in the same object, nested fields index each object in an array as a separate hidden document. This allows you to query multiple properties from the same array object reliably—something that flattened fields cannot do.

From the OpenSearch documentation, nested fields are particularly useful when:

  • You have arrays of objects where object boundaries matter
  • You need to match multiple properties from the same array object
  • You want to maintain relationships between properties within array elements

Understanding the Four Object Types: Default Object, Flattened, Nested, and Join

Before we dive deeper, let’s clarify the four different ways OpenSearch can handle objects and arrays—this will help you understand why nested fields exist and when to use them:

1. Default object Type (Automatic/Implicit)

When you index a document with nested structures (objects or arrays of objects), OpenSearch automatically uses the default object type unless you specify otherwise. This is the implicit, automatic behavior—you don’t need to define it in your mapping.

// Document with array of objects - no explicit mapping needed
{
  "attributes": [
    {"color": "red", "size": "large"},
    {"color": "blue", "size": "small"}
  ]
}

With the default object type:

  • Each property within objects gets its own mapping entry (can cause mapping explosion)
  • Arrays are flattened—all values lose their object boundaries
  • Properties from different array objects are treated equally

2. flat_object/flattened Type (Explicit)

This is an explicit field type you set in your mapping to prevent mapping explosion:

{
  "mappings": {
    "properties": {
      "attributes": {
        "type": "flat_object"  // OpenSearch
        // or "type": "flattened" for Elasticsearch
      }
    }
  }
}

With flat_object/flattened type:

  • Prevents mapping explosion by treating the entire object as a single field
  • All sub-properties are indexed as keywords under one field name
  • Still loses object boundaries in arrays (same limitation as default object type)
  • Cannot reliably match multiple properties from the same array object

3. nested Type (Explicit)

This is an explicit field type that preserves object boundaries:

{
  "mappings": {
    "properties": {
      "attributes": {
        "type": "nested"
      }
    }
  }
}

With nested type:

  • Each array object is indexed as a separate hidden document
  • Preserves object boundaries—properties maintain their relationships
  • Can reliably match multiple properties from the same array object
  • Requires nested queries (more complex syntax than flattened)
  • Entire parent document must be reindexed when updating content

4. join Type (Explicit)

This is an explicit field type that establishes parent-child relationships between separate documents:

{
  "mappings": {
    "properties": {
      "product_to_attribute": {
        "type": "join",
        "relations": {
          "product": "attribute"
        }
      }
    }
  }
}

With join type:

  • Parent and child are separate, independent documents in the same index
  • Linked via a join field that establishes relationships
  • Preserves object boundaries—can match multiple properties from the same child document
  • Can update individual child documents without reindexing parent (huge advantage for large collections)
  • Requires has_child or has_parent queries (more complex syntax)
  • Parent and child documents must be on the same shard (routing requirement)

The Key Differences Summary:

Feature Default object flat_object/flattened nested join
Mapping explosion risk ✅ Yes (each property gets mapping) ❌ No (single field) ❌ No (single field) ❌ No (separate documents)
Object boundaries in arrays ❌ Lost ❌ Lost ✅ Preserved ✅ Preserved
Match multiple properties from same object ❌ No ❌ No ✅ Yes ✅ Yes
Explicit mapping required ❌ No (automatic) ✅ Yes ✅ Yes ✅ Yes
Query complexity Simple Simple (dotted notation) More complex (nested query) More complex (has_child/has_parent)
Update behavior Reindex entire document Reindex entire document Reindex entire document (all nested objects) Update individual documents
Storage Within parent document Within parent document Within parent document (hidden docs) Separate documents

Both the default object and flat_object/flattened types share the same limitation: they lose object boundaries in arrays. That’s why nested fields exist—to solve this specific problem. Join fields also preserve object boundaries and offer the added benefit of independent document updates, making them ideal for large collections that update frequently.

Why Use Nested Fields? (Collection Searching Advantages)

The biggest reason to use nested fields? They preserve object boundaries in arrays. When you use either the default object type or the flat_object/flattened type in OpenSearch with an array of objects, the array gets flattened and object boundaries are lost. This means properties from different objects in the array are treated equally—there’s no way to distinguish that color="red" and size="large" came from the same array object versus different objects.

If you’re searching for products where an attribute has both color="red" AND size="large" in the same object, both the default object type and flattened type will match documents where “red” exists anywhere in the array and “large” exists anywhere in the array—even if they’re from different objects! This is the core problem nested fields solve.

The key difference: Nested fields preserve object boundaries by indexing each array object as a separate hidden document, allowing queries to match multiple properties from the same array object accurately. This is where nested fields shine—especially compared to both default object types and flattened arrays, which share the same limitation of losing object boundaries.

Nested fields (OS nested, ES nested) solve this by treating each object in the array as an independent entity, preserving relationships between properties within the same object. This makes nested fields perfect for collection searching scenarios where object boundaries matter. A wise Code Sloth chooses the right tool for the job! 🦥

Let’s see how this works in practice.

The Basics: Simple Nested Field Mapping

Let’s start with a simple example. We’ll use the ProductWithNestedAttribute class from our test suite to demonstrate how to create a nested mapping and index documents with object attributes.

First, let’s look at the ProductWithNestedAttribute document structure:

public class ProductWithNestedAttribute implements IDocumentWithId {
    private String id;
    private String name;
    private ProductAttribute attribute; // This will be mapped as a nested field
    
    // Constructors, getters, setters...
}

This document has three fields:

  • id: The product identifier
  • name: The product name
  • attribute: An aggregated object containing product attributes (this will be mapped as a nested field)

Now let’s look at the ProductAttribute record structure that represents the nested object:

public record ProductAttribute(
        String color,
        String size
) {
}

This record describes the color and size of a product. When nested inside ProductWithNestedAttribute, the attribute field will be mapped as a nested field type.

Now let’s see how we can use this to define the nested mapping in NestedIndexingTests.java:

@Test
public void nestedMapping_IndexesObjectWithSubProperties() throws Exception {
    // Create a test index with nested mapping for the attribute field
    try (OpenSearchTestIndex testIndex = fixture.createTestIndex(mapping ->
            mapping.properties("attribute", Property.of(p -> p.nested(n -> n
                    .properties("color", Property.of(prop -> prop.keyword(k -> k)))
                    .properties("size", Property.of(prop -> prop.keyword(k -> k)))
            ))))) {

        // Create and index a product document with a strongly-typed record
        // ProductAttribute: (color, size)
        ProductWithNestedAttribute productDocument = new ProductWithNestedAttribute(
                "1", "Product1", new ProductAttribute("red", "large"));
        testIndex.indexDocuments(new ProductWithNestedAttribute[]{productDocument});

        // Retrieve the document
        GetResponse<ProductWithNestedAttribute> result = loggingOpenSearchClient.get(
                g -> g.index(testIndex.getName()).id(productDocument.getId()),
                ProductWithNestedAttribute.class
        );

        // Verify the results
        assertThat(result.found()).isTrue();
        assertThat(result.source()).isNotNull();
        assertThat(result.source().getName()).isEqualTo("Product1");
        
        // Verify attribute was stored (it will be deserialized as a ProductAttribute record)
        ProductAttribute storedAttribute = result.source().getAttribute();
        assertThat(storedAttribute).isNotNull();
        assertThat(storedAttribute.color()).isEqualTo("red");
        assertThat(storedAttribute.size()).isEqualTo("large");
    }
}

The key part here is the mapping definition: mapping.properties("attribute", Property.of(p -> p.nested(n -> n.properties("color", Property.of(prop -> prop.keyword(k -> k))).properties("size", Property.of(prop -> prop.keyword(k -> k)))))). This tells OpenSearch to treat the attribute field as a nested object with explicitly defined sub-properties (color and size as keywords), which means the object and its properties will be indexed in a way that preserves object boundaries. Simple!

HTTP Commands for OpenSearch Dashboards

Here’s the index creation request that sets up the nested field mapping:

PUT /nested_demo_basic_indexing
{
  "mappings": {
    "properties": {
      "attribute": {
        "type": "nested",
        "properties": {
          "color": {
            "type": "keyword"
          },
          "size": {
            "type": "keyword"
          }
        }
      }
    }
  },
  "settings": {
    "number_of_replicas": 0,
    "number_of_shards": 1
  }
}

Index Document Request:

POST /nested_demo_basic_indexing/_bulk
{ "index" : { "_index" : "nested_demo_basic_indexing", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "attribute" : { "color" : "red", "size" : "large" } }

GET Request: Retrieving document by ID

GET /nested_demo_basic_indexing/_doc/1

GET Response:

{
  "_index": "nested_demo_basic_indexing",
  "_id": "1",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "id": "1",
    "name": "Product1",
    "attribute": {
      "color": "red",
      "size": "large"
    }
  }
}

This test uses a GET request to retrieve the document by ID (not a search query). The document is retrieved directly and the nested attribute structure is preserved when deserialized back into the ProductAttribute record.

What the Mapping Looks Like in OpenSearch

To retrieve the index mapping, use the following GET request:

GET /nested_demo_basic_indexing/_mapping

GET Mapping Response:

When you query the index mapping in OpenSearch Dashboards, you’ll see the nested field mapped as nested with its properties:

{
  "nested_demo_basic_indexing": {
    "mappings": {
      "properties": {
        "attribute": {
          "type": "nested",
          "properties": {
            "color": {
              "type": "keyword"
            },
            "size": {
              "type": "keyword"
            }
          }
        },
        "id": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        },
        "name": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        }
      }
    }
  }
}

Note: Because we have dynamic mappings enabled (the default behavior), the other fields in the document (id and name) have automatically appeared in the mapping response. We’ve purposefully kept the mapping command simple to focus on the primary content of the post—the nested field type. In production, you might want to explicitly define these mappings or use strict mapping to prevent automatic field creation.

Notice how the attribute field is defined as "type": "nested" with its properties explicitly defined (color and size as keywords). This tells OpenSearch to treat the attribute field as a nested object with these specific sub-properties, preserving the entire object structure and relationships. Unlike flattened fields which index sub-properties as keywords, nested fields preserve object boundaries.

Nested Objects Count Towards Document Count

One important detail to understand about nested fields is that each nested object is indexed as a separate hidden Lucene document. This means nested objects contribute to the index’s total document count, which can have implications for storage, performance monitoring, and indexing costs.

Understanding the Document Count

When you index a document with nested fields:

  • Each nested object in an array becomes a separate hidden document
  • Even a single nested object (not in an array) counts as a separate document
  • The parent document also counts as a document

For example, if you index one product with an array of 3 nested attributes, you’re actually creating 4 Lucene documents:

  • 1 parent document (the product)
  • 3 nested documents (one for each attribute)

This is important to understand because:

  1. Storage considerations: More nested objects = more Lucene documents = more storage
  2. Indexing performance: Each nested document must be indexed separately
  3. Monitoring: The document count in statistics reflects the actual Lucene document count

Demonstrating with Code

Let’s see this in action with a test that shows the document count:

@Test
public void nestedMapping_NestedObjectsCountTowardsDocumentCount() throws Exception {
    // Create a test index with nested mapping for the attributes array field
    try (OpenSearchTestIndex testIndex = fixture.createTestIndex(mapping ->
            mapping.properties("attributes", Property.of(p -> p.nested(n -> n
                    .properties("color", Property.of(prop -> prop.keyword(k -> k)))
                    .properties("size", Property.of(prop -> prop.keyword(k -> k)))
            ))))) {

        // Create a document with an array of 3 nested attributes
        List<ProductAttribute> attributes = new ArrayList<>();
        attributes.add(new ProductAttribute("red", "large"));
        attributes.add(new ProductAttribute("blue", "medium"));
        attributes.add(new ProductAttribute("green", "small"));

        ProductWithNestedArray product = new ProductWithNestedArray("1", "Product1", attributes);
        testIndex.indexDocuments(new ProductWithNestedArray[]{product});

        // Get the document count using the count API
        // Note: The count API only counts top-level documents, not nested documents
        CountResponse countResponse = loggingOpenSearchClient.count(c -> c.index(testIndex.getName()));

        // The count API returns only 1 (the top-level document)
        // However, the actual Lucene document count is 4:
        // - 1 parent document (the product itself)
        // - 3 nested documents (one for each attribute in the array)
        // This demonstrates that nested objects are indexed as separate hidden documents
        assertThat(countResponse.count()).isEqualTo(1L); // Only top-level documents

        // Get the actual Lucene document count using the stats API
        // The stats API shows the true document count including nested documents
        // The stats method on the logging client automatically logs the request and response
        @SuppressWarnings("unused")
        var statsResponse = loggingOpenSearchClient.stats(testIndex.getName());
        
        // The stats API response contains the actual Lucene document count
        // For this test, we expect 4 documents (1 parent + 3 nested)
        // The response is captured for documentation purposes
        // Access the document count: statsResponse.indices().get(testIndex.getName()).total().docs().count()

        // Verify that we still only retrieve 1 document when getting by ID
        GetResponse<ProductWithNestedArray> retrieved = loggingOpenSearchClient.get(
            g -> g.index(testIndex.getName()).id("1"),
            ProductWithNestedArray.class
        );
        assertThat(retrieved.found()).isTrue();
        assertThat(retrieved.source().getAttributes()).hasSize(3);

    }
}

Important Note: The count API only counts top-level documents. To see the actual Lucene document count (including nested documents), you need to use the stats API. The test above includes a call to the stats API to demonstrate this.

HTTP Commands for OpenSearch Dashboards

Let’s demonstrate this with actual HTTP commands. First, create an index and index a document with nested objects:

Create Index:

PUT /nested_demo_document_count
{
  "mappings": {
    "properties": {
      "attributes": {
        "type": "nested",
        "properties": {
          "color": {
            "type": "keyword"
          },
          "size": {
            "type": "keyword"
          }
        }
      }
    }
  },
  "settings": {
    "number_of_replicas": 0,
    "number_of_shards": 1
  }
}

Index Document with 3 Nested Attributes:

POST /nested_demo_document_count/_bulk
{ "index" : { "_index" : "nested_demo_document_count", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "attributes" : [
  { "color" : "red", "size" : "large" },
  { "color" : "blue", "size" : "medium" },
  { "color" : "green", "size" : "small" }
] }

Count API (Top-Level Documents Only):

GET /nested_demo_document_count/_count

Count API Response:

{
  "count": 1,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  }
}

The count API returns 1 because it only counts top-level documents. However, to see the actual Lucene document count, use the stats API:

Stats API (Actual Lucene Document Count):

GET /nested_demo_document_count/_stats?pretty

Stats API Response (relevant portion):

{
  "_all": {
    "total": {
      "docs": {
        "count": 4,
        "deleted": 0
      }
    }
  },
  "indices": {
    "nested_demo_document_count": {
      "total": {
        "docs": {
          "count": 4,
          "deleted": 0
        }
      }
    }
  }
}

The stats API shows the actual Lucene document count: 4 documents (1 parent + 3 nested). This demonstrates that each nested object is indeed indexed as a separate hidden document.

Single Nested Objects Also Count

Even when you have a single nested object (not an array), it still counts as a separate document:

Index Document with Single Nested Object:

POST /nested_demo_single_nested/_bulk
{ "index" : { "_index" : "nested_demo_single_nested", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "attribute" : { "color" : "red", "size" : "large" } }

Stats API Response:

{
  "indices": {
    "nested_demo_single_nested": {
      "total": {
        "docs": {
          "count": 2,
          "deleted": 0
        }
      }
    }
  }
}

Even with a single nested object, you get 2 documents (1 parent + 1 nested).

Why This Matters

Understanding that nested objects count towards document count is important for:

  • Capacity planning: Large arrays of nested objects significantly increase document count
  • Performance monitoring: More documents means more indexing work
  • Storage costs: Each nested document takes up storage space
  • Query performance: More documents can impact search performance

For example, if you have 100 products, each with 10 nested attributes, you’re creating 1,100 documents (100 parents + 1,000 nested), not just 100! This is why the nested vs. join trade-off we discussed earlier matters so much—with large collections, the document count can grow quickly.

Searching on Nested Fields

Once you’ve indexed documents with nested fields, searching requires a special query wrapper: the nested query. This is different from flattened fields where you can use direct dotted notation—nested fields require you to wrap your query in a nested query that specifies the path parameter.

The nested query supports any query type inside it:

  • Term
  • Terms
  • Bool (for combining multiple conditions)
  • Range
  • Match
  • And many more…

Let’s break down how we search on nested fields using examples from NestedSearchingTests.java:

@Test
public void nestedMapping_SingleField_CanSearchByDottedNotation() throws Exception {
    // Create a test index with nested mapping for the attribute field
    try (OpenSearchTestIndex testIndex = fixture.createTestIndex(mapping ->
            mapping.properties("attribute", Property.of(p -> p.nested(n -> n
                    .properties("color", Property.of(prop -> prop.keyword(k -> k)))
                    .properties("size", Property.of(prop -> prop.keyword(k -> k)))
            ))))) {

        // Create documents with attributes containing multiple properties
        ProductWithNestedAttribute[] products = new ProductWithNestedAttribute[]{
                new ProductWithNestedAttribute("1", "Product1", new ProductAttribute("red", "large")),
                new ProductWithNestedAttribute("2", "Product2", new ProductAttribute("blue", "medium")),
                new ProductWithNestedAttribute("3", "Product3", new ProductAttribute("red", "small"))
        };
        testIndex.indexDocuments(products);

        // Search by attribute.color using nested query
        SearchResponse<ProductWithNestedAttribute> result = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("attribute")
                                        .query(q2 -> q2
                                                .term(t -> t
                                                        .field("attribute.color")
                                                        .value(FieldValue.of("red"))
                                                )
                                        )
                                )
                        ),
                ProductWithNestedAttribute.class
        );

        // With nested type, queries work correctly
        assertThat(result.hits().total().value()).isEqualTo(2);
        assertThat(result.hits().hits().stream()
                .map(h -> h.source().getId())
                .sorted())
                .containsExactly("1", "3"); // Product1 and Product3 have red color

        // Search by attribute.size using nested query
        SearchResponse<ProductWithNestedAttribute> sizeResult = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("attribute")
                                        .query(q2 -> q2
                                                .term(t -> t
                                                        .field("attribute.size")
                                                        .value(FieldValue.of("large"))
                                                )
                                        )
                                )
                        ),
                ProductWithNestedAttribute.class
        );

        assertThat(sizeResult.hits().total().value()).isEqualTo(1);
        assertThat(sizeResult.hits().hits().stream()
                .map(h -> h.source().getId())
                .sorted())
                .containsExactly("1"); // Only Product1 has large size

    }
}

See the difference? Unlike flattened fields where you can use direct dotted notation queries, nested fields require you to wrap your query in a nested query with a path parameter. The path tells OpenSearch which nested field you’re querying, and the inner query specifies what conditions to match within that nested structure.

Important: Even though you specify the path in the nested query wrapper, you still need to include the full path in the field names within your inner query—notice how we use attribute.color and attribute.size in the term queries, not just color or size. This full path notation is required for nested field queries.

HTTP Commands for OpenSearch Dashboards

Here’s the index creation request:

PUT /nested_demo_basic_search
{
  "mappings": {
    "properties": {
      "attribute": {
        "type": "nested",
        "properties": {
          "color": {
            "type": "keyword"
          },
          "size": {
            "type": "keyword"
          }
        }
      }
    }
  },
  "settings": {
    "number_of_replicas": 0,
    "number_of_shards": 1
  }
}

Index Documents Request:

POST /nested_demo_basic_search/_bulk
{ "index" : { "_index" : "nested_demo_basic_search", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "attribute" : { "color" : "red", "size" : "large" } }
{ "index" : { "_index" : "nested_demo_basic_search", "_id" : "2" } }
{ "id" : "2", "name" : "Product2", "attribute" : { "color" : "blue", "size" : "medium" } }
{ "index" : { "_index" : "nested_demo_basic_search", "_id" : "3" } }
{ "id" : "3", "name" : "Product3", "attribute" : { "color" : "red", "size" : "small" } }

Search Request 1: Finding products by color using nested query

POST nested_demo_basic_search/_search
{
  "query": {
    "nested": {
      "path": "attribute",
      "query": {
        "term": {
          "attribute.color": {
            "value": "red"
          }
        }
      }
    }
  }
}

Search Response 1:

{
  "took": 21,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_basic_search",
        "_score": 0.4700036,
        "_source": {
          "id": "1",
          "name": "Product1",
          "attribute": {
            "color": "red",
            "size": "large"
          }
        }
      },
      {
        "_id": "3",
        "_index": "nested_demo_basic_search",
        "_score": 0.4700036,
        "_source": {
          "id": "3",
          "name": "Product3",
          "attribute": {
            "color": "red",
            "size": "small"
          }
        }
      }
    ],
    "max_score": 0.470003604888916,
    "total": {
      "relation": "eq",
      "value": 2
    }
  }
}

Search Request 2: Finding products by size

POST nested_demo_basic_search/_search
{
  "query": {
    "nested": {
      "path": "attribute",
      "query": {
        "term": {
          "attribute.size": {
            "value": "large"
          }
        }
      }
    }
  }
}

Search Response 2:

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_basic_search",
        "_score": 0.9808291,
        "_source": {
          "id": "1",
          "name": "Product1",
          "attribute": {
            "color": "red",
            "size": "large"
          }
        }
      }
    ],
    "max_score": 0.980829119682312,
    "total": {
      "relation": "eq",
      "value": 1
    }
  }
}

Notice the nested query structure: you wrap your term query inside a nested query that specifies the path. This tells OpenSearch to search within the nested structure, preserving object boundaries.

Matching Multiple Properties from the Same Nested Object

You can match multiple properties from within a single nested object (when it’s not in an array). You simply combine multiple conditions using a bool query inside the nested query wrapper.

Let’s look at a test that demonstrates matching multiple properties from the same nested field, using the nestedMapping_SingleField_CanMatchMultiplePropertiesFromSameObject test:

@Test
public void nestedMapping_SingleField_CanMatchMultiplePropertiesFromSameObject() throws Exception {
    // Create a test index with nested mapping for the attribute field
    try (OpenSearchTestIndex testIndex = fixture.createTestIndex(mapping ->
            mapping.properties("attribute", Property.of(p -> p.nested(n -> n
                    .properties("color", Property.of(prop -> prop.keyword(k -> k)))
                    .properties("size", Property.of(prop -> prop.keyword(k -> k)))
            ))))) {

        // Create documents with attributes containing multiple properties
        ProductWithNestedAttribute[] products = new ProductWithNestedAttribute[]{
                new ProductWithNestedAttribute("1", "Product1", new ProductAttribute("red", "large")),
                new ProductWithNestedAttribute("2", "Product2", new ProductAttribute("blue", "medium")),
                new ProductWithNestedAttribute("3", "Product3", new ProductAttribute("red", "small")),
                new ProductWithNestedAttribute("4", "Product4", new ProductAttribute("red", "large"))
        };
        testIndex.indexDocuments(products);

        // Search for products where attribute.color="red" AND attribute.size="large"
        // Using nested query with bool query inside
        SearchResponse<ProductWithNestedAttribute> result = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("attribute")
                                        .query(q2 -> q2
                                                .bool(b -> b
                                                        .must(m -> m
                                                                .term(t -> t
                                                                        .field("attribute.color")
                                                                        .value(FieldValue.of("red"))
                                                                )
                                                        )
                                                        .must(m -> m
                                                                .term(t -> t
                                                                        .field("attribute.size")
                                                                        .value(FieldValue.of("large"))
                                                                )
                                                        )
                                                )
                                        )
                                )
                        ),
                ProductWithNestedAttribute.class
        );

        // With nested type, matching multiple properties from the same object works correctly
        assertThat(result.hits().total().value()).isEqualTo(2);
        assertThat(result.hits().hits().stream()
                .map(h -> h.source().getId())
                .sorted())
                .containsExactly("1", "4"); // Product1 and Product4 match: red AND large

    }
}

This test demonstrates that when you have a single nested object (not an array), you can successfully search for multiple properties within that same object using boolean queries inside the nested query wrapper. Pretty powerful, right? The query for attribute.color="red" AND attribute.size="large" correctly finds documents where both conditions are true within the same nested object.

HTTP Commands for OpenSearch Dashboards

Here’s the index creation request:

PUT /nested_demo_multiple_properties
{
  "mappings": {
    "properties": {
      "attribute": {
        "type": "nested",
        "properties": {
          "color": {
            "type": "keyword"
          },
          "size": {
            "type": "keyword"
          }
        }
      }
    }
  },
  "settings": {
    "number_of_replicas": 0,
    "number_of_shards": 1
  }
}

Index Documents Request:

POST /nested_demo_multiple_properties/_bulk
{ "index" : { "_index" : "nested_demo_multiple_properties", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "attribute" : { "color" : "red", "size" : "large" } }
{ "index" : { "_index" : "nested_demo_multiple_properties", "_id" : "2" } }
{ "id" : "2", "name" : "Product2", "attribute" : { "color" : "blue", "size" : "medium" } }
{ "index" : { "_index" : "nested_demo_multiple_properties", "_id" : "3" } }
{ "id" : "3", "name" : "Product3", "attribute" : { "color" : "red", "size" : "small" } }
{ "index" : { "_index" : "nested_demo_multiple_properties", "_id" : "4" } }
{ "id" : "4", "name" : "Product4", "attribute" : { "color" : "red", "size" : "large" } }

Search Request: Matching multiple properties (color=“red” AND size=“large”)

POST nested_demo_multiple_properties/_search
{
  "query": {
    "nested": {
      "path": "attribute",
      "query": {
        "bool": {
          "must": [
            {
              "term": {
                "attribute.color": {
                  "value": "red"
                }
              }
            },
            {
              "term": {
                "attribute.size": {
                  "value": "large"
                }
              }
            }
          ]
        }
      }
    }
  }
}

Search Response:

{
  "took": 20,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_multiple_properties",
        "_score": 1.0498221,
        "_source": {
          "id": "1",
          "name": "Product1",
          "attribute": {
            "color": "red",
            "size": "large"
          }
        }
      },
      {
        "_id": "4",
        "_index": "nested_demo_multiple_properties",
        "_score": 1.0498221,
        "_source": {
          "id": "4",
          "name": "Product4",
          "attribute": {
            "color": "red",
            "size": "large"
          }
        }
      }
    ],
    "max_score": 1.0498220920562744,
    "total": {
      "relation": "eq",
      "value": 2
    }
  }
}

The Key Advantage: Matching Multiple Properties from the Same Array Object

Alright, now we need to talk about the key advantage of nested fields. When you have an array of objects mapped as a nested field, you can reliably match multiple values from the same object in that array. This is where nested fields truly shine—flattened arrays cannot do this reliably because they lose object boundaries.

Let’s look at the data structures we’ll be working with. The ProductWithNestedArray document class contains an array of attributes:

public class ProductWithNestedArray implements IDocumentWithId {
    private String id;
    private String name;
    private List<ProductAttribute> attributes; // Array of strongly-typed records
    
    // ... constructor and getters/setters
}

The ProductAttribute record structure used in the array is straightforward:

public record ProductAttribute(
        String color,
        String size
) {
}

When you index a document with this structure as a nested field, OpenSearch stores each object in the array as a separate hidden document, preserving object boundaries. This means that values from the same object maintain their relationship—unlike flattened arrays where all values are treated as a flat collection.

Now, here’s where the behavior becomes powerful. Let’s break down the test that demonstrates how nested arrays handle matching values from the same object from NestedSearchingTests.java:

@Test
public void nestedMapping_ArrayOfObjects_CanMatchMultipleValuesFromSameObject() throws Exception {
    // Create a test index with nested mapping for the attributes array field
    try (OpenSearchTestIndex testIndex = fixture.createTestIndex(mapping ->
            mapping.properties("attributes", Property.of(p -> p.nested(n -> n
                    .properties("color", Property.of(prop -> prop.keyword(k -> k)))
                    .properties("size", Property.of(prop -> prop.keyword(k -> k)))
            ))))) {

        // Create a document with an array of attributes where each object has multiple properties
        List<ProductAttribute> attributes = new ArrayList<>();
        attributes.add(new ProductAttribute("red", "large"));  // Object 1: red and large
        attributes.add(new ProductAttribute("blue", "small"));   // Object 2: blue and small

        ProductWithNestedArray[] products = new ProductWithNestedArray[]{
                new ProductWithNestedArray("1", "Product1", attributes)
        };
        testIndex.indexDocuments(products);

        // **KEY TEST**: Search for color="red" AND size="large" from the SAME object
        // This WILL work with nested fields because they preserve object boundaries
        SearchResponse<ProductWithNestedArray> sameObjectResult = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("attributes")
                                        .query(q2 -> q2
                                                .bool(b -> b
                                                        .must(m -> m
                                                                .term(t -> t
                                                                        .field("attributes.color")
                                                                        .value(FieldValue.of("red"))
                                                                )
                                                        )
                                                        .must(m -> m
                                                                .term(t -> t
                                                                        .field("attributes.size")
                                                                        .value(FieldValue.of("large"))
                                                                )
                                                        )
                                                )
                                        )
                                )
                        ),
                ProductWithNestedArray.class
        );

        // This SHOULD match because both "red" and "large" exist in the SAME array object
        // This is the key advantage of nested fields over flattened fields
        assertThat(sameObjectResult.hits().total().value()).isEqualTo(1);
        assertThat(sameObjectResult.hits().hits().get(0).source().getId()).isEqualTo("1");

        // Now test the OPPOSITE: Search for color="red" AND size="small" from DIFFERENT objects
        // This should NOT match because nested fields preserve object boundaries
        SearchResponse<ProductWithNestedArray> differentObjectsResult = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("attributes")
                                        .query(q2 -> q2
                                                .bool(b -> b
                                                        .must(m -> m
                                                                .term(t -> t
                                                                        .field("attributes.color")
                                                                        .value(FieldValue.of("red"))
                                                                )
                                                        )
                                                        .must(m -> m
                                                                .term(t -> t
                                                                        .field("attributes.size")
                                                                        .value(FieldValue.of("small"))
                                                                )
                                                        )
                                                )
                                        )
                                )
                        ),
                ProductWithNestedArray.class
        );

        // This should NOT match because "red" and "small" are in different objects
        assertThat(differentObjectsResult.hits().total().value()).isEqualTo(0);
        assertThat(differentObjectsResult.hits().hits()).isEmpty();

        // Verify that individual searches also work
        SearchResponse<ProductWithNestedArray> colorResult = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("attributes")
                                        .query(q2 -> q2
                                                .term(t -> t
                                                        .field("attributes.color")
                                                        .value(FieldValue.of("red"))
                                                )
                                        )
                                )
                        ),
                ProductWithNestedArray.class
        );

        assertThat(colorResult.hits().total().value()).isEqualTo(1);
        
        // This demonstrates that nested arrays preserve object boundaries:
        // - Matching values from the SAME object works (as shown above)
        // - Matching values from DIFFERENT objects correctly fails
        // This is the KEY ADVANTAGE over flattened arrays

    }
}

Here’s the key insight: when you search for attributes.color="red" AND attributes.size="large", the query will match because both values exist in the same array object. However, when you search for attributes.color="red" AND attributes.size="small", the query will NOT match because “red” comes from the first object and “small” comes from the second object—they’re in different objects!

This demonstrates that nested fields preserve object boundaries within arrays. This is the fundamental difference from flattened arrays, which treat all values as a flat collection and cannot distinguish between values from the same object versus different objects.

The power of nested fields: Nested arrays can reliably match multiple values from a single object in the array because they preserve object boundaries. This is exactly what you need for collection searching scenarios. If you need that kind of precision (ensuring both “red” and “large” come from the exact same array object), nested fields are your solution!

HTTP Commands for OpenSearch Dashboards

Here’s the index creation request:

PUT /nested_demo_array
{
  "mappings": {
    "properties": {
      "attributes": {
        "type": "nested",
        "properties": {
          "color": {
            "type": "keyword"
          },
          "size": {
            "type": "keyword"
          }
        }
      }
    }
  },
  "settings": {
    "number_of_replicas": 0,
    "number_of_shards": 1
  }
}

Index Documents Request:

POST /nested_demo_array/_bulk
{ "index" : { "_index" : "nested_demo_array", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "attributes" : [ { "color" : "red", "size" : "large" }, { "color" : "blue", "size" : "small" } ] }

Search Request 1: Matching values from the SAME object (color=“red” AND size=“large”) – THIS WORKS!

POST nested_demo_array/_search
{
  "query": {
    "nested": {
      "path": "attributes",
      "query": {
        "bool": {
          "must": [
            {
              "term": {
                "attributes.color": {
                  "value": "red"
                }
              }
            },
            {
              "term": {
                "attributes.size": {
                  "value": "large"
                }
              }
            }
          ]
        }
      }
    }
  }
}

Search Response 1 (matches):

{
  "took": 18,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_array",
        "_score": 1.3862942,
        "_source": {
          "id": "1",
          "name": "Product1",
          "attributes": [
            {
              "color": "red",
              "size": "large"
            },
            {
              "color": "blue",
              "size": "small"
            }
          ]
        }
      }
    ],
    "max_score": 1.3862942457199097,
    "total": {
      "relation": "eq",
      "value": 1
    }
  }
}

Search Request 2: Matching values from DIFFERENT objects (color=“red” AND size=“small”) – This correctly does NOT match

POST nested_demo_array/_search
{
  "query": {
    "nested": {
      "path": "attributes",
      "query": {
        "bool": {
          "must": [
            {
              "term": {
                "attributes.color": {
                  "value": "red"
                }
              }
            },
            {
              "term": {
                "attributes.size": {
                  "value": "small"
                }
              }
            }
          ]
        }
      }
    }
  }
}

Search Response 2 (no matches – correctly preserves object boundaries):

{
  "took": 6,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [],
    "total": {
      "relation": "eq",
      "value": 0
    }
  }
}

Search Request 3: Individual search (attributes.color=“red”)

POST nested_demo_array/_search
{
  "query": {
    "nested": {
      "path": "attributes",
      "query": {
        "term": {
          "attributes.color": {
            "value": "red"
          }
        }
      }
    }
  }
}

Search Response 3:

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_array",
        "_score": 0.6931471,
        "_source": {
          "id": "1",
          "name": "Product1",
          "attributes": [
            {
              "color": "red",
              "size": "large"
            },
            {
              "color": "blue",
              "size": "small"
            }
          ]
        }
      }
    ],
    "max_score": 0.6931471228599548,
    "total": {
      "relation": "eq",
      "value": 1
    }
  }
}

This demonstrates that nested arrays preserve object boundaries:

  • Matching values from the SAME object works (as shown in Search Request 1)
  • Matching values from DIFFERENT objects correctly fails (as shown in Search Request 2)
  • This is the KEY ADVANTAGE over flattened arrays, which cannot distinguish between same-object and different-object matches

Multiple Nested Fields in a Single Document

Good news: you’re not limited to a single nested field per document! You can have multiple nested fields, and you can search across them independently. Each nested field maintains its own object boundaries. Let’s see how this works with the ProductWithTwoNestedAttributes class:

public class ProductWithTwoNestedAttributes implements IDocumentWithId {
    private String id;
    private String name;
    private ProductAttribute primaryAttribute; // Nested field for primary attribute information
    private ProductSpecification secondaryAttribute; // Nested field for secondary attribute information (different DTO type)
    
    // ... constructor and getters/setters
}

Notice that each nested field can use any DTO type you want—they can be the same type or different types. In this example, primaryAttribute uses ProductAttribute:

public record ProductAttribute(
        String color,
        String size
) {
}

While secondaryAttribute uses ProductSpecification:

public record ProductSpecification(
        String brand,
        String category
) {
}

You can use the same DTO type for multiple nested fields, or different types—it’s entirely up to you! Each nested field can have its own structure with whatever properties you need.

Here’s how we create an index with multiple nested fields and search on them:

@Test
public void nestedMapping_MultipleNestedFields_CanMatchFromBothFields() throws Exception {
    // Create a test index with multiple nested fields
    try (OpenSearchTestIndex testIndex = fixture.createTestIndex(mapping -> {
                mapping.properties("primaryAttribute", Property.of(p -> p.nested(n -> n
                        .properties("color", Property.of(prop -> prop.keyword(k -> k)))
                        .properties("size", Property.of(prop -> prop.keyword(k -> k)))
                )));
                mapping.properties("secondaryAttribute", Property.of(p -> p.nested(n -> n
                        .properties("brand", Property.of(prop -> prop.keyword(k -> k)))
                        .properties("category", Property.of(prop -> prop.keyword(k -> k)))
                )));
            })) {

        // Create documents with both primary and secondary attributes using different DTO types
        ProductWithTwoNestedAttributes[] products = new ProductWithTwoNestedAttributes[]{
                new ProductWithTwoNestedAttributes("1", "Product1", 
                        new ProductAttribute("red", "large"),
                        new ProductSpecification("BrandA", "Electronics")),
                new ProductWithTwoNestedAttributes("2", "Product2",
                        new ProductAttribute("red", "medium"),
                        new ProductSpecification("BrandB", "Clothing")),
                new ProductWithTwoNestedAttributes("3", "Product3",
                        new ProductAttribute("blue", "small"),
                        new ProductSpecification("BrandA", "Electronics"))
        };
        testIndex.indexDocuments(products);

        // First verify individual nested queries work correctly
        SearchResponse<ProductWithTwoNestedAttributes> primaryOnlyResult = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("primaryAttribute")
                                        .query(q2 -> q2
                                                .term(t -> t
                                                        .field("primaryAttribute.color")
                                                        .value(FieldValue.of("red"))
                                                )
                                        )
                                )
                        ),
                ProductWithTwoNestedAttributes.class
        );

        // Should match Product1 and Product2 (both have red color)
        assertThat(primaryOnlyResult.hits().total().value()).isEqualTo(2);

        // Verify secondaryAttribute query also works independently
        SearchResponse<ProductWithTwoNestedAttributes> secondaryOnlyResult = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("secondaryAttribute")
                                        .query(q2 -> q2
                                                .term(t -> t
                                                        .field("secondaryAttribute.brand")
                                                        .value(FieldValue.of("BrandA"))
                                                )
                                        )
                                )
                        ),
                ProductWithTwoNestedAttributes.class
        );

        // Should match Product1 and Product3 (both have BrandA)
        assertThat(secondaryOnlyResult.hits().total().value()).isEqualTo(2);

        // Search for products with primaryAttribute.color="red" AND secondaryAttribute.brand="BrandA"
        // Using multiple nested queries combined with bool - THIS WORKS CORRECTLY
        // 
        // Expected: Should match Product1 (has red color AND BrandA)
        // Actual: Returns 1 result (Product1) - correctly intersects both nested queries
        //
        // OpenSearch properly intersects results from multiple independent nested queries
        // targeting different paths at the parent document level.
        SearchResponse<ProductWithTwoNestedAttributes> result = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .bool(b -> b
                                        .must(m -> m
                                                .nested(n -> n
                                                        .path("primaryAttribute")
                                                        .query(q2 -> q2
                                                                .term(t -> t
                                                                        .field("primaryAttribute.color")
                                                                        .value(FieldValue.of("red"))
                                                                )
                                                        )
                                                )
                                        )
                                        .must(m -> m
                                                .nested(n -> n
                                                        .path("secondaryAttribute")
                                                        .query(q2 -> q2
                                                                .term(t -> t
                                                                        .field("secondaryAttribute.brand")
                                                                        .value(FieldValue.of("BrandA"))
                                                                )
                                                        )
                                                )
                                        )
                                )
                        ),
                ProductWithTwoNestedAttributes.class
        );

        // This compound query correctly returns 1 result (Product1):
        // - Product1 has primaryAttribute.color="red" AND secondaryAttribute.brand="BrandA" - MATCHES
        // - Product2 has red color but BrandB, so it correctly won't match
        // - Product3 has BrandA but blue color, so it correctly won't match
        //
        // OpenSearch properly intersects results from multiple independent nested queries
        // targeting different paths at the parent document level.
        assertThat(result.hits().total().value()).isEqualTo(1);
        assertThat(result.hits().hits().stream()
                .map(h -> h.source().getId())
                .sorted())
                .containsExactly("1"); // Only Product1 matches both conditions

    }
}

Combining Multiple Top-Level Nested Queries

Good news: you CAN search on multiple nested fields independently, and combining multiple top-level nested queries for different paths in a bool query works correctly!

Each nested field maintains its own object boundaries and can be queried separately using nested queries with the appropriate path. When you combine multiple nested queries targeting different paths (like primaryAttribute and secondaryAttribute) in a bool query with must, OpenSearch properly intersects the results at the parent document level.

What Works:

  • Individual nested queries on each field work correctly
  • Combining multiple independent nested queries for different sibling paths in a bool query works correctly
  • Multi-level nested queries (one nested inside another for the same path hierarchy) work correctly
  • Combining multiple conditions within the same nested path works correctly

OpenSearch correctly intersects results from multiple independent nested queries targeting different paths, allowing you to search across multiple nested fields simultaneously. This gives you the flexibility to query complex documents with multiple nested structures while maintaining object boundaries within each nested field.

HTTP Commands for OpenSearch Dashboards

Here’s the index creation request with multiple nested fields:

PUT /nested_demo_multiple_fields
{
  "mappings": {
    "properties": {
      "primaryAttribute": {
        "type": "nested",
        "properties": {
          "color": {
            "type": "keyword"
          },
          "size": {
            "type": "keyword"
          }
        }
      },
      "secondaryAttribute": {
        "type": "nested",
        "properties": {
          "brand": {
            "type": "keyword"
          },
          "category": {
            "type": "keyword"
          }
        }
      }
    }
  },
  "settings": {
    "number_of_replicas": 0,
    "number_of_shards": 1
  }
}

Index Documents Request:

POST /nested_demo_multiple_fields/_bulk
{ "index" : { "_index" : "nested_demo_multiple_fields", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "primaryAttribute" : { "color" : "red", "size" : "large" }, "secondaryAttribute" : { "brand" : "BrandA", "category" : "Electronics" } }
{ "index" : { "_index" : "nested_demo_multiple_fields", "_id" : "2" } }
{ "id" : "2", "name" : "Product2", "primaryAttribute" : { "color" : "red", "size" : "medium" }, "secondaryAttribute" : { "brand" : "BrandB", "category" : "Clothing" } }
{ "index" : { "_index" : "nested_demo_multiple_fields", "_id" : "3" } }
{ "id" : "3", "name" : "Product3", "primaryAttribute" : { "color" : "blue", "size" : "small" }, "secondaryAttribute" : { "brand" : "BrandA", "category" : "Electronics" } }

Search Request 1: Searching primaryAttribute alone

POST nested_demo_multiple_fields/_search
{
  "query": {
    "nested": {
      "path": "primaryAttribute",
      "query": {
        "term": {
          "primaryAttribute.color": {
            "value": "red"
          }
        }
      }
    }
  }
}

Search Response 1 (matches Product1 and Product2):

{
  "took": 10,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_multiple_fields",
        "_score": 0.4700036,
        "_source": {
          "id": "1",
          "name": "Product1",
          "primaryAttribute": {
            "color": "red",
            "size": "large"
          },
          "secondaryAttribute": {
            "brand": "BrandA",
            "category": "Electronics"
          }
        }
      },
      {
        "_id": "2",
        "_index": "nested_demo_multiple_fields",
        "_score": 0.4700036,
        "_source": {
          "id": "2",
          "name": "Product2",
          "primaryAttribute": {
            "color": "red",
            "size": "medium"
          },
          "secondaryAttribute": {
            "brand": "BrandB",
            "category": "Clothing"
          }
        }
      }
    ],
    "max_score": 0.470003604888916,
    "total": {
      "relation": "eq",
      "value": 2
    }
  }
}

Search Request 2: Compound query matching from BOTH nested fields (primaryAttribute.color=“red” AND secondaryAttribute.brand=“BrandA”)

POST nested_demo_multiple_fields/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "nested": {
            "path": "primaryAttribute",
            "query": {
              "term": {
                "primaryAttribute.color": {
                  "value": "red"
                }
              }
            }
          }
        },
        {
          "nested": {
            "path": "secondaryAttribute",
            "query": {
              "term": {
                "secondaryAttribute.brand": {
                  "value": "BrandA"
                }
              }
            }
          }
        }
      ]
    }
  }
}

Search Response 2 (Correctly matches Product1):

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_multiple_fields",
        "_score": 0.9400072,
        "_source": {
          "id": "1",
          "name": "Product1",
          "primaryAttribute": {
            "color": "red",
            "size": "large"
          },
          "secondaryAttribute": {
            "brand": "BrandA",
            "category": "Electronics"
          }
        }
      }
    ],
    "max_score": 0.940007209777832,
    "total": {
      "relation": "eq",
      "value": 1
    }
  }
}

Notice that Product1 correctly matches because it has both primaryAttribute.color="red" AND secondaryAttribute.brand="BrandA". OpenSearch properly intersects results from multiple independent nested queries targeting different paths at the parent document level. This demonstrates that combining multiple nested queries for different paths works as expected!

Nested Nested Fields: Multi-Level Structures

Nested fields can also contain nested complex types, requiring multiple levels of nested queries to access leaf properties. Don’t let that scare you—it’s actually quite straightforward! This is super useful when you have hierarchical data structures within nested fields.

The ProductWithNestedDetails document class demonstrates this pattern:

public class ProductWithNestedDetails implements IDocumentWithId {
    private String id;
    private String name;
    private ProductDetails details; // Nested field containing nested ProductAttribute
    
    // ... constructor and getters/setters
}

The ProductDetails record contains a nested ProductAttribute:

public record ProductDetails(
        ProductAttribute attribute, // Nested ProductAttribute (color, size)
        String description
) {
}

public record ProductAttribute(
        String color,
        String size
) {
}

Here’s how we search on nested nested fields using nested queries, from the nestedMapping_NestedNestedField_CanSearchByNestedDottedNotation test:

@Test
public void nestedMapping_NestedNestedField_CanSearchByNestedDottedNotation() throws Exception {
    // Create a test index with nested mapping for the details field
    try (OpenSearchTestIndex testIndex = fixture.createTestIndex(mapping ->
            mapping.properties("details", Property.of(p -> p.nested(n -> n
                    .properties("attribute", Property.of(attr -> attr.nested(n2 -> n2
                            .properties("color", Property.of(prop -> prop.keyword(k -> k)))
                            .properties("size", Property.of(prop -> prop.keyword(k -> k)))
                    )))
                    .properties("description", Property.of(prop -> prop.keyword(k -> k)))
            ))))) {

        // Create documents with nested attribute structures
        ProductWithNestedDetails[] products = new ProductWithNestedDetails[]{
                new ProductWithNestedDetails("1", "Product1",
                        new ProductDetails(
                                new ProductAttribute("red", "large"),
                                "Premium quality product")),
                new ProductWithNestedDetails("2", "Product2",
                        new ProductDetails(
                                new ProductAttribute("blue", "medium"),
                                "Standard quality product")),
                new ProductWithNestedDetails("3", "Product3",
                        new ProductDetails(
                                new ProductAttribute("red", "small"),
                                "Compact design"))
        };
        testIndex.indexDocuments(products);

        // Search using nested -> query -> nested -> query structure: details.attribute.color
        SearchResponse<ProductWithNestedDetails> result = loggingOpenSearchClient.search(s -> s
                        .index(testIndex.getName())
                        .query(q -> q
                                .nested(n -> n
                                        .path("details")
                                        .query(q2 -> q2
                                                .nested(n2 -> n2
                                                        .path("details.attribute")
                                                        .query(q3 -> q3
                                                                .term(t -> t
                                                                        .field("details.attribute.color")
                                                                        .value(FieldValue.of("red"))
                                                                )
                                                        )
                                                )
                                        )
                                )
                        ),
                ProductWithNestedDetails.class
        );

        assertThat(result.hits().total().value()).isEqualTo(2);
        assertThat(result.hits().hits().stream()
                .map(h -> h.source().getId())
                .sorted())
                .containsExactly("1", "3");

    }
}

Important Note: When you have nested fields containing nested types (nested nested fields), you cannot use a single nested query with dot notation. Instead, you must use a nested -> query -> nested -> query structure:

  1. Outer nested query targets the first nested field (details)
  2. Inner nested query targets the nested field within (details.attribute)
  3. Term query operates on the leaf property (details.attribute.color)

This structure ensures that OpenSearch correctly handles the object boundaries at each nesting level. The nested field type preserves object hierarchy, but when you have nested within nested, you need nested queries at each level to properly match values within the same object boundaries.

HTTP Commands for OpenSearch Dashboards

Here’s the index creation request with nested nested mapping (note that attribute is also nested, not object):

PUT /nested_demo_nested
{
  "mappings": {
    "properties": {
      "details": {
        "type": "nested",
        "properties": {
          "description": {
            "type": "keyword"
          },
          "attribute": {
            "type": "nested",
            "properties": {
              "color": {
                "type": "keyword"
              },
              "size": {
                "type": "keyword"
              }
            }
          }
        }
      }
    }
  },
  "settings": {
    "number_of_replicas": 0,
    "number_of_shards": 1
  }
}

Index Documents Request:

POST /nested_demo_nested/_bulk
{ "index" : { "_index" : "nested_demo_nested", "_id" : "1" } }
{ "id" : "1", "name" : "Product1", "details" : { "attribute" : { "color" : "red", "size" : "large" }, "description" : "Premium quality product" } }
{ "index" : { "_index" : "nested_demo_nested", "_id" : "2" } }
{ "id" : "2", "name" : "Product2", "details" : { "attribute" : { "color" : "blue", "size" : "medium" }, "description" : "Standard quality product" } }
{ "index" : { "_index" : "nested_demo_nested", "_id" : "3" } }
{ "id" : "3", "name" : "Product3", "details" : { "attribute" : { "color" : "red", "size" : "small" }, "description" : "Compact design" } }

Search Request: Nested nested property search (details.attribute.color=“red”)

When searching nested nested fields, you must use nested -> query -> nested -> query structure:

POST /nested_demo_nested/_search
{
  "query": {
    "nested": {
      "path": "details",
      "query": {
        "nested": {
          "path": "details.attribute",
          "query": {
            "term": {
              "details.attribute.color": {
                "value": "red"
              }
            }
          }
        }
      }
    }
  }
}

Search Response:

{
  "took": 7,
  "timed_out": false,
  "_shards": {
    "failed": 0,
    "skipped": 0,
    "successful": 1,
    "total": 1
  },
  "hits": {
    "hits": [
      {
        "_id": "1",
        "_index": "nested_demo_nested",
        "_score": 0.4700036,
        "_source": {
          "id": "1",
          "name": "Product1",
          "details": {
            "attribute": {
              "color": "red",
              "size": "large"
            },
            "description": "Premium quality product"
          }
        }
      },
      {
        "_id": "3",
        "_index": "nested_demo_nested",
        "_score": 0.4700036,
        "_source": {
          "id": "3",
          "name": "Product3",
          "details": {
            "attribute": {
              "color": "red",
              "size": "small"
            },
            "description": "Compact design"
          }
        }
      }
    ],
    "max_score": 0.470003604888916,
    "total": {
      "relation": "eq",
      "value": 2
    }
  }
}

Nested vs. Join: Understanding the Trade-offs

When working with relationships between objects in OpenSearch, you have two main options for preserving object boundaries: nested fields and join fields (OS join, ES join). Both can maintain relationships, but they have fundamentally different approaches to storage and updates—and this difference matters a lot for indexing performance, especially with large collections.

How They Work: Storage Differences

Nested Fields: Each object in a nested array is indexed as a separate hidden document within the same parent document. The nested objects are stored alongside their parent document—they’re part of the same document structure. This means:

  • All nested objects are stored with their parent document
  • The entire document (including all nested objects) is retrieved together
  • Nested objects cannot exist independently—they’re always part of the parent

Join Fields: Parent and child documents are separate, independent documents in the same index. They’re linked via a join field, but each document can exist on its own. This means:

  • Parent and child documents are stored separately
  • They’re linked through a join field type that establishes relationships
  • Each document can be retrieved, updated, or deleted independently

The Critical Difference: Update Behavior and Indexing Impact

Here’s where things get interesting—and where the choice really matters for performance:

Nested Fields – Reindexing Impact:

When you update a single nested object in an array, OpenSearch must reindex the entire parent document. Think about it: since nested objects are stored within the parent document, changing one nested object means:

  • Retrieving the entire parent document (including all nested objects)
  • Updating the specific nested object in memory
  • Reindexing the complete document (all nested objects + parent fields)
  • This happens even if you only changed one property of one nested object!

For large collections with many nested objects, this can have significant indexing performance implications. If you have a product with 100 attributes (nested objects), updating a single attribute’s color means reindexing all 100 attributes plus the parent document fields. That’s a lot of work for a small change!

Join Fields – Independent Updates:

When you use join fields, parent and child documents are separate. This means you can:

  • Update a single child document without touching the parent
  • Update the parent without affecting children
  • Add or remove children without reindexing the parent
  • Each document is indexed independently

This is a huge advantage for large collections where updates are frequent. Need to update one product attribute? Just update that child document—no need to touch the parent or any other attributes. Much more efficient!

Query Performance Considerations

Nested Fields:

  • Generally faster for queries within a single document
  • More efficient for aggregations on nested data
  • Simpler query syntax (nested query wrapper)
  • No routing concerns—everything is in one document

Join Fields:

  • Can be slower due to cross-document joins
  • Requires careful routing—parent and child must be on the same shard
  • More complex query syntax (has_child, has_parent queries)
  • Limited aggregation capabilities (child to parent only, not vice versa)

When to Use Each

Use Nested Fields When:

  • Your nested objects are read-heavy and update-light
  • Collections are small to medium in size (update performance impact is acceptable)
  • You need efficient aggregations within nested structures
  • Query performance is more important than update performance
  • Nested objects logically belong to the parent and aren’t independent entities
  • You prefer simpler query syntax

Use Join Fields When:

  • You need to update individual collection items frequently
  • Collections are large (100s or 1000s of items) where reindexing impact matters
  • Parent and child documents are logically independent entities
  • Update performance is critical—you can’t afford to reindex entire documents
  • You have one-to-many or many-to-many relationships between distinct entities
  • You’re willing to trade some query performance for update flexibility

Real-World Example: Product with Attributes

Let’s say you have a product catalog where each product has many attributes (color variations, sizes, etc.):

Scenario: Nested Fields

{
  "id": "product-123",
  "name": "T-Shirt",
  "attributes": [
    {"color": "red", "size": "large", "price": 19.99},
    {"color": "red", "size": "medium", "price": 19.99},
    {"color": "blue", "size": "large", "price": 19.99}
    // ... 97 more attributes
  ]
}

Updating the price of one attribute requires reindexing all 100 attributes + the product name. If you do this frequently, that’s a lot of unnecessary indexing work.

Scenario: Join Fields

// Parent document
{
  "id": "product-123",
  "name": "T-Shirt",
  "product_to_attribute": {"name": "product"}
}

// Child documents
{
  "id": "attr-1",
  "color": "red",
  "size": "large",
  "price": 19.99,
  "product_to_attribute": {"name": "attribute", "parent": "product-123"}
}
// ... other child documents

Updating the price of one attribute? Just update that child document. No impact on the parent or other attributes. Much more efficient for frequent updates!

The Bottom Line

Both nested and join fields preserve object boundaries and allow matching multiple properties from the same object. The key difference is:

  • Nested fields = Better query performance, but require reindexing entire documents on updates (can be costly for large collections)
  • Join fields = Better update performance (independent document updates), but may have query performance overhead and complexity

For most collection searching scenarios with small-to-medium sized arrays that don’t update frequently, nested fields are the simpler and more efficient choice. However, if you’re dealing with large collections (especially 100+ items) that update frequently, join fields may be worth the added complexity to avoid the reindexing overhead.

Remember: a wise Code Sloth considers both query AND update performance when choosing between nested and join! 🦥

Nested vs. Flattened: When to Use Each

Understanding when to use nested fields versus flattened fields is crucial for building effective search solutions. Don’t worry—we’ll help you figure out which one fits your needs. Let’s compare them:

Nested Fields (OS nested, ES nested) – Best For:

  • Arrays of objects where you need to match multiple properties from the same object
  • Collection searching that requires maintaining object relationships within arrays
  • Queries like “find products where an attribute has color=‘red’ AND size=‘large’ in the same object”
  • Cases where object boundaries matter for query accuracy
  • Preserving relationships between properties within array elements

Flattened Fields – Best For:

  • Single objects with multiple properties you want to search on
  • Multiple independent flattened fields in the same document
  • Mapping explosion prevention when you have dynamic object structures
  • Simple queries on object properties using dotted notation
  • Keyword-based searching (since flattened fields index everything as keywords)
  • Cases where object boundaries don’t matter (single objects, not arrays)

The fundamental difference is that nested fields preserve object boundaries, allowing you to query within the context of a single nested object (especially in arrays), while flattened fields lose this context for arrays but work great for single objects. For arrays where you need precise matching of multiple properties from the same object, nested fields are your solution. For single objects or cases where object boundaries don’t matter, flattened fields are simpler and more efficient.

Sloth Summary

Phew! We’ve covered a lot of ground in this article. Let’s recap what we’ve learned about the nested field type (OS nested, ES nested) in OpenSearch:

  • What it is: Nested fields preserve object boundaries by indexing each array object as a separate hidden document
  • Why use it: Essential for collection searching where you need to match multiple properties from the same array object
  • Simple usage: Create nested mappings with Property.of(p -> p.nested(n -> n)) and search using nested queries with path parameter
  • The key advantage: Nested arrays CAN match multiple values from the same array object—this is where they truly shine over flattened fields
  • Multiple fields: You can have multiple nested fields in the same document and search across them independently, and combining multiple top-level nested queries for different paths in a bool query works correctly
  • Critical difference: Nested fields preserve object boundaries in arrays, while flattened arrays cannot reliably match multiple values from the same object
  • Important trade-off: Nested fields require reindexing the entire document when updating nested objects—consider join fields for large collections with frequent updates

The nested field type (OS nested, ES nested) is a powerful tool in your OpenSearch arsenal, especially for collection searching scenarios. Use nested fields when you need to maintain object relationships within arrays and query performance is more important than update performance. For large collections with frequent updates, consider join fields to avoid reindexing overhead. For single objects or when object boundaries don’t matter, reach for flattened fields. Remember: a wise Code Sloth chooses the right tool for the job! 🦥

You can find all the code examples we discussed in the Code Sloth Code Samples page. The NestedDemo package contains all the tests and document classes we explored today.

Happy Code Slothing! 🦥

You may also like