Elasticsearch Viewtool
The Elasticsearch viewtool lets templates and widgets run index queries directly and iterate the results in Velocity. It is registered in toolbox.xml under the key estool, so templates reach it as $estool:
<tool>
<key>estool</key>
<scope>request</scope>
<class>com.dotcms.rendering.velocity.viewtools.ESContentTool</class>
</tool>Queries Are JSON, Not Lucene#
Every method on this tool takes a raw engine JSON query body — the same syntax you would send to the search engine itself. It does not accept Lucene query strings.
## Correct
#set($q = '{"query":{"bool":{"filter":[{"term":{"live":true}}]}},"size":20}')
## Not accepted — throws "Unable to parse the given query"
#set($q = '+contentType:News +live:true')If you want Lucene syntax, use $dotcontent.pull() instead.
You can test a query in Dev Tools → ES Search before putting it in a template.
The Four Methods#
| Method | Returns | Status |
|---|---|---|
$estool.search($q) | ContentSearchResults<ContentMap> — content loaded from the database | Current |
$estool.raw($q) | ContentSearchResponse — the index response only, no database load | Current |
$estool.esSearch($q) | ESSearchResults wrapping raw Contentlet objects | Deprecated |
$estool.esRaw($q) | Elasticsearch's own SearchResponse | Deprecated |
Use search() when you want content to render. Use raw() when you want counts, facets or aggregations and do not need the content itself — it skips the database entirely and is considerably cheaper.
Query Normalization#
The methods on this tool lowercase the entire query before executing it. This is deliberate: it means a mixed-case field name like contentType still resolves to the physical index field contenttype.
This applies to the deprecated methods too, so it is not something a template meets for the first time when it moves off them — if your aggregation names work today, they will keep working.
Two consequences worth knowing:
- Values are lowercased too, not just field names. There is no case-sensitive exact match on a value.
- Aggregation names come back lowercased. An aggregation you declared as
"tagAgg"is keyedtagaggin the response. Looking it up under the name you wrote returns null, and a#foreachover null renders nothing rather than raising an error.
$estool.search()#
Returns a list of ContentMap objects — the same wrapper $dotcontent.pull() gives you — so fields read directly off each item.
#set($q = '{"query":{"bool":{"filter":[
{"term":{"contenttype":"news"}},
{"term":{"live":true}}
]}},"size":10}')
#set($results = $estool.search($q))
<p>Found $results.totalResults articles</p>
#foreach($item in $results)
<h3>$item.title</h3>
<p>$item.summary</p>
#endThe result implements List, so #foreach, $results.size(), $results.isEmpty() and $results.get(0) all behave as you would expect.
Beyond the list itself:
| Expression | What it gives you |
|---|---|
$results.totalResults | Total matching documents, not just the current page |
$results.aggregations | The full aggregation tree — see below |
$results.hits | The underlying index hits |
$results.response | The whole ContentSearchResponse |
$results.scrollId | Scroll id for paginated requests, or null |
$results.queryTook | Query time in milliseconds |
Example: Filtering by Language#
This example passes a query to $estool.search() to find every content title containing the word "gas", and excludes non-English versions by using must_not on a languageid range of 2 or above.
#set($results = $estool.search('{
"query": {
"bool": {
"must": {
"term": {
"title": "gas"
}
},
"must_not": {
"range": {
"languageid": {
"gte": 2
}
}
}
}
}
}'
))
#foreach($con in $results)
$con.title
#endFor more examples of Elasticsearch queries, please see the Elasticsearch API documentation.
$estool.raw()#
Returns the index response without loading content from the database.
#set($raw = $estool.raw($q))
<p>$raw.hits.totalHits.value results in ${raw.tookInMillis}ms</p>
#foreach($hit in $raw.hits.hits)
$hit.sourceAsMap.get("title")
#end| Expression | What it gives you |
|---|---|
$raw.hits | The hit collection |
$raw.hits.hits | The list of individual hits |
$raw.hits.totalHits.value | Total matching documents |
$raw.aggregations | The full aggregation tree |
$raw.scrollId | Scroll id, or null |
$raw.tookInMillis | Query time in milliseconds |
On each hit: $hit.id, $hit.index, $hit.score, $hit.sourceAsMap, $hit.sortValues,
$hit.fields.
Reading Aggregations#
Use $results.aggregations (or $raw.aggregations). Each entry exposes .buckets, and each bucket exposes .key, .keyAsString, .keyAsNumber, .docCount, and its own nested .aggregations.
#set($q = '{"aggs":{"tagagg":{"terms":{"field":"tags","size":10}}},"size":0}')
#set($results = $estool.search($q))
## remember: aggregation names come back lowercased
#set($tags = $results.aggregations.get("tagagg"))
#foreach($bucket in $tags.buckets)
$bucket.keyAsString ($bucket.docCount)
#endNested aggregations and top_hits are preserved, so templates that walk deeper into the tree continue to work unchanged.
Producing JSON#
$raw.toString() does not produce JSON. It returns a Java object description, and it does so without raising an error — so anything downstream that expects JSON silently receives something else.
To emit JSON, use the $json viewtool:
#set($raw = $estool.raw($q))
<script>
var data = $json.generate($raw);
</script>This produces the response in dotCMS's neutral shape, which uses different key names from the ones Elasticsearch emitted. If a script downstream was parsing the old output, it needs remapping:
| Elasticsearch emitted | The neutral response emits |
|---|---|
took | tookInMillis |
hits.total | hits.totalHits |
_index | index |
_id | id |
_score | score |
_source | sourceAsMap |
timed_out, _shards and max_score have no equivalent, and totalHits carries value without the relation qualifier.
If you only need part of the response, build the object you want and generate that instead:
#set($out = {"total": $raw.hits.totalHits.value, "items": []})
#foreach($hit in $raw.hits.hits)
#set($ignore = $out.items.add($hit.sourceAsMap))
#end
<script>var data = $json.generate($out);</script>Elasticsearch's own wire format is not available. If a consumer expects the ES response envelope — hits.hits[]._source and the rest — it needs restructuring around the accessors above. The neutral response has different keys by design.
What Changed in 26.05.19-01#
$estool.search() and $estool.raw() changed their return types in release 26.05.19-01. If you are on that release or later, this has already happened on your installation.
Most templates are unaffected. #foreach, $results.size(), $item.title, $results.aggregations, $results.hits and $results.response all continue to resolve. Three things did change:
$item.map.fieldName no longer works on search() results. Items are now ContentMap objects, which have no map property. Read the field directly — $item.fieldName or $item.get("fieldName"). The old expression resolves to nothing and the value disappears from the page without an error.
$raw.toString() no longer returns JSON. See above. This is the change most likely to break something quietly, because the page still renders.
$raw.took is now $raw.tookInMillis, a plain number of milliseconds rather than a formatted duration.
Two of these stop resolving, and what you see depends on how the template was written. The plain and formal forms — $item.map.title and ${item.map.title} — print the expression itself onto the page, which is ugly but visible. The quiet form $!{item.map.title} fails quietly and prints nothing at all, leaving a blank where the value used to be. While converting a template, switch its quiet references to ${...} so failures show up, then switch them back.
$raw.toString() is potentially a subtler snag: The call succeeds and returns a string, but not a JSON string. As a result, nothing may appear unresolved or awry; the page renders normally and whatever consumes the output receives a Java object description instead.
Deprecated Methods#
$estool.esSearch() and $estool.esRaw() return Elasticsearch-specific types and are deprecated. They exist as a compatibility path and are not a long-term option.
They do not follow your installation's search engine. Unlike search() and raw(), these two read Elasticsearch directly, whatever engine the rest of your site is using.
On an installation migrating to OpenSearch, that matters in one window and one ending.
During dual-write with reads on OpenSearch, a page calling these methods reads Elasticsearch while everything around it reads OpenSearch. Both engines normally hold the same content, so the page looks correct — but if a write reached one engine and not the other, that page disagrees with the rest of the site, with no error and nothing in the logs.
Once the migration completes, behaviour depends on your release. From 26.09.23-01 these methods refuse to run: rather than answer from an Elasticsearch index that no longer receives writes, they throw, which fails the page render. Support can enable FEATURE_FLAG_OPEN_SEARCH_LEGACY_ES_SEARCH_RETURNS_NULL to make them return nothing and log a rate-limited warning instead, so a page renders without those results rather than failing — a stopgap for an installation caught mid-conversion, not a way to keep using them. On earlier releases they keep answering from the frozen Elasticsearch copy, going quietly stale, and fail only when that cluster is switched off.
One further difference: esSearch() returns raw Contentlet objects rather than ContentMap. That is why $item.map.fieldName works there and not on search(). It also exposes a field-shadowing problem — a custom field whose name matches a built-in getter (contentTypeId, for example) resolves to the getter instead of your field's value. Moving to search() resolves it.
The replacements are direct:
| Stop using | Use instead |
|---|---|
$estool.esSearch($q) | $estool.search($q) |
$estool.esRaw($q) | $estool.raw($q) |
These methods are deprecated and will be removed in a future release, currently slated for March 31, 2027.