Clojure,
hosted on Go.
A compiler written in Go that AOT-emits plain Go source — the ClojureScript model, with Go as the JavaScript — plus a tree-walk evaluator that is the REPL and the macro engine. The same code runs interpreted at the prompt and compiles to a static native binary, with byte-identical output on both paths.
$ cljgo repl cljgo 0.0.1-m2 user=> (require-go '[strings]) user=> (strings/ToUpper "clojure on go") ;; a real Go call, zero bindings "CLOJURE ON GO" user=> (defn fact [n] (if (< n 2) 1 (* n (fact (- n 1))))) user=> (fact 20) 2432902008176640000
Five priorities, in order
Not a wishlist — the design contract. Everything in cljgo is judged against these, Clojure-first.
1 · Universal interop
Any Go module is importable and callable with zero hand-written bindings — the Go ecosystem is the standard library. C reaches in via cgo modules and purego FFI.
2 · REPL-driven
The tree-walk evaluator is a real Clojure REPL: live re-def, defmacro at the prompt, namespaces, eval, resolve.
3 · Faithful Clojure
Persistent data structures with real structural sharing, transients, a numeric tower, macros as plain fns, seqs, and vars as the indirection layer.
4 · Fast in both modes
Fast tree-walk and a compiled performance ladder — a feature, not an option. Benchmarked in CI; a perf regression is treated like a conformance failure.
5 · cgo is first-class
CGO_ENABLED=1 projects are supported, not tolerated — cgo-based Go modules (sqlite drivers, sensors, GUI/audio) import like anything else.
Dual-mode is the gate
Interpreted result == compiled result. A dual-harness conformance suite enforces it on every commit; a REPL↔binary divergence is a release blocker.
Zero-binding Go interop
require-go pulls in any Go package and calls it directly — no wrappers, no generated stubs. The Go toolchain is the classpath. This runs identically interpreted and compiled.
(require-go '[strings]) (require-go '[strconv]) (require-go '[math]) ;; single-return package fns + int64→int arg coercion (println "ToUpper:" (strings/ToUpper "hello")) (println "Repeat:" (strings/Repeat "ab" 3)) ;; a const in value position (println "Pi:" math/Pi) ;; (T, error) call → [v err] vector; happy-path err slot is nil (println "Atoi:" (strconv/Atoi "123")) ;; the ! suffix unwraps-or-throws — returns the widened value (println "Atoi!:" (strconv/Atoi! "456"))
Errors as values
(T, error) Go calls shape to a [v err] vector — branch on the error slot, Clojure-style. The ! suffix unwraps or throws.
Members, ctors & real goroutines
(.Method r …), (.-Field r), (pkg/T. {…}) constructors, plus (chan) / (>! c v) / (<! c) / (go …) over real goroutines — no CPS rewrite.
build.cljgo declares (go-require app "github.com/gorilla/websocket" "v1.5.3") — cljgo synthesizes the go.mod, resolves signatures from go/packages type facts, and the emitted binary makes the real call. A websocket client, zero hand-written bindings.
Dependencies, and a citizen of both ecosystems
Declare a dependency as code in build.cljgo — no deps.edn. Then publish
the same pure-Clojure library to Go developers (a go-gettable module) and JVM-Clojure
developers (source), from one build description.
1 · Depend on a library
(defn build [b] (dep b "greetlib" {:path "../greetlib"}) ;; or a pinned git coordinate: ;; (dep b "greetlib" {:git "https://…" :ref "v1.2.0"}) (let [app (exe b {:name "app" :main "src/app/core.cljg"})] (install b app) (run b app)))
2 · Build resolves it, both legs
$ cljgo build run Hello, world, from greetlib! ;; a committed build.lock.edn pins it: {:deps [{:name "greetlib" :paths ["src"] :pure? true :requires []}] :lock/version 1} $ cljgo run src/app/core.cljg # same dep, interpreter Hello, world, from greetlib! # byte-identical
3 · Publish to Go
$ cljgo publish go wrote go-gettable module example.com/greet (ns greet.core, 2 exported) ;; a Go dev then just: go get example.com/greet ;; pkg.Hello("world"), pkg.Shout("world")
4 · Publish to Clojars
$ cljgo publish clojars wrote pure Clojure source tree + deps.edn $ cljgo publish clojars # if it uses Go interop: error: cannot publish to the JVM — greet.core (core.cljg:3) require-go call: uses Go interop
Purity is the gate, checked at publish
A pure-Clojure library reaches both worlds. The moment a namespace uses require-go/ffi it is
Go-side only — and publish clojars refuses it with the offending file:line, not a broken download.
Reproducible by construction
A global content-addressed cache (keyed by identity, verified by a merkle tree hash — a git SHA is not a content
hash), a committed build.lock.edn, and a hard error on a version conflict. One resolver feeds both
legs, so a dependency resolves identically interpreted and compiled.
One analyzer, two backends
The unforgivable failure mode is the REPL diverging from the compiled binary. cljgo makes that structurally hard: one reader, one analyzer, one AST — feeding both a tree-walk evaluator and a Go-source emitter.
def and defmacro at the prompt.go/packages type facts — direct, non-reflective calls. The Go toolchain then produces a static native binary.Compatibility you can measure
cljgo is scored against the jank clojure-test-suite — 242 real
clojure.core files — as a single ratcheting number in CI. Not a self-graded checklist.
- 100% of tested
clojure.corevars resolve (242 / 242). - 98.3% of files pass — 0 failures, 4 errors. Reproduce it yourself:
cljgo suite. - Coverage ratchet — the passing count may only rise.
- Runs interpreted; cljgo's own dual-mode conformance stays fully green alongside it.
:default, so a runtime
the suite has never heard of misreads them. With :cljgo branches
added — the same mechanism :cljr/:lpy/:phel
use — it reads 242/242, but that is not upstreamed, so we publish 98.3%.
Where we actually stand
Every row below is reproducible with the command next to it. Apple M5 Pro, go1.26.3,
hello.clj = (println "hi"). We publish the ones we lose on too.
| Metric | cljgo | Reproduce |
|---|---|---|
| Tool binary | 12.7 MB stripped | go build -trimpath -ldflags="-s -w" ./cmd/cljgo |
| Compiled binary, hello | 5.3 MB | cljgo build hello.clj |
| Compiled startup, hello | 5.0 ms (was 28.9 ms pre-AOT-core) | hyperfine -N ./hello |
| Peak RSS, hello | 14.7 MB | /usr/bin/time -l ./hello |
| Interpreter boot | 31.7 ms (a larger core loads now) | go test -bench=BenchmarkBoot -benchmem -run '^$' ./pkg/eval/ |
| Emitted vs handwritten Go | ~5× (target ~10×, reached and passed 2026-07-23) | go test -run TestFactorialPerfBudget ./pkg/emit/ |
| clojure-test-suite | 238 / 242 (98.3%) | cljgo suite |
The whole field
We ran let-go's benchmark suite with let-go's published methodology. All 7 files run on cljgo with no edits. Every runtime here was installed and measured on the same machine (Apple M5 Pro) — no normalization, no quoted numbers. Wall-clock mean of 10 runs, startup included. Best per row in bold.
The field is mixed modes, so we show both cljgo legs and compare like-for-like:
interpreted — cljgo-run and joker are tree-walkers;
compiled — cljgo-aot (native binary), babashka (GraalVM native image),
let-go (bytecode VM), Clojure JVM (JIT, post-warmup).
| Benchmark | cljgo-run interp | cljgo-aot compiled | let-go | babashka | joker interp | clojure JVM |
|---|---|---|---|---|---|---|
| startup | 38.5 ms | 5.0 ms | 5.0 ms | 9.7 ms | 6.7 ms | 289.2 ms |
tak | 11.47 s | 34.6 ms | 1.33 s | 1.14 s | — | 457.9 ms |
fib | 8.79 s | 24.7 ms | 1.25 s | 1.14 s | — | 419.1 ms |
loop-recur | 469.3 ms | 5.4 ms | 36.6 ms | 37.8 ms | 437.8 ms | 397.5 ms |
persistent-map | 48.5 ms | 9.4 ms | 12.9 ms | 13.0 ms | 30.5 ms | 385.2 ms |
map-filter | 39.9 ms | 5.1 ms | 4.8 ms | 10.0 ms | 8.4 ms | 311.5 ms |
transducers | 89.0 ms | 16.4 ms | 25.4 ms | 13.0 ms | — | 315.9 ms |
reduce | 61.4 ms | 26.0 ms | 22.8 ms | 20.0 ms | 1.46 s | 302.5 ms |
cljgo-run is in the same class.
Against the compiled field, cljgo-aot now wins every recursion and data-structure row outright
(tak, fib, loop-recur, persistent-map), dead-heats let-go on
startup, is 0.3 ms from let-go on map-filter, and trails only babashka on
transducers/reduce — the two rows still lost.
bash benchmark/run.sh.
joker has no transducers and is skipped on fib/tak. Not measured:
gloat (its module exposes no installable package path) and go-joker (needs a source clone +
codegen) — let-go's published M1 Pro data puts gloat at 12.7× let-go on fib, 5.4× on
reduce.
tak and fib the compiled leg (cljgo-aot) is the
fastest result in the field — 34.6 ms / 24.7 ms, 13× and 17× faster than the JVM and ~40×
ahead of both a bytecode VM (let-go, 1.33 s / 1.25 s) and a GraalVM native image (babashka, 1.14 s / 1.14 s).
Pure int64 recursion is exactly what the ADR 0067 numeric-inference pass lifts to raw typed Go
(func fib(n int64) int64 — direct recursion, zero boxing). loop-recur (5.4 ms) and
persistent-map (9.4 ms) are also outright wins. The "emit plain Go" bet works.
core.clj at runtime)." AOT-core cut that to 6.5 ms; boot growth from the
fundamentals batches then pushed it back to 9.5 ms; and the 2026-07-23 clawback (a bulk-refer snapshot +
boot GC deferral) brought it to 5.0 ms — a dead heat with let-go, ahead of babashka and joker, and now
CI-gated so it cannot drift again. The +3 ms regression was attributed (boot-time per-symbol refer and GC churn,
not the seal/dual-body work, which measured +0.0 ms) before it was clawed back.
transducers 16.4 ms vs
babashka's 13.0 ms, and reduce 26.0 ms vs babashka's 20.0 ms and let-go's 22.8 ms — closer than the
2.4–2.8× of the previous run, but honestly lost. The residual is per-element Apply2 dispatch on the
reducing fn plus LongChunk.Nth boxing; ADR 0067's follow-ups (float64, multi-arity specialization,
broader lift) and an unboxed internal-reduce are the named path. The emitted-vs-handwritten-Go ratio is
~5× — it was ~35× before 2026-07-23, and the ~10× target is passed. (cljgo-run is the
interpreter — the REPL/dev path — and loses everywhere except against joker; that's the tree-walker tax, and
it's not what you deploy.)
TestBootUnderBudget (250 ms) and
TestFactorialPerfBudget (15× ceiling) run inside plain go test ./...,
host-relative via CLJGO_BOOT_BUDGET / CLJGO_PERF_RATIO_MAX because a CI
runner is not your laptop. Measured: ~5× — under the ~10× target of design/00 §1.4 for the
first time (ADR 0067; naive emission was ~168×, boxed emission ~35×). The 15× ceiling is a
regression guard, not the target. v0.2.0 made boot 8.9× faster (211 ms → 23.7 ms) by killing
a stack-scraping goroutine-ID lookup that burned 73% of boot CPU; boot is 31.7 ms today, grown with
the core it loads.
Memory is where it actually wins
Throughput is a fair fight; memory and size are not. Same program
((reduce + (range 1000))), max resident set via /usr/bin/time -l — the number
that decides how many services fit on one box.
| Runtime | Static binary / install | Max RSS |
|---|---|---|
| cljgo | 5.3 MB static binary | 14 MB |
| joker | 27.4 MB | 27 MB |
| babashka | 67.9 MB | 28 MB |
| JVM Clojure | JVM + classpath | 102 MB |
Quickstart
Install once, then: a REPL, run a Clojure file, or build a whole build.cljgo project.
Needs a Go 1.26 toolchain (and a C toolchain with CGO_ENABLED=1 for cgo interop features).
1 · Install cljgo
# Homebrew — macOS & Linux brew install muthuishere/tap/cljgo # …or with a Go 1.26+ toolchain go install github.com/muthuishere/cljgo/cmd/cljgo@latest
cljgo run and Go interop straight away — no Go toolchain needed.
Compiling to a native binary (cljgo build) additionally needs the Go toolchain on
PATH: the binary pins the published runtime module in the generated go.mod,
and the first build fetches it from the Go module proxy once per machine (~1 MB, a few seconds).
2 · Start a REPL
$ cljgo repl cljgo 0.1.0-dev user=> (defn square [x] (* x x)) #'user/square user=> (map square [1 2 3 4]) (1 4 9 16) user=> (require-go '[strings]) ;; call Go, live user=> (strings/ToUpper "hi") "HI"
3 · A Clojure file — hello.clj
;; hello.clj (defn fact [n] (if (< n 2) 1 (* n (fact (- n 1))))) (println "hello from cljgo") (println "(fact 10) =" (fact 10))
4 · Run it, then compile it
$ cljgo run hello.clj hello from cljgo (fact 10) = 3628800 $ cljgo build hello.clj # → ./hello $ ./hello hello from cljgo (fact 10) = 3628800 # byte-identical
5 · Or a whole project — build.cljgo
;; build.cljgo — the build is a program (defn build [b] (let [app (exe b {:name "app" :main "src/app/core.cljg"})] (install b app) (run b app)))
Build & run the project
$ cljgo build # → installs ./app $ cljgo build run # build it, then run it hello from the project # declare deps + third-party Go right in build.cljgo: # (dep b "greetlib" {:path "../greetlib"}) # (go-require app "github.com/gorilla/websocket" "v1.5.3")
CLI surface
cljgo repl # start a REPL cljgo nrepl # nREPL server — Calva / CIDER connect cljgo run <file.clj> # evaluate a file (interpreted) cljgo build [-o out] <file.clj> # compile a file to a native binary cljgo build # project mode: run ./build.cljgo cljgo new <name> # generate a project (lib | cli | web) cljgo test # run the project's clojure.test tests cljgo publish <go|clojars> # publish the library to Go or Clojars cljgo suite # run the jank clojure-test-suite cljgo cache clean # clear the global dependency cache cljgo check <file.clj> [--json] # analyze, report diagnostics cljgo explain <code> [--json] # show an error code's explain page cljgo version # print the version string
Status & roadmap
A working REPL and a native compiler. Milestones landed to date:
| Milestone | State | What landed |
|---|---|---|
| M0–M1 | ✅ | REPL: full syntax-quote reader, loop*/recur, dynamic vars, namespaces, macroexpansion, defmacro at the prompt, embedded core.clj, clojure.test |
| M2 | ✅ | cljgo build → native binary, <10 ms startup, fixed-arity calling convention |
| M3 | ✅ | Zero-ceremony Go interop, both modes — require-go, package fns/consts, members (.Method r …)/(.-Field r), ctors, (T,error)→[v err], ! unwrap |
| core.async | ✅ | Real clojure.core.async over goroutines — no CPS rewrite. 55 publics = every non-deprecated, non-internal var of JVM core.async 1.6.681: alts!/alt!, timeout, transducers, mult/pub/mix/pipe, pipeline(-blocking/-async) |
| AOT-core | ✅ | The startup lever, shipped: clojure.core AOT-compiled into the binary instead of evaluated at boot — compiled startup 28.9 → 6.5 ms at the time (today's number is 5.0 ms, via the 2026-07-23 campaign below) |
| perf 07-23 | ✅ | The 2026-07-23 campaign (ADRs 0063–0067): chunk-aware seq ops, the IFn2 2-arg reduce seam, direct-call emission, the sealed-core dirty flag, int64 numeric inference, <=/>= unboxed compares, startup clawback — emitted-vs-handwritten Go ~35× → ~5×, fib 975 → 24.7 ms, startup back to 5.0 ms, CI-gated |
| deps | ✅ | Dependency resolution — (dep …) in build.cljgo, content-addressed cache, committed build.lock.edn, hard error on version conflict, one resolver both legs |
| publish | ✅ | cljgo publish go (go-gettable module) + publish clojars (pure Clojure source), purity-gated at file:line — one library, both ecosystems |
| build.cljgo | ✅ | Zig-style build graph — exe/install/run + go-require third-party Go (gorilla/websocket, zero bindings) |
| bri T0–T1 | ✅ | The app framework — cljgo new/cljgo dev (server + nREPL), HTTP + hiccup HTML + routes/middleware, signed-cookie sessions + CSRF, layered config (cljgo config) |
| core batches | ✅ | Numeric tower (bigint/bigdec/ratios/promotion/bit-*), transients, JVM-compatible hashing, reify, tagged-literals/reader-conditionals, richer error rendering, suite ratchet |
| Next | ◦ | ADR 0067 follow-ups (float64, multi-arity/variadic specialization, capturing-closure lift); reduce/transducers vs babashka's core (the two rows still lost); the batteries (see below): bri data layer + jobs/cache, a curated Go-native stdlib, layered config + vault + i18n; plus C FFI (purego) and comptime |
Where it's headed — the Bun of Clojure
The direction (roadmap, not yet shipped): one fast native binary, batteries included, zero-config — Bun's ergonomics with Go's delivery and no runtime to distribute. Go is the near-perfect host for the whole list, so the batteries stay native-fast and keep the single static binary:
- Data —
bri.db: a pure-Go SQLite as the zero-install default DB, Postgres (pgx) for production, migrations (cljgo migrate). (bri T2, spiked) - Jobs & cache — a durable Postgres queue (
FOR UPDATE SKIP LOCKED) + in-process TTL/singleflight cache. (bri T3, spiked) - A curated Go-native stdlib — secrets (OS keychain), streaming file I/O, crypto/hashing, http-client,
websocket — there by default, no
require-goceremony. - Spring-Boot-style config — one layered chain (defaults →
application.edn/.properties→ profiles →APP_*env → pluggable vault → overrides), plus i18n message bundles on the same infra.
The Zig model
cljgo's "batteries" are Zig's, not Leiningen's / deps.edn's (design/08).
- build.cljgo — the build is a program, not a data file.
(defn build [b] …)defines an artifact/step DAG;cljgo build/build run/testmirrorzig build. Replaces deps.edn for the AOT product. - Dependencies as code —
(dep …)lives inbuild.cljgo, not a separate manifest. Resolution is content-addressed and lockfile-pinned, one resolver feeds both legs, and a Java-carrying dependency fails loud rather than at link time. Nodeps.ednin either direction. - Publish both ways — a pure-Clojure library ships to Go developers (
cljgo publish go, a go-gettable module) and JVM-Clojure developers (cljgo publish clojars, source), from one build description, gated on purity at publish time. - comptime — Zig-style compile-time value execution alongside Clojure macros. Macros transform syntax;
comptimecomputes values embedded as literals. In the REPL, compile-time == eval-time. - clojure.core compliance — the jank clojure-test-suite as the external yardstick with a CI coverage ratchet. Measured 238/242 (98.3%) — 242/242 vars resolved, 0 failures — climbing per batch.
- Cross-compilation — cljgo emits plain Go, so pure-Go + purego programs build for any OS/arch with no target toolchain.
--target os/arch, or a matrix per artifact.