<img src="https://github.com/RecoLabs/gnata/raw/v0.2.2/assets/gnata-light.png" alt="gnata" width="720" />
A full JSONata 2.x implementation in Go, built for production streaming workloads.
Quick Start · Streaming API · Metrics · Performance · Compatibility · WASM Playground · Contributing · Security
JSONata is a lightweight query and transformation language for JSON data — think "jq meets XPath with lambda functions." gnata brings the full JSONata 2.x specification to Go, with a production-grade streaming tier designed for evaluating thousands of expressions against millions of events per day with zero contention.
StreamEvaluator batches multiple expressions per event with schema-keyed plan caching. After warm-up, the hot path uses only atomic loads — no mutexes, no RWLocks, no channels.user.email = "admin@co.com" evaluate with 0 heap allocations via GJSON zero-copy string views.WithMaxCachedSchemas), evicting the oldest entry on overflow.context.Context for cancellation and timeouts. Long-running expressions check context at loop boundaries.regexp (RE2 engine) for guaranteed linear-time matching with no timeouts or backtracking.tidwall/gjson for fast-path byte-level field extraction.package main
import (
"context"
"fmt"
"github.com/recolabs/gnata"
)
func main() {
expr, _ := gnata.Compile(`Account.Order.Product.Price`)
data := map[string]any{
"Account": map[string]any{
"Order": []any{
map[string]any{"Product": map[string]any{"Price": 34.45}},
map[string]any{"Product": map[string]any{"Price": 21.67}},
},
},
}
result, _ := expr.Eval(context.Background(), data)
fmt.Println(result) // [34.45 21.67]
}
gnata provides three evaluation tiers, each building on the previous:
Eval(context.Context, any)Evaluate against pre-parsed Go values. Pass a context for cancellation and timeouts.
expr, err := gnata.Compile(`$sum(orders.amount)`)
result, err := expr.Eval(ctx, data) // data is map[string]any
EvalBytes(context.Context, json.RawMessage)Evaluate directly against raw JSON bytes. For fast-path-eligible expressions, fields are extracted via GJSON with zero-copy — the entire document is never materialized.
expr, _ := gnata.Compile(`user.email = "admin@example.com"`)
result, _ := expr.EvalBytes(ctx, rawJSON) // rawJSON is json.RawMessage
fmt.Println(expr.IsFastPath()) // true — zero-copy evaluation
StreamEvaluatorBatch-evaluate multiple compiled expressions against each event in a streaming pipeline. Schema-keyed plan caching deduplicates field extraction across expressions. Lock-free after warm-up.
se := gnata.NewStreamEvaluator(nil,
gnata.WithPoolSize(500),
gnata.WithMaxCachedSchemas(50000),
)
// Compile expressions (goroutine-safe)
indices := make([]int, len(rules))
for i, rule := range rules {
indices[i], _ = se.Compile(rule.Expr)
}
// Hot path — millions of times, hundreds of goroutines
results, _ := se.EvalMany(ctx, eventBytes, schemaKey, indices)
for i, result := range results {
if result != nil {
handleMatch(indices[i], result)
}
}
// Alternative: pre-decoded map input (avoids re-serialization)
results, _ = se.EvalMap(ctx, fieldMap, schemaKey, indices)
Register domain-specific functions via WithCustomFunctions on StreamEvaluator or NewCustomEnv for standalone expressions. See Custom Functions below.
The StreamEvaluator is designed for high-throughput event processing where the same expressions are evaluated against millions of structurally similar events.
Startup (once)
Compile N expressions ──> Analyze AST: classify fast/full
Configure stable routing: schemaKey -> exprIndices
Hot Path (millions/day, lock-free)
Raw json.RawMessage event + schemaKey
├── BoundedCache lookup (atomic pointer read)
│ ├── HIT ──> Immutable GroupPlan
│ └── MISS ──> Build plan (merge GJSON paths, atomic CAS store)
├── gjson.GetBytes per fast-path expression
├── Fast-path expressions: distribute extracted results (0 allocs)
├── Full-path expressions: selective unmarshal + AST eval
└── results[]
gjson.GetBytes for zero-copy path lookups directly on raw JSON bytes.GroupPlan (merged paths, expression groupings, selective unmarshal targets) is computed once per schema key and reused immutably.BoundedCache publishes an atomic.Pointer snapshot on every write; reads scan the snapshot without acquiring a lock. Writes are serialised by a mutex.items array from a 10KB event), not the entire document.EvalMap accepts map[string]json.RawMessage directly, skipping full-document serialization when the caller already has individually-encoded fields. Fast paths resolve top-level keys via O(1) map lookup.Replace, Remove, and Reset methods allow modifying registered expressions at runtime with automatic cache invalidation.MetricsHook to receive per-evaluation callbacks for cache hits/misses, eval latency, fast-path usage, and errors.gnata supports registering user-defined functions that extend the standard JSONata library.
Custom functions implement the CustomFunc signature:
type CustomFunc func(args []any, focus any) (any, error)
Where args are the evaluated arguments passed by the JSONata expression and focus is the current context value.
Register custom functions via WithCustomFunctions when creating a StreamEvaluator:
customFuncs := map[string]gnata.CustomFunc{
"md5": func(args []any, focus any) (any, error) {
if len(args) == 0 || args[0] == nil {
return nil, nil
}
h := md5.Sum([]byte(fmt.Sprint(args[0])))
return fmt.Sprintf("%x", h), nil
},
"parseEpochSeconds": func(args []any, focus any) (any, error) {
// Convert epoch seconds to ISO 8601
f, ok := args[0].(float64)
if !ok {
return nil, nil
}
return time.Unix(int64(f), 0).UTC().Format(time.RFC3339), nil
},
}
se := gnata.NewStreamEvaluator(nil,
gnata.WithCustomFunctions(customFuncs),
gnata.WithMaxCachedSchemas(10000),
)
// Expressions can now use $md5() and $parseEpochSeconds()
idx, _ := se.Compile(`$md5(user.email)`)
result, _ := se.EvalOne(ctx, eventJSON, "schema1", idx)
For one-off evaluations with custom functions, create a custom environment and pass it to EvalWithCustomFuncs:
env := gnata.NewCustomEnv(customFuncs)
expr, _ := gnata.Compile(`$md5(payload.email)`)
result, _ := expr.EvalWithCustomFuncs(ctx, data, env)
The environment should be created once and reused across evaluations for best performance.
The StreamEvaluator accepts an optional MetricsHook (via WithMetricsHook) for production telemetry. Implement the interface and wire it in — a nil hook (the default) adds zero overhead.
| Callback | Arguments | What to monitor |
|---|---|---|
OnEval |
exprIndex, fastPath, duration, err |
Fast-path ratio; per-expression latency |
OnCacheHit |
schemaKey |
Cache hit rate — should approach 100% after warm-up |
OnCacheMiss |
schemaKey |
Cache misses trigger plan rebuilds |
OnEviction |
— | Cache at capacity, evicting plans; increase WithMaxCachedSchemas |
For point-in-time cache stats without a hook, use se.Stats() which returns hit/miss/entry/eviction counts.
All benchmarks on Apple M4 Pro. gnata is compared against the reference jsonata-js implementation running in Node.js. The JSONata (eval) column estimates pure evaluation time by subtracting RPC overhead from the total; entries showing < 1 us mean the expression evaluated faster than the measurement floor.
Simple field lookups and comparisons are evaluated directly against raw json.RawMessage via GJSON — the JSON document is never fully parsed.
| Expression | gnata | JSONata (eval) | JSONata (RPC) | Speedup |
|---|---|---|---|---|
field.lookup |
55 ns | 83 us | 232 us | 1,500x |
nested.3.deep |
95 ns | 56 us | 205 us | 590x |
field = "string" |
42 ns | 49 us | 197 us | 1,170x |
field = 2 (numeric) |
142 ns | 163 us | 311 us | 1,150x |
field = true (bool) |
111 ns | 64 us | 212 us | 570x |
field != null |
41 ns | 23 us | 172 us | 570x |
field != "value" |
41 ns | < 1 us | 147 us | — |
Fast-path expressions typically achieve 0-2 allocations and 0-40 bytes per evaluation.
Expressions calling a supported built-in function on a pure path (e.g. $exists(a.b), $lowercase(name), $contains(path, "literal")) are classified at compile time. At runtime, the field is extracted with a single gjson.GetBytes call and the function is applied directly — no json.Unmarshal, no AST walk.
Supported functions (21): $exists, $contains, $string, $boolean, $number, $keys, $distinct, $not, $lowercase, $uppercase, $trim, $length, $type, $abs, $floor, $ceil, $sqrt, $count, $reverse, $sum, $max, $min, $average.
| Expression | gnata | JSONata (eval) | JSONata (RPC) | Speedup |
|---|---|---|---|---|
a = "x" and b = "y" |
573 ns | 24 us | 173 us | 43x |
a = "x" or a = "y" |
144 ns | 38 us | 187 us | 270x |
$not(field) |
127 ns | 7 us | 156 us | 58x |
(a or b) and c |
194 ns | 17 us | 166 us | 88x |
a and b and c (3-way) |
180 ns | 11 us | 159 us | 59x |
| Expression | gnata | JSONata (eval) | JSONata (RPC) | Speedup |
|---|---|---|---|---|
field > 1 |
191 ns | < 1 us | 148 us | — |
(field + 1) * 10 |
200 ns | 12 us | 160 us | 58x |
field in [1, 2, 3] |
530 ns | 17 us | 165 us | 32x |
$sum(array) |
177 ns | < 1 us | 144 us | — |
$average(array) |
190 ns | < 1 us | 143 us | — |
$max(a) - $min(a) |
264 ns | 52 us | 201 us | 200x |
| Expression | gnata | JSONata (eval) | JSONata (RPC) | Speedup |
|---|---|---|---|---|
$uppercase(field) |
117 ns | < 1 us | 139 us | — |
$contains(field, "sub") |
73 ns | < 1 us | 111 us | — |
$split(email, "@") |
247 ns | 90 us | 238 us | 360x |
$join(array, ", ") |
233 ns | < 1 us | 140 us | — |
a & "-" & b (concat) |
580 ns | 4 us | 153 us | 7x |
$replace(field, "x", "y") |
234 ns | < 1 us | 142 us | — |
$uppercase($substringBefore(...)) |
314 ns | < 1 us | 140 us | — |
| Expression | gnata | JSONata (eval) | JSONata (RPC) | Speedup |
|---|---|---|---|---|
$count(array) |
406 ns | 13 us | 162 us | 33x |
items[active = true].name |
551 ns | 13 us | 162 us | 24x |
items[value > 20].name |
573 ns | 39 us | 188 us | 68x |
$count(items[active]) |
378 ns | 42 us | 190 us | 110x |
items.name (auto-map) |
247 ns | 34 us | 183 us | 140x |
items^(value).name (sort) |
524 ns | 68 us | 216 us | 130x |
items^(>value).name |
$ claude mcp add gnata \
-- python -m otcore.mcp_server <graph>