Language Tour
CEL by example: types, operators, strings, lists, maps, and conversions.
A tour of the CEL language as it behaves in Octo, one example at a time. Every
expression on this page was evaluated with octo eval, and the results are shown exactly as it
prints them — as JSON-native values. Try any of them yourself:
bin/octo eval --expr '[1, 2, 3].filter(x, x % 2 == 1)'
# {"ok":true,"result":[1,3]}Types and literals
CEL has eight scalar types — int, uint, double, bool, string,
bytes, timestamp, duration — plus list, map, and null.
| Expression | Result |
|---|---|
42 | 42 |
-7 | -7 |
0x10 | 16 |
123u | 123 (a uint literal) |
1.5 | 1.5 |
2.0e3 | 2000 |
true | true |
"hello" | "hello" |
b'abc' | "YWJj" (bytes render as base64 in JSON output) |
[1, 2, "three"] | [1,2,"three"] |
{"name": "Ada", "age": 36} | {"age":36,"name":"Ada"} |
null | null |
Ints, uints, and doubles are distinct types — 1, 1u, and 1.0 are three
different values, and mixing them in operators is a compile error (see
Equality and numeric types).
Operators
Arithmetic, comparison, logical operators, and the ternary conditional.
| Expression | Result |
|---|---|
2 + 3 * 4 | 14 |
10 - 4 | 6 |
7 / 2 | 3 (integer division truncates) |
7.0 / 2.0 | 3.5 |
7 % 2 | 1 |
1 < 2 | true |
2 >= 2 | true |
"a" != "b" | true |
"Hello" < "World" | true (strings compare lexicographically) |
true && false | false |
true || false | true |
!true | false |
1 < 2 ? "yes" : "no" | "yes" |
"foo" + "bar" | "foobar" |
Runtime failures like 1 / 0 surface as evaluation errors (division by zero), which in a flow fail the message into its error
path.
Strings
| Expression | Result |
|---|---|
"hello".contains("ell") | true |
"hello".startsWith("he") | true |
"hello".endsWith("lo") | true |
"hello".matches("^h.*o$") | true |
"h3llo".matches("[0-9]") | true |
size("hello") | 5 |
"quota: " + string(85) + "%" | "quota: 85%" |
matches uses RE2 syntax — no
backreferences or lookaheads. It is also available as a global:
matches("hello", "^h") is the same as "hello".matches("^h").
Octo's CEL environment does not enable the optional strings extension:
helpers such as split, lowerAscii, replace, and string indexing
("abc"[0]) are not available and fail to compile. Build strings with +,
string(), and matches, or reshape data with an AI
Mapping block when transformation gets complex.
Lists and maps
| Expression | Result |
|---|---|
[10, 20, 30][1] | 20 |
{"a": 1}["a"] | 1 |
{"a": 1}.a | 1 |
{"a": {"b": {"c": 42}}}.a.b.c | 42 |
2 in [1, 2, 3] | true |
"a" in {"a": 1} | true (tests keys, not values) |
size([1, 2, 3]) | 3 |
size({"a": 1, "b": 2}) | 2 |
[1, 2] + [3, 4] | [1,2,3,4] |
Indexing past the end of a list or reading a missing key is an evaluation
error (no such key), not null — see Presence and
defaults. There is no slice syntax ([1,2,3][1:2]
does not parse) and maps cannot be concatenated with +.
Presence and defaults
Accessing a field that does not exist is an error, so guard optional fields
with has() or the in operator before reading them. These examples bind
body with --data:
| Expression | --data | Result |
|---|---|---|
has(body.name) | {"name": "Ada"} | true |
has(body.name) | {} | false |
has(body.user.email) | {"user": {"id": 1}} | false |
"name" in body | {"name": "Ada"} | true |
body.missing | {} | error: no such key: missing |
The idiomatic default is a ternary:
bin/octo eval --expr 'has(body.name) ? body.name : "anonymous"' --data '{}'
# {"ok":true,"result":"anonymous"}has() takes a field selection (has(body.name)), not a bare variable or an
index expression. For map keys held in variables, use key in map instead.
Type conversions
| Expression | Result |
|---|---|
string(42) | "42" |
string(3.14) | "3.14" |
string(true) | "true" |
int("42") | 42 |
int(3.9) | 3 (truncates toward zero) |
int(-3.9) | -3 |
double("3.14") | 3.14 |
double(2) | 2 |
uint(42) | 42 |
bool("true") | true |
bytes("hi") | "aGk=" |
string(b'hi') | "hi" |
timestamp("2026-01-01T00:00:00Z") | "2026-01-01T00:00:00Z" |
timestamp(1767225600) | "2026-01-01T00:00:00Z" (from Unix seconds) |
int(timestamp("2026-01-01T00:00:00Z")) | 1767225600 |
duration("1h30m") | "5400s" |
type(x) returns a value's type and is useful in comparisons —
type("a") == string evaluates to true — but a type itself is not a
JSON-serializable result, so an expression must not return one, and
string(type(x)) does not compile.
Timestamps and durations
timestamp parses RFC 3339 strings, duration parses Go-style duration
strings ("300ms", "90m", "1h30m", "24h"). The variable now is bound
to the evaluation time. Timestamps render back as RFC 3339 strings and
durations as seconds.
| Expression | Result |
|---|---|
timestamp("2026-01-01T00:00:00Z") + duration("24h") | "2026-01-02T00:00:00Z" |
timestamp("2026-01-02T00:00:00Z") - timestamp("2026-01-01T00:00:00Z") | "86400s" |
duration("1h") + duration("30m") | "5400s" |
duration("1h") < duration("90m") | true |
timestamp("2020-01-01T00:00:00Z") < timestamp("2026-01-01T00:00:00Z") | true |
now > timestamp("2020-01-01T00:00:00Z") | true |
string(now) | "2026-07-09T20:26:32.867835-07:00" |
Accessor methods extract components. Note the conventions: getMonth() is
zero-based (January is 0), getDayOfWeek() is zero-based from Sunday,
getDayOfMonth() is zero-based while getDate() is one-based.
| Expression | Result |
|---|---|
timestamp("2026-07-09T12:30:45Z").getFullYear() | 2026 |
timestamp("2026-07-09T12:30:45Z").getMonth() | 6 (July, zero-based) |
timestamp("2026-07-09T12:30:45Z").getDate() | 9 (day of month, one-based) |
timestamp("2026-07-09T12:30:45Z").getDayOfMonth() | 8 (zero-based) |
timestamp("2026-07-09T12:30:45Z").getDayOfYear() | 189 |
timestamp("2026-07-09T12:30:45Z").getDayOfWeek() | 4 (Thursday; Sunday is 0) |
timestamp("2026-07-09T12:30:45Z").getHours() | 12 |
timestamp("2026-07-09T12:30:45Z").getMinutes() | 30 |
timestamp("2026-07-09T12:30:45Z").getSeconds() | 45 |
timestamp("2026-07-09T12:30:45.123Z").getMilliseconds() | 123 |
timestamp("2026-07-09T12:30:45Z").getHours("America/New_York") | 8 (accessors take an optional IANA time zone) |
On durations, the accessors convert the whole value:
duration("90m").getHours() is 1, duration("90m").getMinutes() is 90,
and duration("2h").getSeconds() is 7200.
Equality and numeric types
Equality is deep for lists and maps, and CEL refuses to compile comparisons between statically known distinct types:
| Expression | Result |
|---|---|
[1, 2] == [1, 2] | true |
{"a": 1} == {"a": 1} | true |
null == null | true |
b"raw" == b"raw" | true |
1 == 1.0 | compile error: no matching overload for '_==_' applied to '(int, double)' |
1 == 1u | compile error |
"1" == 1 | compile error |
dyn(1) == dyn(1.0) | true (dynamic values compare numerically) |
That last row matters in practice: message data is dynamically typed, and JSON
numbers decode as doubles, so comparisons against int literals work —
body.n == 1 and body.n > 0 are both true for --data '{"n": 1}'.
Arithmetic is stricter: body.n + 1 fails at runtime with no such overload
because a double cannot be added to an int. Write body.n + 1.0, or convert:
int(body.n) + 1.