Let's get started with a Microservice Architecture with Spring Cloud:
Perform Wildcard Search in Elasticsearch
Last updated: December 24, 2025
1. Overview
Elasticsearch is a powerful, widely used search engine with robust full-text search capabilities. When we’re building an application that searches large collections of documents, wildcard-style matching (e.g., starts-with or contains) is a common requirement.
In this tutorial, we’ll explore practical ways to perform wildcard searches in Elasticsearch using Java.
2. Understanding Wildcard Search
First, let’s review the main strategies for implementing wildcard-style search and the considerations for each approach.
2.1. Wildcard Query — Flexible Patterns (* And ?)
Wildcard queries are the most approachable technique for simple pattern matching. It works on an exact term string, so we’ll get the most predictable results by targeting a keyword field, for example, name.keyword.
The syntax for a wildcard() query is structured as follows:
- * matches zero or more characters. For example, “john*” → matches “john”, “johnson”, “johnstone”
- ? matches exactly one character. For example, “jo?n” → matches “john”, “joan”
The Elasticsearch client provides a method to perform the wildcard search:
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.wildcard(w -> w.field(fieldName)
.value(lowercaseSearchTerm)
.caseInsensitive(true)))
.size(maxResults), ObjectNode.class);
In this case, we specify the search term to match against.
2.2. Prefix Query — Optimized “Starts With”
A prefix() query matches terms that begin with a given prefix. For example, the prefix “pre” will match any of: “prefix”, “premium”, “preset”. This method focuses on left-anchored matching and autocomplete while excluding substring or suffix matches:
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.prefix(p -> p.field(fieldName)
.value(prefix)))
.size(maxResults), ObjectNode.class);
2.3. Regexp Query — Complex Patterns
Next, a regexp() query uses Lucene-style regular expressions to provide support for rich patterns. For example, the regular expression “jo(hn|n?y).*”, will match these items: “john”, “jony”, “jonny bravo”. In this case, we need to specify an exact expression to perform the search:
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.regexp(r -> r.field(fieldName)
.value(pattern)))
.size(maxResults), ObjectNode.class);
2.4. Fuzzy Query — Typo Tolerant (Not a Wildcard)
Unlike wildcards, fuzzy() queries don’t look for patterns. Instead, they find similar terms by edit distance, so a query like “jon” can still match “john”. Fuzzy query takes advantage of the Levenshtein distance, which is a way to measure how different two strings are by counting the minimum number of single-character edits needed to turn one into the other:
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.fuzzy(f -> f.field(fieldName)
.value(searchTerm)
.fuzziness("AUTO")))
.size(maxResults), ObjectNode.class);
We use the AUTO value for the fuzziness parameter to enable approximate string matching based on Levenshtein edit distance.
3. Implementation
Now, we’ll see the implementation of each strategy we have discussed. For a wildcard() query, we’ll take advantage of the .keyword subfield defined in the Elasticsearch mapping to execute an exact match query:
public List<Map<String, Object>> wildcardSearchOnKeyword(String indexName, String fieldName,
String searchTerm) throws IOException {
logger.info("Performing wildcard search on keyword field - index: {}, field: {}, term: {}",
indexName, fieldName, searchTerm);
// Use the .keyword subfield for exact matching
String keywordField = fieldName + ".keyword";
// Convert to lowercase for case-insensitive matching
String lowercaseSearchTerm = searchTerm.toLowerCase();
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.wildcard(w -> w.field(keywordField)
.value(lowercaseSearchTerm)
.caseInsensitive(true)))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
Notice how we first convert the term to lowercase before performing the search.
Now, let’s see how to execute a prefix() search:
public List<Map<String, Object>> prefixSearch(String indexName, String fieldName,
String prefix) throws IOException {
logger.info("Performing prefix search on index: {}, field: {}, prefix: {}",
indexName, fieldName, prefix);
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.prefix(p -> p.field(fieldName)
.value(prefix)))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
For a regexp() query, we need to specify the regular expression to match in our searches:
public List<Map<String, Object>> regexpSearch(String indexName, String fieldName,
String pattern) throws IOException {
logger.info("Performing regexp search on index: {}, field: {}, pattern: {}",
indexName, fieldName, pattern);
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.regexp(r -> r.field(fieldName)
.value(pattern)))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
Finally, a fuzzy() query will perform the search using the Levenshtein edit distance mode:
public List<Map<String, Object>> fuzzySearch(String indexName, String fieldName,
String searchTerm) throws IOException {
logger.info("Performing fuzzy search on index: {}, field: {}, term: {}",
indexName, fieldName, searchTerm);
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.fuzzy(f -> f.field(fieldName)
.value(searchTerm)
.fuzziness("AUTO")))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
We can then parse and extract the search results based on the response we get from the Elasticsearch client:
private List<Map<String, Object>> extractSearchResults(SearchResponse<ObjectNode> response) {
List<Map<String, Object>> results = new ArrayList<>();
logger.info("Search completed. Total hits: {}", response.hits()
.total()
.value());
for (Hit<ObjectNode> hit : response.hits()
.hits()) {
Map<String, Object> sourceMap = new HashMap<>();
if (hit.source() != null) {
hit.source()
.fields()
.forEachRemaining(entry -> {
// Extract the actual value from JsonNode
Object value = extractJsonNodeValue(entry.getValue());
sourceMap.put(entry.getKey(), value);
});
}
results.add(sourceMap);
}
return results;
}
Now that we have all our core wildcard search implementations, let’s start writing some tests for our main use cases.
4. Testing the Implementation
Now that we have our implementation ready, we can define unit tests and integration tests for each search strategy.
4.1. Unit Tests for Wildcard Search
We can create unit tests for our search methods by mocking the results and adding the stubs we need to verify the results. In the next test, we execute and check wildcard search results:
@Test
@DisplayName("Return matching documents when performing wildcard search")
void whenWildcardSearch_thenReturnMatchingDocuments() throws IOException {
// Given
SearchResponse<ObjectNode> mockResponse = createMockResponse(
createHit("1", "John Doe", "[email protected]"),
createHit("2", "Johnny Cash", "[email protected]"));
when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))).thenReturn(mockResponse);
// When
List<Map<String, Object>> results = wildcardService.wildcardSearch("users", "name", "john*");
// Then
assertThat(results).hasSize(2)
.extracting(result -> result.get("name"))
.containsExactly("John Doe", "Johnny Cash");
verify(elasticsearchClient).search(any(Function.class), eq(ObjectNode.class));
}
Additionally, we could check for specific cases when performing wildcard searches, for example, wildcard searches should be case-insensitive:
@Test
@DisplayName("Perform case-insensitive wildcard search")
void whenWildcardSearch_thenBeCaseInsensitive() throws IOException {
// Given
SearchResponse<ObjectNode> mockResponse =
createMockResponse(createHit("1", "John Doe", "[email protected]"));
when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))).thenReturn(mockResponse);
// When
List<Map<String, Object>> results = wildcardService.wildcardSearch("users", "name", "JOHN*");
// Then
assertThat(results)
.hasSize(1)
.extracting(result -> result.get("name"))
.contains("John Doe");
}
4.2. Integration Tests
To test the wildcard search service against an Elasticsearch instance and run integration tests, we can use Docker containers to execute them, ensuring a more consistent, isolated, and reproducible testing environment across different systems.
First, we need to define a component that initializes the Elasticsearch instance using the ElasticsearchContainer class:
@Container
static ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer(
"docker.elastic.co/elasticsearch/elasticsearch:8.11.1")
.withExposedPorts(9200)
.withEnv("discovery.type", "single-node")
.withEnv("xpack.security.enabled", "false")
.withEnv("xpack.security.http.ssl.enabled", "false");
Now that we have our container ready, our wildcard search service could connect to this instance and perform searches:
@Test
void whenWildcardSearchOnKeyword_thenReturnMatchingDocuments() throws IOException {
// When
List<Map<String, Object>> results = wildcardService.wildcardSearchOnKeyword(TEST_INDEX, "name", "john*");
// Then
assertThat(results)
.isNotEmpty()
.hasSize(2)
.extracting(result -> result.get("name"))
.doesNotContainNull()
.extracting(Object::toString)
.allSatisfy(name -> assertThat(name.toLowerCase()).startsWith("john"));
logger.info("Found {} results for 'john*'", results.size());
}
Let’s look at another interesting integration test case, this time, one that performs a search across multiple fields:
@Test
void whenMultiFieldWildcardSearch_thenReturnDocumentsMatchingAnyField() throws IOException {
// When
List<Map> results = wildcardService.multiFieldWildcardSearch(TEST_INDEX, "john", "name", "email");
// Then
assertThat(results).isNotEmpty()
.allSatisfy(result -> {
String name = result.get("name") != null ? result.get("name")
.toString()
.toLowerCase() : "";
String email = result.get("email") != null ? result.get("email")
.toString()
.toLowerCase() : "";
assertThat(name.contains("john") || email.contains("john")).as("Expected 'john' in name or email")
.isTrue();
});
}
Integration tests may take longer to run because they need to spin up a Docker-based Elasticsearch instance and execute the full workflow. The advantage, however, is that this approach allows us to test against specific Elasticsearch engine versions.
5. Conclusion
In this article, we’ve explored the main strategies for performing wildcard searches in an Elasticsearch instance and how to test each approach in different scenarios.
As always, the complete code for this tutorial is available over on GitHub.















