Painless Scripting for Custom Logic
AI generated
_doc
_index
Elasticsearch / Ingest & Pipelines
Painless Scripting
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.

12 min read Painless · Sandbox Function Score · Runtime Fields

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.

11. FAQ: Painless Scripting: The Essentials at a Glance

1In which contexts does Painless get used in Elasticsearch?
In runtime and scripted fields, in update requests through ctx, in function_score queries through script_score, in an ingest pipeline's script processor, and in scripted metric aggregations, each with its own execution context.
2Why doesn't a script written for updates automatically work in a search context?
Because every context provides its own variables. The update context uses ctx._source, while the search context instead provides doc for accessing indexed field values.
3Why should dynamic values always be passed through params instead of directly in the script text?
Because embedded values generate a new script signature for every combination, bypassing the script cache and forcing expensive recompilation on every call.
4When does a runtime field pay off compared to a classically indexed field?
When the field is queried rarely or the computation logic changes often. For frequent queries, a field computed once at write time and indexed normally performs better.
5Why should the upstream query in a script_score be scoped as narrowly as possible?
Because the script runs for every hit the upstream query returns. A broad query with many hits multiplies the script cost accordingly.
6What exactly does the Painless sandbox forbid?
Access to the filesystem, the network, arbitrary Java classes via reflection, and spawning custom threads. Only an explicitly allowlisted, context-dependent API is permitted.
7Why are regular expressions disabled in Painless by default?
Because uncontrolled regex patterns can cause substantial CPU load. They must be explicitly enabled through script.painless.regex.enabled.
8How can a script be tested before going into production?
Through the _scripts/painless/_execute API, which runs a script in isolation against test data and returns the result or an error message, without modifying a real document.
9What happens when the configured compilation rate gets exceeded?
Elasticsearch rejects further compilations with an error until the rate falls back within the limit configured through script.max_compilations_rate.
10How do you handle access to a potentially missing field in a script?
With an explicit check through doc['field'].size() == 0 before the actual access, to avoid a runtime error when the field is missing.