Skip to content
ExponaBLOG
ELASTICSEARCH & SEARCH ENGINEERING · DAY 070 / 100

Elasticsearch scripting: real-world lessons and examples

Painless scripts used carefully.

Expona Admin6 min read

Introduction

By day 70 of this series, you've built robust search features and optimized query performance. Now comes the temptation to reach for Elasticsearch scripting to handle that special scoring logic or custom field transformation. Elasticsearch scripting: real-world lessons and examples teaches us that while Painless scripts are powerful, they're also a sharp tool that demands careful handling in production systems.

We've seen teams ship scripts that looked elegant in development, only to discover they were tanking cluster performance under real traffic. This article distills lessons from production deployments across e-commerce, trading, and SaaS platforms to show you when scripting makes sense, when it doesn't, and how to implement it safely.

Script scoring and updates

Script scoring lets you compute relevance scores dynamically at query time. Instead of pre-computing every ranking factor, you can adjust scores based on business logic, user context, or real-time data. For example, in an e-commerce catalog, you might boost products based on inventory levels or personalized purchase history.

Consider a scenario where you need to rank search results by a combination of text relevance and a dynamic "freshness" score. A product uploaded yesterday should rank higher than one from six months ago, but only when they have similar text match quality.

json
{
  "query": {
    "script_score": {
      "query": {
        "multi_match": {
          "query": "laptop",
          "fields": ["title^2", "description"]
        }
      },
      "script": {
        "source": "Math.log(2 + doc['upload_date'].value / (params.now - doc['upload_date'].value))",
        "params": {
          "now": 1704067200000
        }
      },
      "min_score": 5
    }
  }
}

Script scoring also works for updates. When you need to modify documents without re-indexing, you can use a script in an update request:

json
{
  "script": {
    "source": "ctx._source.view_count += params.increment; ctx._source.last_viewed = params.now",
    "params": {
      "increment": 1,
      "now": 1704067200000
    }
  }
}

This is genuinely useful when counters or timestamps need frequent updates. However, scripts for updates on high-volume documents can create contention in your cluster. We've seen teams updating a popular product's view count millions of times daily, causing shard refresh storms. Consider using bulk updates in batches and potentially moving these counters to a separate cache layer if traffic becomes extreme.

Slower than native queries

Here's the uncomfortable truth: scripts are always slower than native Elasticsearch queries. Painless is compiled to bytecode and cached, but it still adds overhead compared to built-in filters and aggregations written in Java.

Benchmark this yourself. A simple filter using a native range query runs in microseconds. The same logic in a Painless script runs in milliseconds. With thousands of documents to score, that overhead multiplies quickly.

We learned this the hard way on a trading platform where we tried to score market data using a complex script that checked multiple field conditions:

json
{
  "query": {
    "script_score": {
      "query": { "match_all": {} },
      "script": {
        "source": "if (doc['price'].value > params.min && doc['price'].value < params.max && doc['volume'].value > params.vol_threshold) { return doc['momentum'].value * params.weight; } else { return 0; }",
        "params": {
          "min": 10,
          "max": 100,
          "vol_threshold": 1000000,
          "weight": 2.5
        }
      }
    }
  }
}

On a 10-million document index, this query took 8 seconds. We refactored it into a bool query with filters and a simple function_score:

json
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            { "range": { "price": { "gte": 10, "lte": 100 } } },
            { "range": { "volume": { "gte": 1000000 } } }
          ]
        }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "momentum",
            "factor": 2.5
          }
        }
      ]
    }
  }
}

Same business logic. The refactored version ran in 200 milliseconds. That's a 40x improvement. Always ask yourself: can I express this as a native query or aggregation instead?

Use stored scripts

If you must use scripts, store them server-side rather than embedding them in queries. Stored scripts are compiled once and reused, whereas inline scripts are compiled on every request. For high-frequency queries, this becomes a measurable win.

Store a script like this:

json
PUT _scripts/my-freshness-scorer
{
  "script": {
    "lang": "painless",
    "source": "Math.log(2 + doc['created_at'].value) * params.boost"
  }
}

Then reference it in your query:

json
{
  "query": {
    "script_score": {
      "query": { "match": { "title": "laptop" } },
      "script": {
        "id": "my-freshness-scorer",
        "params": { "boost": 1.5 }
      }
    }
  }
}

Stored scripts also make your infrastructure safer. Scripts live in cluster state and are version-controlled with your deployment process. You avoid the chaos of developers embedding custom logic directly in application code, which makes debugging and auditing much harder.

On a CRM system, we centralized all scoring logic as stored scripts. This let us update business rules without redeploying the application, and we could A/B test scoring changes by switching between script versions without code changes.

Prefer runtime fields for computed values

Runtime fields let you define computed values at index time without storing them. They're often a better alternative to scripts when you're just transforming or combining existing fields.

For example, if you need a "search_text" field that combines title and description with custom formatting:

json
PUT my-index/_mapping
{
  "runtime": {
    "search_text": {
      "type": "keyword",
      "script": {
        "source": "emit(doc['title'].value + ' ' + doc['description'].value)"
      }
    }
  }
}

Now you can search and aggregate on search_text as if it were a real field, but without the storage overhead. Runtime fields are evaluated lazily—only when you query them—so they don't slow down indexing.

We used runtime fields extensively in a SaaS platform to create computed dimensions for customer segmentation. Rather than maintaining dozens of pre-computed fields, we defined them as runtime fields. Queries were only slightly slower, but we gained enormous flexibility to modify segmentation logic without reindexing.

The tradeoff: runtime fields are best for fields you query frequently, not for rare use cases. If you're computing a value in every single query but only using it 5% of the time, you're paying the cost unnecessarily. Profile your actual query patterns.

Common mistakes

  • Not profiling scripts in production-like conditions: A script that runs fine on 100k documents explodes on 100 million. Test under realistic data volume before deploying.
  • Overusing scripts for simple logic: If you can express it as a filter, aggregation, or function_score, do that instead. Scripting should be your last resort, not your first tool.
  • Accessing fields without checking if they exist: Painless throws exceptions if a field doesn't exist in a document. Use doc['field'].size() > 0 checks or use params.get('field') patterns.
  • Forgetting to set script timeouts: A poorly written script can hang indefinitely. Always set script.max_compilations_rate limits and individual script timeouts in production.
  • Keeping inline scripts scattered across your application: This makes them invisible to code review and nearly impossible to audit. Centralize scripts as stored scripts or in a dedicated configuration.
  • Ignoring script caching and compilation overhead: Each unique script source is compiled separately. Reuse the same script with different parameters rather than creating new script variants.

Key takeaways

  • Scripts enable powerful dynamic scoring and field transformations, but they're slower than native queries by orders of magnitude—always profile and benchmark against native alternatives.
  • Use stored scripts in production to enable recompilation caching and centralize business logic, making it auditable and version-controlled.
  • Script scoring makes sense for contextual ranking when text relevance alone isn't enough, but often can be replaced by function_score or bool queries.
  • Prefer runtime fields for computed values that combine or transform existing fields, especially when you need flexibility without reindexing.
  • Script updates work well for occasional counters and timestamps, but high-frequency updates on popular documents should be delegated to cache layers like Redis.
  • Always wrap scripts in safety checks: validate field existence, set execution timeouts, and test on production-scale data before deploying.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles