cel-spec: the specification behind CEL expressions
Common Expression Language -- specification and binary representation
At a glance
- What is it?
- cel-spec is not an evaluator. It is the language definition, the conformance suite and the canonical protobuf schemas that every CEL implementation is measured against, and that distinction decides whether it belongs in your build.
- Who is it for?
- Adopt cel-spec if you are writing an evaluator, a compiler, or tooling that must agree byte for byte with other CEL implementations, and start by reading doc/langdef.md and the proto/cel/expr directory before you write a parser. Do not adopt it if you only need to evaluate expressions in an application: use cel-go, CEL-Java, CEL-Python or CEL-js, which are the runtime libraries, and treat this repository as the contract they answer to.
- Can I use it commercially?
- Yes. Apache-2.0 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 last received commits 14 days ago.
- What is it written in?
- Mainly Starlark, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 30, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What cel-spec actually is, and who needs it
The README opens with a definition that is easy to misread: CEL "implements common semantics for expression evaluation, enabling different applications to more easily interoperate." The repository is the place those semantics are written down. It contains doc/intro.md and doc/langdef.md for the language, proto/cel/expr for the canonical protobuf schemas, and a conformance/ directory for the test corpus. The generated Go files at the top level, syntax.pb.go, checked.pb.go, eval.pb.go, explain.pb.go and value.pb.go, are the compiled form of those schemas. The go.mod declares the module as cel.dev/expr and depends on google.golang.org/protobuf v1.36.10.
The intended audience is narrow. If you are building an evaluator, a policy compiler, a linter, or any tool that has to agree with another CEL implementation about what an expression means, this is the reference you work against. If you are an application developer who wants to let users write conditions in a config file, you want one of the runtime libraries instead. The README names two application areas, security policy and protocols, and both are cases where two independent systems must reach the same answer from the same expression.
The four components a CEL system has to provide
The README lists the required components of any system that supports CEL, and that list is effectively the architecture. First, the textual representation a developer writes, with syntax similar to C/C++/Java/JavaScript. Second, an abstract syntax tree. Third, a compiler library that turns text into the binary representation, and the README notes this can happen ahead of time in the control plane or just before evaluation in the data plane. Fourth, a context holding typed variables, usually protobuf messages, with attribute_context.proto called out as the common choice. Then an evaluator that takes the binary format plus the context and returns a result, usually a Boolean.
The split between compile time and evaluation time is the part worth pausing on. Because the binary form is a protocol buffer, a control plane can type-check an expression once and ship the serialized AST to a data plane that never parses text. The README recommends exactly this for use cases that need persistence or cross-process communication. That is a design decision with a cost: the serialized form becomes a compatibility surface between your services, and the README commits the CEL team to keeping the canonical protobufs identical and wire-compatible in perpetuity.
Working with cel-spec: the module, the schemas and the first example
There is no package to install in the usual sense. The repository is consumed as a Go module, as protobuf definitions, and as a specification document. The go.mod declares the module path cel.dev/expr and requires google.golang.org/protobuf v1.36.10, so a Go program that needs the generated types imports them from that module.
module cel.dev/expr
go 1.23.0
toolchain go1.24.9
require google.golang.org/protobuf v1.36.10That is the module declaration as it appears in go.mod. The generated types live in the root package, so an import of cel.dev/expr gives you the syntax, checked, eval, explain and value messages that correspond to the .proto files under proto/cel/expr.
If you are writing an implementation in another language, you do not install anything. You read the language definition and you run the conformance corpus. The repository keeps a conformance/ directory for that purpose, and the top level also carries checked.pb.go, eval.pb.go, syntax.pb.go and value.pb.go as the compiled schemas to compare against. The regeneration scripts regen_go_proto.sh and regen_go_proto_canonical_protos.sh show how the generated files are produced from the proto sources, which is the workflow to copy if you maintain your own bindings.
The README's own example is the quickest way to see what the language looks like in practice. It gives a boolean condition and an object construction:
account.balance >= transaction.withdrawal
|| (account.overdraftProtection
&& account.overdraftLimit >= transaction.withdrawal - account.balance)
common.GeoPoint{ latitude: 10.0, longitude: -5.5 }The first is the kind of expression a policy engine evaluates against a context. The second shows that CEL can build protobuf messages, not just return booleans. If you are implementing a parser, these two lines tell you the two shapes you have to support before anything else works.
The limits are deliberate, and they bite
CEL evaluates in linear time, is mutation free, and is not Turing-complete. The README calls this limitation a feature of the language design and says it lets the implementation evaluate orders of magnitude faster than equivalently sandboxed JavaScript. That is a real constraint, not a marketing line. There are no loops, no recursion, no user-defined state. If your problem is "walk this list and accumulate until a condition changes," CEL is the wrong tool and no amount of embedding will fix it.
The second limitation is structural. The README says most use cases will use attribute_context.proto for the variable context. That means the data an expression can see has to be shaped into protobuf messages first. Applications with plain JSON, maps of strings, or objects that only exist inside a running process need an adapter layer before CEL sees anything. That layer is where most integration bugs live, and the specification does not remove it.
The third is versioning. The README points at two schema locations, the canonical protobufs under proto/cel/expr in this repository and the older CEL v1alpha1 definitions in googleapis. Anyone maintaining a long-lived evaluator has to decide which one their wire format follows, and the answer is not reversible once expressions have been persisted.
cel-spec against cel-go, CEL-Java and the other runtimes
The most common confusion is treating cel-spec as an alternative to cel-go, CEL-Java, CEL-Python or CEL-js. They are not alternatives. cel-go and its siblings are evaluator libraries that implement the semantics this repository defines. If you want to evaluate expressions inside a Go service, cel-go is the dependency; cel-spec is what cel-go is tested against. The distinction matters when you file a bug: if your expression returns the wrong value, the question is whether your runtime deviates from the specification, and the conformance corpus is how you find out.
The comparison that is genuinely a choice is between the two protobuf lineages. The README lists the canonical protobufs in this repository and the CEL v1alpha1 definitions in googleapis. New work should follow the canonical set, which is the one the CEL team maintains here and commits to keeping wire-compatible. Choosing v1alpha1 means tracking a schema that lives in a different repository with a different release cadence.
Maintenance, licensing and upgrade cost
The last push to the default branch was on 2026-09-16, and the most recent release listed is v0.25.3 on 2026-08-13. Releases are not frequent: v0.25.2 came on 2026-05-08 and v0.25.1 on 2025-11-10. For a specification repository that cadence is reasonable, because the artifact that matters is the wire format, and the README commits to keeping the canonical protobuf versions identical and wire-compatible in perpetuity. A slow release train is consistent with that promise rather than a sign of neglect.
Upgrade cost depends on which surface you consume. If you import the Go module, a version bump can change generated code, and the regeneration scripts in the repository show the pipeline the maintainers use. If you implement the language yourself, the cost is re-running the conformance corpus against a new tag. If you only read doc/langdef.md, upgrades cost you nothing until the language definition changes.
The licence is Apache-2.0, stated in the README and in the LICENSE file. That is a permissive licence with an explicit patent grant, which is the usual choice for a specification that vendors are expected to implement independently. It does not tell you whether your own implementation's licence is compatible with the schema files you copy, and that question belongs with your legal team rather than with this review.
Editorial conclusion
Adopt cel-spec if you are writing an evaluator, a compiler, or tooling that must agree byte for byte with other CEL implementations, and start by reading doc/langdef.md and the proto/cel/expr directory before you write a parser. Do not adopt it if you only need to evaluate expressions in an application: use cel-go, CEL-Java, CEL-Python or CEL-js, which are the runtime libraries, and treat this repository as the contract they answer to. Before committing, verify that the canonical protobufs under proto/cel/expr match the wire format your evaluator already emits, because the README states the CEL team keeps those versions identical and wire-compatible in perpetuity, and a mismatch there is the one thing you cannot paper over later.
Frequently asked questions
Is CEL Turing complete?
No. The README states that CEL evaluates in linear time, is mutation free, and is not Turing-complete, and it describes that limitation as a feature of the language design.
What does CEL mean in cel-spec?
CEL stands for Common Expression Language. The README describes it as implementing common semantics for expression evaluation so that different applications can interoperate more easily.
What are the CEL rules for writing an expression?
The repository documents the language in doc/langdef.md and doc/intro.md. The README gives the shape of the syntax with a boolean condition such as account.balance >= transaction.withdrawal and an object construction such as common.GeoPoint{ latitude: 10.0, longitude: -5.5 }.
What is CEL validation?
The README does not use the term validation. It describes a compiler library that converts the textual representation into the binary representation, which can be done ahead of time in the control plane, and it recommends serializing the type-checked expression as a protocol buffer for persistence or cross-process communication.
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-expr-cel-spec)