Optimizing OPA performance: From arrays to objects
Achieve 99% faster Rego policy execution through optimization.

Note: This post focuses on one aspect of performance tuning Rego policies and datasets evaluated by OPA—arrays vs. objects. The Rego Style Guide and Regal Rego linter are very helpful resources for learning Rego best practices and avoiding code smells in Rego policies. There is also the OPA performance tuning documentation.
In 2018, I started using open policy agent (OPA) as a solution for controlling and preventing unwanted behaviors in our Kubernetes Clusters. OPA, along with Kubernetes Dynamic Admission Control, provided a means to build preventive controls. Since then, I have worked with several PaC solutions. I have always stayed close to the OPA tool set because of how well it supports multiple use cases.
OPA is domain agnostic and can be used with virtually any use case, as long as you supply the correct data and policies. To that end, OPA use cases have expanded throughout several technical disciplines, such as cloud-native computing and software supply chain management.
OPA performance engineering
OPA enables us to unify PaC solutions across multiple use cases and systems, using the same languages and tools. However, there is always room for improvement and performance engineering policies and the execution thereof. In addition, optimizing data that policies evaluate and mutate should be part of our focus when we deliver OPA-based solutions.
Recently I was asked to help with OPA performance issues. I made several recommendations, but I overlooked one simple and glaring issue: the poor performing policy was processing a large data set using nested-arrays, instead of the best practice of using keyed-objects. Later, something was bothering me about my interaction and I realized that while I gave decent architectural level advice, I completely missed the best engineering advice. Rego policies and data should be optimized just like other algorithms and relative data, and part of that optimization is using the correct data structures.
Object access versus array traversal
Rego (pronounced RAY-GO) is OPA’s domain-agnostic query language that makes assertions on data stored in and submitted to OPA. Domain-agnostic means that Rego is wide open; it wasn’t written for a specific domain or use case. In fact, OPA doesn’t know anything about your use case until you give it policies and data needed to evaluate required decisions.
Rego works on JSON. While you can send YAML to OPA, it will be parsed into JSON before it’s evaluated by Rego. And as it turns out, Rego executes faster through JSON data when it accesses data via objects and dot-notation and slower when array searching and iteration are done. This execution difference is exacerbated with large datasets, nested arrays or lists, and complex policies.
The Rego behavior is not unique. When we write algorithms, we strive for as close to constant time complexity, denoted by O(1) in Big O notation, as we can get. In other words, the execution time of an algorithm should remain constant regardless of the size of the input data. Generally, accessing data via object keys, map keys or array indexes helps us stay close to O(1). Iterating through a collection is usually slower and is compounded by the size of the collection and any comparisons we perform on the elements.
When I looked more closely at the data and policies with which I was asked to help, I realized that the policy authors were not following one of the most prominent rules of policy performance from the OPA documentation: use objects over arrays. They were performing multiple (nested) array operations over a large dataset. I missed this, and it prompted me to revisit Rego performance tuning.
What follows is an example of a data-intensive use case that shows how Rego policy performance is substantially improved through data structure and policy optimizations, switching from array traversal operations to object dot-notation.
Use case: SBOM EPSS data enrichment
The following use case uses OPA Rego policies to build risk profiles from software bills of material (SBOM) data, in CycloneDX format, that contains vulnerability information. The SBOMs are created using Anchore Grype.
$ grype <container_id> -o cyclonedx-json > sbom-with-vulns.json
A snippet of the output SBOM with included vulnerabilities is seen in the following listing:
...
"vulnerabilities": [
...
{
"bom-ref": "urn:uuid:a7d956ab-b2f9-4f93-a6c5-01cee8a9f1b0",
"id": "CVE-2015-8390",
"source": {
"name": "nvd-cpe",
"url": ""
},
"references": [{
"id": "CVE-2015-8390",
"source": {
"name": "nvd-cpe",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2015-8390"
}
}
],
"ratings": [{
"score": 7.5,
"severity": "critical",
"method": "CVSSv2",
"vector": "AV:N/AC:L/Au:N/C:P/I:P/A:P"
}...The risk profiles are created using Exploit Prediction Scoring System (EPSS) data. Using EPSS enriches risk data with exploitability predictions, formatted in percentages. It extends the risk-based evaluation of vulnerabilities beyond the Common Vulnerability Scoring System (CVSS) ratings from the National Vulnerability Database.
For this use case, I downloaded the EPSS CSV file and then used ANTLR to generate a Java-based parser to parse the CSV data into JSON data. I built my own parser because I wanted more control over the parsing output, and I am familiar with ANTLR. A snippet of the parsed data file is seen in the following listing:
{
"rows": [{
"fields": {
"cve": "CVE-1999-0001",
"epss": "0.00383",
"percentile": "0.72987"
}
},
{
"fields": {
"cve": "CVE-1999-0002",
"epss": "0.01328",
"percentile": "0.85885"
}
}...The generated EPSS JSON uses an array of rows that contain field objects. It’s one of the simplest forms of creating JSON from a CSV object and is modeled after the rows and columns in a CSV file. However, the file is 1.9 million lines long and 46MB, and it is not optimized for processing.
The following Rego policy is used to interleave the SBOM CVE data with the EPSS data and logically create a risk-weighting system that allows organizations to assign risk based on the weight of the CVSS and the EPSS data, using weighted risk-level functions.
package grc.sbom.vulns.epss
profiles_epss contains result if {
c := count(input.vulnerabilities)
profiles := {p |
some v in input.vulnerabilities
e := build_epss(v.id)
r := risk(v.ratings[0].severity, to_number(e.percentile))
p := {
"1-nvd": {
"id": v.id,
"severity": v.ratings[0].severity,
"ref": v.affects[0].ref,
"url": v.source.url,
},
"2-epss": e,
"3-riskLevel": r,
}
}
result := {"count": c, "profiles": profiles}
}
default build_epss(id) := set()
build_epss(id) := epss if {
some row in data.epss.rows
id == row.fields.cve
epss := {"score": to_number(row.fields.epss), "percentile":
to_number(row.fields.percentile)}
}
risk(s, p) := "medium" if {
s == "none"
p >= 0.75
p < 0.8
}
risk(s, p) := "high" if {
s == "none"
p >= 0.8
}
risk(s, p) := "low" if {
s == "none"
p < 0.75
}...In the previous Rego listing, the profiles_epss rule uses a Rego comprehension to create a collection of profile objects. The rule starts by iterating through the array of vulnerabilities in the SBOM. Then the rule calls the build_epss function for each CVE, which also loops through a massive array of EPSS data looking for a match between the supplied CVE ID and CVE field in the selected array element. This is not the best way to handle this data processing scenario with Rego, and we can make data-driven changes to improve processing.
Profiling Rego
The OPA CLI eval command can be used to evaluate data with Rego policies, and this command has a handy profiler that shows where the policy execution bottlenecks occur. OPA eval is similar to running an OPA server, but it is a one-shot execution, which is a good fit for CLI use cases like automated processes. And I use OPA eval when I am performance-tuning Rego policies.
The OPA eval command also supports collecting metrics about the different steps in the query execution cycle. This helps pinpoint where potential issues are. Finally, you can use the shell time command to get an idea of the execution time of the command. The following shell command runs OPA eval and specifies the --profile and --metrics arguments:
$ time opa eval --metrics --profile data.grc.sbom.vulns.epss.profiles_epss -i sbom-with-vulns.json -b bundle | jq .The following listing contains the JSON output from the preceding OPA eval command. The output first indicates 124 profiles were generated, using CVSS and EPSS data, and then lists the first profile in the list with a computed org-specific risk level of high.
{
"count": 124,
"profiles": [{
"1-nvd": {
"id": "CVE-2015-8390",
"ref":
"pkg:rpm/amzn/glib2@2.56.1-9.amzn2.0.2?arch=x86_64&upstream=glib2-2.56.1-9.amzn2.0.2.src.rpm&distro=amzn-2&package-id=f9b462fdfe4d7809",
"severity": "critical",
"url": ""
},
"2-epss": {
"percentile": 0.91806,
"score": 0.03801
},
"3-riskLevel": "high"
}...Executing the OPA eval command to process the SBOM and EPSS data into risk profiles took approximately 48 seconds, as indicated by the information returned from the shell time command.
47.71s user 0.30s system 180% cpu 26.639 total
If we look at the metrics gathered by the OPA eval command, we can see that it took approximately 26 seconds to evaluate the Rego query.
"metrics": {
"timer_rego_data_parse_ns": 288190875,
"timer_rego_external_resolve_ns": 166,
"timer_rego_load_bundles_ns": 302749250,
"timer_rego_module_compile_ns": 804125,
"timer_rego_module_parse_ns": 264250,
"timer_rego_query_compile_ns": 49792,
"timer_rego_query_eval_ns": ,
"timer_rego_query_parse_ns": 37625
}...If we look at the performance profile data returned, including the top two highest-processing-time locations, we can see that lines 30 and 31 of the Rego policy took the longest to run.
"profile": [{
"total_time_ns": 12752356894,
"num_eval": 73,
"num_redo": 20119895,
"num_gen_expr": 1,
"location": {
"file": "bundle/sbom-with-vulns-epss.rego",
"row": 30,
"col": 2
}
},
{
"total_time_ns": 11672399442,
"num_eval": 20119895,
"num_redo": 70,
"num_gen_expr": 1,
"location": {
"file": "bundle/sbom-with-vulns-epss.rego",
"row": 31,
"col": 2
}
}...Lines 30 and 31 of the Rego policy are listed below. They are where the processing of the 1.9 million-line JSON EPSS data takes place, as you can see by traversing each item in the array and doing a comparison.
build_epss(id) := epss if {
some row in data.epss.rows
id == row.fields.cve
epss := {"score": to_number(row.fields.epss), "percentile":
to_number(row.fields.percentile)}
}We know how long the policy execution took as well as where the potential bottlenecks are, and nested array processing with element comparisons is clearly an issue.
Data optimization
As I said earlier, accessing JSON data via Rego using object dot-notation is faster than traversing arrays. So I first transformed the EPSS JSON data using a jq command seen in the following listing:
$ jq '.rows | map({(.fields.cve): (.fields | del(.cve))}) | add' data.json
The preceding command transformed the EPSS data JSON into the following object-oriented format. This also reduced the file down to 1 million lines and 20MB—a reduction of 900K lines and 26MB.
...
"CVE-1999-0001": {
"epss": "0.09373",
"percentile": "0.91945"
},
"CVE-1999-0002": {
"epss": "0.16835",
"percentile": "0.94322"
}...Next, I modified the Rego policy to take advantage of the new dot-notation used to get the EPSS data, keyed by CVE IDs. I also removed the build_epss function.
some v in input.vulnerabilities
e := {"score": to_number(data.epss[v.id].epss), "percentile":
to_number(data.epss[v.id].percentile)}Then I reran the risk profile generator command with the optimized data and policy.
0.99s user 0.06s system 160% cpu 0.654 total
As you can see, the new execution time is 0.99 seconds, down from approximately 48 seconds. This comes out to a 98% decrease, approximately. The metrics also indicated a drastic decrease in processing time. The query evaluation time dropped from approximately 26 seconds to 5 milliseconds, a 99% decrease.
"metrics": {
"timer_rego_data_parse_ns": 187242209,
"timer_rego_external_resolve_ns": 4508,
"timer_rego_load_bundles_ns": 199753459,
"timer_rego_module_compile_ns": 7093292,
"timer_rego_module_parse_ns": 1709082,
"timer_rego_query_compile_ns": 48500,
"timer_rego_query_eval_ns": ,
"timer_rego_query_parse_ns": 29750
}...Finally, with the nested array processing eliminated, the profiling output no longer indicates huge processing constraints.
"profile": [
{
"total_time_ns": 1347990,
"num_eval": 583,
"num_redo": 558,
"num_gen_expr": 5,
"location": {
"file": "bundle/sbom-with-vulns-epss.rego",
"row": 11,
"col": 46
}
},
{
"total_time_ns": 569618,
"num_eval": 353,
"num_redo": 350,
"num_gen_expr": 5,
"location": {
"file": "bundle/sbom-with-vulns-epss.rego",
"row": 30,
"col": 75
}
}...The highest code line execution time dropped from more than 12 seconds down to just over 1 millisecond, which is a more than 99% improvement.
If we chart the improvements, we can easily see where we improved most. Overall, loading the OPA bundle that contained the large EPSS dataset and the Rego policy only improved by 34%, while data parsing only improved by 35%. The biggest gains were in the Shell Execution, Policy Eval and Longest Expression times. Here, we saw duration improvements of 98%, 99% and 99%, respectively.
Summary
There are potentially other performance tweaks I could make to the Rego policy to improve the performance of the evaluation. However, a 98%-99% improvement is a great start, and it was accomplished by using algorithmic constant time complexity improvements—converting a very large data array to a keyed object—allowing object dot-notation to be used instead of array traversal and subsequent comparisons.
For those of you out there who are using OPA, especially with large datasets, I strongly encourage you to review the OPA performance-tuning documentation and remember your algorithm tuning techniques.



