# cel-go: embedding CEL expression evaluation in Go services

> cel-go is the Go implementation of the Common Expression Language, a non-Turing complete evaluator for policy-style expressions. It is for Go teams that need user-supplied conditions without running a scripting runtime, and its main constraint is that you must design the type environment before anything compiles.

**cel-expr/cel-go** — Fast, portable, non-Turing complete expression evaluation with gradual typing (Go)

- Repository: https://github.com/cel-expr/cel-go
- Website: https://cel.dev
- Stars: 3,118 · Forks: 310
- Language: Go
- License: Apache-2.0
- Published: 2026-09-24 · Updated: 2026-09-24 · Language: en
- Canonical page: https://hysenlabs.com/projects/cel-expr-cel-go

## The problem cel-go solves for Go services

Configuration and policy drift toward code. A service needs an authorization check, a routing rule, a validation predicate, and the first version is a Go function. Then someone wants per-tenant variation, and the function grows an if-chain. Then someone wants to edit the rule without a deploy, and the team starts talking about embedding a scripting language, which brings a parser, an evaluator, resource limits and a sandbox to maintain.

CEL sits in the middle. The README describes it as "a non-Turing complete language designed for simplicity, speed, safety, and portability", and the intended use is "lightweight expression evaluation when a fully sandboxed scripting language is too resource intensive". A CEL program is a single expression, not a script with statements. There is no loop construct in the core language; bounded iteration arrives through macros such as all, exists, exists_one, filter and map, which are expanded at parse time.

The audience is therefore narrow and specific. If you are a Go team exposing conditions to tenants, admins or config authors, and those conditions are predicates over data you already have, cel-go is the evaluator. If you want users to write functions, accumulate state or call arbitrary APIs, CEL is the wrong shape and the README does not pretend otherwise.

## Environment, parse, check, evaluate: the actual data flow

The README lays out three phases and recommends that you respect the split. First you declare an environment: which variables exist and what types they have, via cel.NewEnv and cel.Variable. Second you parse and check, combined into env.Compile, which the README calls "more computationally expensive than evaluation" and recommends doing ahead of time. Third you build a cel.Program from the checked AST and call Eval with a map of inputs.

The output of Compile is an AST plus an issues value. The output of env.Program is described in the README as "stateless, thread-safe, and cachable", which is the property that makes this design work in a server: compile once at startup or at config load, keep the program, and evaluate it per request with different inputs.

Type-checking is optional but the README calls it "strongly encouraged", and gives a concrete reason beyond correctness: the check produces metadata that can improve function invocation performance and object field selection at evaluation time. Skipping the check is possible; you give up static rejection of semantically invalid expressions and that metadata.

Evaluation itself is side-effect free, and inputs that are present but unreferenced are ignored. The README also documents partial state, where a variable is missing at evaluation time. CEL uses commutative logical operators so that `false && ?` is false and `<x> || true` is true, meaning an unknown on one side does not necessarily poison the result. The README states this choice aligns with SQL evaluation semantics and is more tolerant of dynamic JSON inputs. Note the distinction it draws: an error and an unknown value are not the same thing.

## Installing cel-go and running a first expression

The module path in go.mod is cel.dev/cel-go, so that is what goes in your require block. The README also points readers at a Codelab for a guided start, and the repository carries an examples/ directory with files such as example_cel_compile_test.go and example_cel_custom_functions_test.go if you prefer reading runnable code to prose.

Add the dependency with the module path the repository declares:

```bash
go get cel.dev/cel-go
```

The go.mod in the repository pins a minimum Go version of 1.23.0, and its direct requirements include cel.dev/expr, github.com/antlr4-go/antlr/v4, github.com/google/go-cmp, go.yaml.in/yaml/v3, google.golang.org/genproto/googleapis/api and google.golang.org/protobuf. Expect the protobuf and ANTLR dependencies to appear in your own go.sum.

A minimal program follows the README's own sequence: declare variables, compile, build a program, evaluate. The README's example expression checks whether a name starts with a group path.

```go
import "cel.dev/cel-go/cel"

env, err := cel.NewEnv(
    cel.Variable("name", cel.StringType),
    cel.Variable("group", cel.StringType),
)
if err != nil {
    log.Fatal(err)
}
```

With the environment in hand, compile and check. The README's example reports a type-check error through issues.Err() and a program construction error separately, which is worth copying because the two failures mean different things to an operator.

```go
ast, issues := env.Compile(`name.startsWith("/groups/" + group)`)
if issues != nil && issues.Err() != nil {
    log.Fatalf("type-check error: %s", issues.Err())
}
prg, err := env.Program(ast)
if err != nil {
    log.Fatalf("program construction error: %s", err)
}
```

Finally, evaluate. The README's example passes a map and prints the boolean result, which should be true for the values shown.

```go
out, details, err := prg.Eval(map[string]interface{}{
    "name": "/groups/acme.co/documents/secret-stuff",
    "group": "acme.co"})
fmt.Println(out) // 'true'
```

The details value is empty unless you enable intermediate state tracking through a cel.ProgramOption; the README mentions cel.EvalOptions(cel.OptTrackState) as the way to turn that on, and describes details as useful for visualizing how the output was reached. Leave it off in production paths and turn it on when you are debugging a rule.

## Where cel-go stops being the right tool

The non-Turing complete design is a feature until it is a wall. There is no general loop, no recursion, no assignment and no side effects, so any rule that needs to accumulate across iterations has to be expressed with the macros the README lists: all, exists, exists_one, filter and map. Those are bounded folds over lists and maps, not a general iteration primitive. If your users keep asking for a counter that increments per element, you are fighting the language, and the honest answer is that a scripting runtime is the correct dependency for that requirement.

The second constraint is the environment. Every variable and function must be declared before compilation, with types. That is what makes static checking possible, and it is also the part teams underestimate: an expression language that accepts arbitrary JSON blobs is easier to ship and harder to reason about. cel-go makes you do the modelling work up front. The README notes CEL has first-class support for JSON and Protocol Buffers, which softens this if your data is already protobuf-shaped, but dynamic inputs still need a declared type.

The third is the compile step itself. Compilation is more expensive than evaluation by the README's own statement, so compiling per request is a design mistake. The program is thread-safe and cachable, which means the intended shape is a cache keyed by expression text, populated at startup or on config change. The README does not document rollback, versioning or migration for expressions you have already stored, so if your expressions live in a database you own that problem entirely. Nothing in the repository layout suggests a built-in answer: there is a policy/ directory and a conformance/ suite, but the README does not describe an expression registry or a migration story.

## cel-go compared with a general-purpose embedded language

The obvious alternative for a Go service is an embedded scripting language: a Lua binding such as gopher-lua, or a JavaScript engine such as goja. The difference is not speed, it is the shape of the contract.

An embedded scripting language gives users statements, loops, functions and mutable state. That generality is exactly what the CEL README trades away. CEL's core language is a single expression, and the features that would otherwise need language-level syntax, field presence testing and bounded iteration, are exposed as macros that expand at parse time and are type-checked at check time. So the comparison is: scripting engines let you express more and require you to bound execution yourself, while CEL lets you express less and gets static checking, a stateless thread-safe program object and partial-state semantics from the design.

There is a family dimension too. CEL is not a Go-only project; the README frames the syntax as shared across C++, Go, Java and TypeScript, and cel-go is the Go implementation of that specification, with the conformance/ directory in the repository reflecting that specification-driven approach. If your rules must be authored once and evaluated by services in several languages, that portability argument is the strongest reason to pick CEL over a Go-specific scripting engine. If your rules only ever run in one Go binary and need real control flow, the scripting engine is the smaller conceptual gap.

One practical note on the module path: the README carries a warning that on June 16, 2026 the repository moves to github.com/cel-expr/cel-go and asks readers to update links and dependencies, pointing at a pinned issue for details. Check which path your dependency graph resolves before you build a vendoring or mirroring policy around it.

## Maintenance, releases and licence

The repository is not archived, and the last push was on 2026-09-23. Releases are frequent and numbered in the v0.x series: v0.32.0 on 2026-08-19, v0.31.0 on 2026-08-07 and v0.30.0 on 2026-07-26. A pre-1.0 version line moving this often means minor releases can carry behavioural or API changes, so pinning a version in go.mod and reading release notes before bumping is the practical upgrade policy. The README does not describe a deprecation window or a compatibility guarantee for the v0.x line, so treat each minor bump as something to test rather than absorb.

The dependency surface is modest but not trivial: cel.dev/expr, antlr4-go/antlr/v4, google/go-cmp, go.yaml.in/yaml/v3, genproto/googleapis/api and google.golang.org/protobuf as direct requirements, with golang.org/x/exp, golang.org/x/text and genproto/googleapis/rpc indirect. A go.mod minimum of Go 1.23.0 sets the floor for your build.

Licensing is Apache-2.0, per the LICENSE file and the repository metadata. Apache-2.0 is a permissive licence with an explicit patent grant and requires that you retain notices and state changes. It is compatible with typical commercial Go services. This is a description of the licence, not legal advice; if you redistribute cel-go inside a product, have your own counsel confirm the notice obligations.

## Conclusion

Adopt cel-go when the expressions you accept are conditions over data you already model in Go or protobuf, and when you can afford a compile step at startup or config-load time. Do not adopt it as a general scripting layer: CEL is non-Turing complete, so loops, recursion and side effects are outside its design, and the README does not document rollback or versioning for stored expressions. Before committing, verify the module path you depend on (the README warns the repository moves to github.com/cel-expr/cel-go on June 16, 2026), confirm the Go version in go.mod, which is 1.23.0, and run one real expression through env.Compile and prg.Eval to see the error shape your service will have to handle.

## FAQ

### What does CEL stand for in cel-go?

CEL stands for Common Expression Language. The repository is the Go implementation of that language, published under the module path cel.dev/cel-go.

### What is Google CEL and how does cel-go relate to it?

The README describes the Common Expression Language as a non-Turing complete language designed for simplicity, speed, safety and portability, with a C-like syntax that looks nearly identical to equivalent expressions in C++, Go, Java and TypeScript. cel-go is the Go implementation of that language.

### Is cel-go a company?

No. cel-go is a Go library hosted in the cel-expr organization; the search results about a company named Cel refer to something else entirely.

## Sources

- [cel-expr/cel-go on GitHub](https://github.com/cel-expr/cel-go)
- [License: Apache-2.0](https://github.com/cel-expr/cel-go/blob/master/LICENSE)
- [Project website](https://cel.dev)
- [README](https://github.com/cel-expr/cel-go/blob/master/README.md)
- [Releases](https://github.com/cel-expr/cel-go/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/cel-expr-cel-go
