cel-rust: evaluating Common Expression Language in Rust
Common Expression Language interpreter written in Rust
At a glance
- What is it?
- The cel crate compiles and executes Common Expression Language programs in Rust, with a standard library environment, a context you extend with custom functions and variables, and a workspace that includes conformance tests and fuzzing. It suits services that need to evaluate policy expressions safely at runtime without embedding a full scripting language.
- Who is it for?
- cel-rust fits a Rust service that needs to evaluate short policy or authorisation expressions supplied at runtime, where embedding a general scripting language would be too heavy or too risky, and it does not fit a team that needs an arbitrary programming language or a hosted evaluation service.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What CEL is for: small expressions, evaluated safely, everywhere
The Common Expression Language is a non-Turing complete language designed for simplicity, speed, safety, and portability, and the readme notes that its C-like syntax looks nearly identical to equivalent expressions in C++, Go, Java, and TypeScript.
The readme's own example of the design goal is worth reading closely. In Java, a check whether a resource name starts with a group name is written as resource.name.startsWith("/groups/" + auth.claims.group). In Go, the equivalent is written as request.time - resource.age < duration("24h"). In TypeScript it is written as auth.claims.email_verified && resources.all(r, r.startsWith(auth.claims.email)).
Those are authorisation and policy checks, and they are the use case CEL was designed for. The key property is non-Turing completeness: an expression cannot loop, so evaluating an expression supplied by an untrusted party terminates. That is what makes CEL suitable for Kubernetes admission, API authorisation and similar places where a general scripting language would be a liability.
The readme states the positioning directly: CEL is ideal for lightweight expression evaluation when a fully sandboxed scripting language is too resource intensive.
The audience is a Rust developer who needs to evaluate expressions rather than write them, and the crate is published as cel on crates.io.
The three-object model: Env, Context and a compiled Program
The getting started example is short enough that the whole API shape is visible in it:
use cel::{Context, Env};
use std::sync::Arc;
fn main() {
let env = Arc::new(Env::stdlib());
let program = env.compile("add(2, 3) == 5").unwrap();
let mut context = Context::with_env(env);
context.add_function("add", |a: i64, b: i64| a + b).unwrap();
let value = program.execute(&context).unwrap();
assert_eq!(value, true.into());
}Three objects, three jobs. The Env is the environment, and Env::stdlib() gives you the standard library, so an expression can call the built-in functions without any setup. Compiling with env.compile turns a source string into a Program, and the compile step is separate from execution, which is the point: a service that evaluates the same expression for many requests compiles once and executes many times.
The Context is the variable and function scope for an execution. Context::with_env takes the environment, and the mutable context is where the host program injects its own functions, here through context.add_function with a closure that takes two i64 arguments. The value returned by execute is compared against true.into() in the assertion, which shows the result type is a CEL value rather than a plain bool, and the Into conversion is how a Rust bool becomes one.
The Arc around the Env is the concurrency story. The environment is immutable once built, so it can be shared across threads, and the context is the per-evaluation mutable part.
Custom functions, variables, and why the context is per-call
The examples directory in the repository shows the three things you will do beyond the basics: Simple, a simple example of how to use the library; Variables, passing variables and using them in your program; Functions, defining and using custom functions in your program; and Concurrent Execution, executing the same program concurrently.
The custom function mechanism in the main example is the one to understand first. A Rust closure is registered under a name, and the name becomes callable from the expression source, so the host application exposes exactly the operations it wants to expose. That is the security model in practice: an expression cannot reach a filesystem or a socket because nothing in the environment or the context gives it one.
Variables work the same way, with the host supplying named values that the expression reads, which is how the CEL examples in the readme work, where auth.claims.email_verified and resources come from somewhere.
The concurrent example is the reason the environment is wrapped in an Arc and the context is not. A compiled Program is safe to share between threads, while each evaluation needs its own Context, because a context holds the variables and functions for one execution and is mutated as the expression runs. Sharing one context across concurrent executions would be a data race, so the shape of the API pushes you towards the correct design rather than leaving it to be discovered.
That is the practical difference between CEL and a scripting language in a service. The expression is data, the host decides what it can touch, and the cost of an evaluation is bounded.
The workspace: conformance tests, fuzzing and a bench profile
The Cargo.toml is a workspace with members cel, example, fuzz and conformance, and the presence of two of those members is the strongest quality signal in the repository.
The conformance member is the one that matters for anyone adopting a CEL implementation. A language specification is only useful if implementations agree on it, and a conformance suite is how you find out whether this one agrees with the spec version your policies are written against. For a library whose entire value is executing expressions written by other people, running the conformance tests is the check you should run before you trust it.
The fuzz member is the second one. Fuzzing an expression parser and evaluator is the standard response to the fact that CEL programs come from outside your process. A parser that crashes on a malformed expression is a denial of service, and a parser that reads out of bounds is worse. The presence of a fuzz target directory is a claim that this class of bug is being looked for.
The profiles are also tuned with intent. The dev profile sets opt-level 1, which keeps debug builds workable while giving the optimiser something to work with, and the bench profile turns on lto, sets codegen-units to 1, opt-level to 3, and keeps debug info at 1 with a comment noting it is for line numbers with dhat. That last detail tells you the project measures allocations as well as time.
The repository also has a release.toml and a.release-plz.toml, so the release process is automated,.
What the crate does not give you, and where a different tool fits
CEL is small on purpose, and the size of the language is the source of both its virtues and its limits.
Because CEL is non-Turing complete, an expression cannot loop, cannot recurse freely, and cannot allocate without bound. That is the safety property, and it also means a request that needs a genuine computation over an unbounded collection, or a stateful workflow with several steps, is not expressible. The readme's Go example uses resources.all with a lambda, which is iteration over a finite list, and that is about as far as the expressive ceiling sits.
The second limit is standard library surface. Env::stdlib() gives the built-in functions, and the readme does not enumerate them. Before you commit, you need to know that the operations your policy expressions rely on, string manipulation, timestamp handling, duration arithmetic, list and map builtins, exist in this implementation in the form you expect. The CEL specification is the reference for that, and the readme points to the spec repository as the definition of the language.
The alternative approaches are worth naming so the comparison is concrete. A general scripting language embedded in the same process, such as a JavaScript or Lua runtime, gives you the full language at the cost of a sandboxing story you have to build and a resource profile you have to bound. Writing a bespoke evaluator in your own language gives you exactly the operations you need and nothing else, at the cost of building the parser, the type system and the testing yourself. A hosted policy decision service puts the evaluation behind a network call, which moves the sandboxing problem to a service boundary but adds latency and an availability dependency.
CEL and this crate sit in the middle: a portable, specified, sandboxed expression language with a real type system, evaluated in process, with a cost proportional to the expression's size.
Versioning, licence and the state of the project
The crate is at version 0.15.0, released on 2026-10-06, with v0.14.5 on 2026-09-07 and v0.14.4 on 2026-08-27. That is a release every few weeks on a 0.x line, which for a pre-1.0 library means the public API can still change, and the semver contract will not protect you from breaking changes until 1.0.
The repository is not archived, and the last push was on 2026-10-19. The licence is MIT, and the crate is published to crates.io as cel, with the crates.io page given as the homepage in the project listing.
One naming detail is worth flagging for anyone searching for this later. The repository path is cel-rust/cel-rust, but the package name in the workspace is cel, and the homepage points at the cel crate rather than at cel-interpreter. If you are evaluating this against other CEL implementations, check the crate name you actually depend on, because a Cargo.toml saying cel and a Cargo.toml saying cel-interpreter are different packages, and the import in the example, use cel::{Context, Env}, is what tells you which one this is.
The version and cadence facts together point to a project that is shipping regularly and is not yet finished. That is a reasonable thing to depend on if your expressions are simple and you pin the version, and a reason to keep the CEL specification, rather than the crate's docs, as the source of truth for your expression syntax.
Editorial conclusion
cel-rust fits a Rust service that needs to evaluate short policy or authorisation expressions supplied at runtime, where embedding a general scripting language would be too heavy or too risky, and it does not fit a team that needs an arbitrary programming language or a hosted evaluation service. Before adopting it, run the conformance suite in the workspace against the CEL spec version you depend on, read the CEL specification for the subset your policies need, and check the crate's licence and maintenance status for your own release cycle.
Frequently asked questions
What is the cel crate and what language does it implement?
It is a Rust implementation of the Common Expression Language, a non-Turing complete language designed for simplicity, speed, safety and portability, whose C-like syntax resembles equivalent expressions in C++, Go, Java and TypeScript. It is published on crates.io as cel.
How do I add cel-rust to a Rust project and evaluate an expression?
Run cargo add cel, then create an Env with Env::stdlib(), compile a source string with env.compile, build a Context::with_env, register any custom functions with context.add_function, and execute the program against the context. The readme's example compiles add(2, 3) == 5 with a custom add function and asserts the result equals true.
How do I expose custom functions and variables to a CEL expression?
You register Rust closures on the context with context.add_function, giving the name the expression will call, and pass variables through the same context. The example directory has dedicated files for variables and functions, and a separate one for executing the same program concurrently.
Why does the cel-rust workspace include conformance and fuzz members?
The workspace members are cel, example, fuzz and conformance. The conformance suite checks the implementation against the CEL specification, and fuzzing targets the parser and evaluator, which matters because expressions often come from outside the host process and a parser that crashes on malformed input is a denial of service.
Is cel-rust safe for expressions from untrusted users?
The language itself is non-Turing complete, so an expression cannot loop, and the host controls what an expression can reach because the environment and context expose only the functions and variables the host registers. The fuzz target in the workspace is aimed at the parser side of that risk, and the readme positions CEL as an alternative to a fully sandboxed scripting language.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/cel-rust-cel-rust)Was this useful?