Evaluation model
A CEL evaluation has three phases. Each runs at a different time, with different cost profile and different ways to fail.
Phase 1 — Parse (cheap, runs once)
CelExpression.Compile(source, env)// └─ internally: Parsing.Parser.Parse(source, ...)The hand-written lexer + Pratt parser produces an AST plus source-info
metadata (line/column positions). It also expands macros (has, all,
exists, comprehensions, plus any contributed by extensions).
If the source has syntax errors, you get a CelCompileException with all
diagnostics aggregated.
Cost: linear in source length. Microseconds for typical rules.
Phase 2 — Type check (cheap, runs once)
The checker walks the AST, resolves identifiers against the env, dispatches
overloads, unifies type parameters, and decorates every node with its
CelType. The output is a CheckedAst.
If a function call is ambiguous, an identifier is undeclared, or a type
constraint can’t be satisfied, you again get a CelCompileException.
Cost: linear in AST size, but with a constant factor for overload resolution. Still microseconds.
Phase 3 — Evaluate (hot path)
program.Eval(activation)A tree-walking evaluator. Each AST node maps to a small visitor method that calls into:
- the
IActivationfor variable lookups - the
FunctionRegistryfor function/operator dispatch (binding overload id → implementationOverloadFn) - the
ITypeProviderfor object reads/writes/has/construct/projection - the
PocoAdapterfor reflection-based field access on plain CLR types when no provider claims a value
The evaluator is intentionally simple — there is no JIT, no caching of
sub-tree results, no AST rewriting at runtime. The perf comes from CEL’s
tightness: small expressions, short ASTs, almost no allocation in the hot
path beyond the result CelValues.
Cost: depends on the expression. A field access + comparison is hundreds of nanoseconds; a comprehension over a 1k-element list is a few microseconds plus the cost of the body.
Compile once, evaluate many
The first two phases produce a reusable CompiledProgram. The compiled state
includes:
- the checked AST (immutable),
- a function registry built from the env’s stdlib + extensions (immutable once built),
- a reference to the type provider (shared).
A CompiledProgram is safe to share across threads for evaluation. The
activation you pass to Eval is your responsibility — typically scoped to
one request.
public sealed class PolicyService{ private readonly CompiledProgram _program;
public PolicyService(string rule, CelEnv env) { _program = CelExpression.Compile(rule, env); }
public bool Allows(Request req) => (bool)_program.Eval(new { req })!;}Activations: how variables flow in
An IActivation is just bool TryResolve(string name, out object? value).
Three built-in implementations cover most cases:
MapActivation— wraps anIReadOnlyDictionary<string, object?>. The default forprogram.Eval(IDictionary).ObjectActivation— reflects a single root POCO and exposes its public properties / fields by name. The default forprogram.Eval(object).ChainedActivation— tries each child in order; first to claim wins. Useful for layering “globals” under per-request bindings.
You can implement your own — e.g. a LazyActivation that fetches expensive
values only when CEL actually references them.
Errors short-circuit through logical operators
Per the spec:
true || error → truefalse || error → errorfalse && error → falsetrue && error → errortrue ? a : error → afalse ? error : b → bInternally, the runtime represents an error as ErrorValue. It only escapes
to your code as a CelEvaluationException when the top-level result is
an error — i.e. nothing short-circuited it.
This is why program.Eval(...) is much “calmer” than CLR equivalents — many
runtime issues that would throw in normal code are absorbed by the operator
semantics.
Unknowns (partial evaluation)
The runtime supports an UnknownValue value type. It marks an attribute that
the host hasn’t supplied yet. Logical operators short-circuit through
unknowns the same way they do errors. The unknowns.textproto corpus drives
this; the .NET implementation does not yet emit unknowns from public APIs —
it’s a planned feature for partial-evaluation use cases.
See Errors & unknowns for the full hierarchy.
Performance anchors
Rough orders of magnitude on a modern x86 / ARM machine, single-threaded:
| Operation | Time |
|---|---|
Compile (10-line rule) | 50–200 µs |
Eval (5-op boolean over POCOs) | 0.5–2 µs |
Eval (comprehension over 1k items, simple body) | 50–200 µs |
| Compile-then-eval for one-shot use | almost always pay once for the first |
The dominant runtime cost in well-formed programs is reflection on POCO
fields. Use ITypeProvider for hot types if you measure it as the bottleneck
— see Performance & trimming.