custom logic between flexibility and performance cost
Elasticsearch covers most use cases with declarative query building blocks, but some calculations cannot reasonably be squeezed into a fixed set of query clauses: a dynamic price calculation depending on several fields, a custom scoring formula, or a conditional update that reacts differently depending on the value already present. Painless exists for exactly these cases, a scripting language built specifically for Elasticsearch that runs inside a strict sandbox. This article covers the contexts where Painless gets used, the performance cost scripts carry compared to native query building blocks, and where the sandbox's deliberately drawn security boundaries sit.
Table of Contents
- 1. Where Painless gets used in Elasticsearch
- 2. Scripted fields and runtime fields: computing values at search time
- 3. Update scripts: conditional, server-side document changes
- 4. Function score: custom relevance scoring with scripts
- 5. Performance implications compared to native query building blocks
- 6. The security sandbox: what Painless deliberately cannot do
- 7. Practical example: dynamic price calculation via an update script
- 8. Script caching and the cost of compilation
- 9. Debugging and error handling for Painless scripts
- 10. Summary
- 11. FAQ
1. Where Painless gets used in Elasticsearch
Painless shows up in several clearly separated places across the Elasticsearch ecosystem, each with its own execution context and its own available variables. In runtime fields and scripted fields, a script computes a field value at search time from existing document data, in update requests a script changes the stored document content through the ctx variable, and in a function_score query, a script influences a hit's relevance score.
Painless can also be used in an ingest pipeline's script processor as well as in scripted metric aggregations, where it accumulates its own intermediate values across multiple documents. These contexts are deliberately kept separate: a script written for the update context using ctx._source does not work unchanged in a search context, which instead provides the doc variable for accessing indexed field values.
2. Scripted fields and runtime fields: computing values at search time
A runtime field does not define a stored value in the mapping, but a Painless computation rule that runs live against the actually returned documents on every search query requesting that field. This suits fields that get queried rarely but would change often if they were indexed the classic way, for example a derived metric from several base fields that would need recomputing on every price change.
The advantage is not needing reindexing when the computation logic changes, since only the script definition in the mapping changes, not the stored documents themselves. The downside is that the computation runs again for every returned document on every affected query, instead of once at write time.
PUT products/_mapping
{
"runtime": {
"margin_percent": {
"type": "double",
"script": {
"source": "if (doc['price'].size() > 0 && doc['cost'].size() > 0) { emit((doc['price'].value - doc['cost'].value) / doc['price'].value * 100) }"
}
}
}
}
3. Update scripts: conditional, server-side document changes
An update request with a script allows conditional, server-side changes to an existing document without the application having to load the current state entirely beforehand and send back the full document content. Through the ctx._source variable, the script accesses stored fields directly and can change them depending on passed-in parameters, for example a stock adjustment that only applies if current stock does not fall below a threshold.
It matters to pass every dynamic value through the params object instead of embedding it directly in the script text. A script with hardcoded embedded values generates a new, distinct script signature for every different value combination, which bypasses the internal script cache and forces expensive recompilation on every call.
POST products/_update/4711
{
"script": {
"source": "if (ctx._source.stock >= params.amount) { ctx._source.stock -= params.amount } else { ctx.op = 'noop' }",
"params": { "amount": 3 }
}
}
4. Function score: custom relevance scoring with scripts
In a function_score query, script_score lets an arbitrary Painless formula act as an additional scoring factor, for example to combine popularity, recency, and text relevance into a single, individually weighted formula that the built-in scoring functions alone could not express.
Since this script runs for every document the upstream query already returns as a hit, the upstream query should be scoped as narrowly as possible before the score script even kicks in. A broad match query with an expensive score script over millions of hits is nearly always slower than a narrowly scoped query with a simpler score script over a few thousand hits.
GET products/_search
{
"query": {
"function_score": {
"query": { "match": { "category": "shoes" } },
"script_score": {
"script": {
"source": "doc['popularity'].value / (1 + params.now_days - doc['created_days'].value)",
"params": { "now_days": 20320 }
}
}
}
}
}
5. Performance implications compared to native query building blocks
Native query building blocks such as range, term, or built-in scoring functions like field_value_factor operate directly against pre-indexed, optimized data structures and are compiled for exactly these access patterns. A Painless script, by contrast, also compiles to bytecode, but has to be interpreted per document, giving it structurally higher per-evaluation cost than a native comparison operator.
For fields that get queried frequently, it therefore almost always pays off to compute the value once at write time and store it as a regular, indexed field, rather than recomputing it on every search through a runtime field or a score script. Scripts remain worthwhile where the computation is queried rarely enough, or changes too often to justify fixed indexing.
6. The security sandbox: what Painless deliberately cannot do
Painless runs inside a strict sandbox with no access to the filesystem, the network, or arbitrary Java classes via reflection, and cannot spawn its own threads either. Only an explicitly allowlisted, context-dependent API surface is permitted, for example accessing document values through doc or parameters through params, not free access to arbitrary system resources.
This restriction is a deliberate lesson learned from the predecessor solution Groovy, which served as the default scripting language before version 5 and repeatedly caused security vulnerabilities due to missing sandbox boundaries, through which arbitrary code could be executed on the server. In addition, regular expressions are disabled in Painless by default and must be explicitly enabled through the script.painless.regex.enabled setting, since uncontrolled regex patterns can cause substantial CPU load.
7. Practical example: dynamic price calculation via an update script
A realistic example is a discount update that adjusts the stored price through a script only when a campaign condition is met, instead of resending the full document content from the application. The script reads the current base price from ctx._source, applies the discount factor passed through params, and writes the new price back, all server-side in a single request.
This approach reduces network traffic compared to a classic read-modify-write cycle from the application, but carries the same structural requirement as every Painless script: the discount factor must be passed through params so that the same script signature gets reused for different discount values and served from the script cache, instead of being recompiled for every differing factor.
POST products/_update_by_query
{
"script": {
"source": "ctx._source.price = Math.round(ctx._source.base_price * params.discount_factor * 100) / 100.0",
"params": { "discount_factor": 0.85 }
},
"query": { "term": { "campaign": "summer-sale" } }
}
8. Script caching and the cost of compilation
Every newly compiled Painless script lands in the internal script cache, whose size is configurable through script.cache.max_size and whose expiry through script.cache.expire. An already compiled script with an identical signature gets served from this cache and does not need to be parsed and compiled again, which considerably reduces the actual execution overhead for recurring calls.
If the same script logic gets called with different values embedded directly in the script text instead of passed as parameters, though, every call generates its own cache signature, leading to constant recompilation, which past a certain rate can exceed the configured compilation rate under script.max_compilations_rate, at which point Elasticsearch rejects further compilations with an error.
9. Debugging and error handling for Painless scripts
If a Painless script fails, Elasticsearch returns a detailed error response with a script stack trace, the affected line, and column, which makes troubleshooting considerably easier than a generic error message. A common runtime error is accessing a field that is missing from the current document, which can be guarded against with an explicit check through doc['field'].size() == 0 before the actual access.
For developing new scripts, it helps to first check them against individual test documents through the _scripts/painless/_execute API before they go into a production query or update operation. This API runs a script in isolation and returns either the result or a precise error message, without ever needing to modify a real document.
POST _scripts/painless/_execute
{
"script": {
"source": "params.price * params.discount_factor",
"params": { "price": 49.90, "discount_factor": 0.85 }
}
}
| Usage site | Execution timing | Typical use | Performance note |
|---|---|---|---|
| Runtime field | On every matching search | Rarely queried, dynamic fields | Prefer indexing for frequent queries |
| Update script | At write time, server-side | Conditional document changes | Always pass values through params |
| Function score | Per hit of the upstream query | Custom relevance formula | Scope the upstream query narrowly |
| Ingest script processor | At indexing time, once | Custom transformation logic | Use sparingly, CPU cost per document |
| Scripted metric aggregation | Accumulating across multiple documents | Complex, multi-step computations | Only when native aggregations fall short |
Mironsoft
Search index setup, relevance tuning, and Magento search
Magento search that shows the wrong products first?
We set up Elasticsearch or OpenSearch for Magento cleanly, tune relevance and facets to the actual catalog, and optimize indexing processes for large catalogs.
Relevance Tuning
Match search results and facets to actual customer needs.
Search Migration
Guide a clean migration from Solr or MySQL search to Elasticsearch/OpenSearch.
Index Performance
Make indexing processes for large catalogs reliable and performant.
10. Summary
Painless Scripting: The Essentials at a Glance
Core principle
Painless brings custom logic into runtime fields, update requests, function score, and ingest pipelines, each with its own, clearly scoped execution context.
Performance rule
Native query building blocks are almost always faster for frequently queried values. Scripts pay off mainly for rare queries or logic that changes often.
Cache trap
Dynamic values always belong in params, not in the script text, otherwise every value combination generates a new signature and forces expensive recompilation.
Security boundaries
No file, network, or reflection access, no custom threads, regular expressions disabled by default, a deliberate lesson from the insecure Groovy era.