Compile Once, Evaluate Many: Using OPA Correctly in Production
When working with Open Policy Agent (OPA), it’s easy to assume that performance issues stem from complex policy logic. That was my…
Compile Once, Evaluate Many: Using OPA Correctly in Production
When working with Open Policy Agent (OPA), it’s easy to assume that performance issues stem from complex policy logic. That was my assumption too — until I realized the real problem had nothing to do with the policies themselves.
It was how the Rego engine was being initialized.
This post focuses on a production pattern that is rarely discussed but critically important:
OPA policies must be compiled once and reused — not initialized per request.
If you already understand what OPA is and how Rego works, this post is for you.
For readers new to OPA or looking for general use cases, this article by Chathura Gunasekera provides a good introduction — Implementing Policies with OPA — Example Use Cases.
This post intentionally skips the basics and dives straight into runtime behavior and performance.
Why Initialization Strategy Matters
OPA is often used in request paths that are:
- latency-sensitive
- high-throughput
- evaluated per API call
Because of that, how policies are loaded and prepared matters just as much as what the policies do.
At a high level, OPA does two very different kinds of work:
- Policy compilation (expensive)
- Policy evaluation (cheap)
Problems start when these two get mixed up.
The Mistake: Initializing Rego Per Request
A very common (and tempting) approach looks like this:
func authorize(input Input) bool {
r := rego.New(
rego.Query("data.authz.allow"),
rego.Load([]string{"./policies"}, nil),
)
rs, err := r.Eval(context.Background(), rego.EvalInput(input))
if err != nil {
return false
}
return rs.Allowed()
}
This code works. But it hides a serious problem.
Every time this function is called:
- Rego files are loaded
- Policies are parsed
- ASTs are built
- Compilation happens again
If this runs per request, you are recompiling your entire policy set on every call.
What Actually Happens Inside OPA
To understand why this hurts performance, it helps to know what OPA does internally.
When policies are loaded, OPA:
- Parses Rego source files
- Builds an abstract syntax tree (AST)
- Compiles rules into an internal representation
- Prepares the query execution plan
This process is CPU-intensive and memory-heavy — and it is not meant to run on every request.
OPA is designed with the assumption that:
Policies change rarely, inputs change frequently.
The Correct Model: Compile Once, Evaluate Many
The correct approach is to separate:
- policy lifecycle
- request lifecycle
Conceptually
Application startup:
load policies
compile rego
prepare query
Per request:
evaluate prepared query with input
A Practical Initialization Pattern (Go Example)
Below is a simplified example showing the correct approach.
Step 1: Initialize OPA at startup
var preparedQuery rego.PreparedEvalQuery
func initOPA() error {
r := rego.New(
rego.Query("data.authz.allow"),
rego.Load([]string{"./policies"}, nil),
)
pq, err := r.PrepareForEval(context.Background())
if err != nil {
return err
}
preparedQuery = pq
return nil
}
This function should be called:
- at application startup
- or when policies are updated
Step 2: Evaluate per request
func authorize(input Input) (bool, error) {
rs, err := preparedQuery.Eval(
context.Background(),
rego.EvalInput(input),
)
if err != nil {
return false, err
}
if len(rs) == 0 {
return false, nil
}
allowed, ok := rs[0].Expressions[0].Value.(bool)
return ok, nil
}
Here:
- No policies are reloaded
- No compilation happens
- Only input data changes
This is the intended production usage model for OPA.
Example: Simple Go Server — demonstrates a simple Go HTTP server integrated with Open Policy Agent (OPA) for request authorization.
A Related Pitfall: Large Bundles and Test Policies
While investigating initialization behavior, another related issue became apparent.
OPA loads everything it is given:
- all Rego files
- all data files
- all test policies
Even if test rules are never queried in production, they:
- increase compilation time
- increase memory usage
- amplify the cost of per-request initialization
This means:
- Shipping large test policies to production
- Using oversized bundles
- Recompiling per request
…all compound into severe performance problems.
Keeping production policy bundles minimal is just as important as reusing compiled queries.
A Practical Go Example (with Runnable Code)
To make this concrete, I created a small GitHub repository with runnable examples: **https://github.com/sushansapaliga/opa-go-examples**
The repository contains:
- a latency benchmark
- a simple Go HTTP server
- examples of correct and incorrect OPA initialization patterns
Closing Thoughts
OPA is fast, reliable, and production-ready — but only if used the way it was designed.
Most performance issues are not caused by “slow policies”. They are caused by doing expensive work repeatedly when it should only happen once.
If you’re debugging OPA latency issues, don’t just look at your Rego logic. Look at when and how the engine is initialized.
That’s often where the real problem lies.
메타데이터
- post_id
- 039bea4ddf25
- slug
- compile-once-evaluate-many-using-opa-correctly-in-production-039bea4ddf25
- url
- https://medium.com/@sushansapaliga/compile-once-evaluate-many-using-opa-correctly-in-production-039bea4ddf25
- canonical_url
- https://medium.com/@sushansapaliga/compile-once-evaluate-many-using-opa-correctly-in-production-039bea4ddf25
- author_url
- https://medium.com/@sushansapaliga
- status
- ok
- fetched_at
- 2026-07-13 06:23:13