Clojure ⟶ plain Go · one analyzer, two backends

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 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
Zero·
hand-written bindings for any Go module
5.3 MB
static hello-world binary, stripped
98.3%
clojure-test-suite passing, climbing
any OS
cross-compiles, no target toolchain
The mandate

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.

Priority #1

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.

examples/interop/core.clj
(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.

Third-party modules too. 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.
Consume & publish

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

app/build.cljgo
(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.

Why the two never drift

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.

Reader → Analyzer → AST shared
Full syntax-quote reader, macroexpansion, one analyzer producing a single AST. Everything below consumes exactly this.
Tree-walk evaluator pkg/eval
IS the REPL and the macro engine. Pre-resolved locals, non-allocating fast paths, live re-def and defmacro at the prompt.
Go-source emitter pkg/emit
AOT-emits plain Go from go/packages type facts — direct, non-reflective calls. The Go toolchain then produces a static native binary.
Dual-harness conformance the gate
Every semantic test runs through both paths, oracle-cited against JVM Clojure. Interpreted result == compiled result, or the build fails.
External yardstick

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.core vars 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.
98.3%
238 / 242 suite files fully passing
Upstream suite, unmodified. The 4 outstanding are dialect registration, not semantics: their reader conditionals have no :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%.
Numbers, not adjectives

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.

MetriccljgoReproduce
Tool binary12.7 MB strippedgo build -trimpath -ldflags="-s -w" ./cmd/cljgo
Compiled binary, hello5.3 MBcljgo build hello.clj
Compiled startup, hello5.0 ms (was 28.9 ms pre-AOT-core)hyperfine -N ./hello
Peak RSS, hello14.7 MB/usr/bin/time -l ./hello
Interpreter boot31.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-suite238 / 242 (98.3%)cljgo suite
let-go's own suite, unmodified

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: interpretedcljgo-run and joker are tree-walkers; compiledcljgo-aot (native binary), babashka (GraalVM native image), let-go (bytecode VM), Clojure JVM (JIT, post-warmup).

Benchmarkcljgo-run interpcljgo-aot compiledlet-gobabashkajoker interpclojure JVM
startup38.5 ms5.0 ms5.0 ms9.7 ms6.7 ms289.2 ms
tak11.47 s34.6 ms1.33 s1.14 s457.9 ms
fib8.79 s24.7 ms1.25 s1.14 s419.1 ms
loop-recur469.3 ms5.4 ms36.6 ms37.8 ms437.8 ms397.5 ms
persistent-map48.5 ms9.4 ms12.9 ms13.0 ms30.5 ms385.2 ms
map-filter39.9 ms5.1 ms4.8 ms10.0 ms8.4 ms311.5 ms
transducers89.0 ms16.4 ms25.4 ms13.0 ms315.9 ms
reduce61.4 ms26.0 ms22.8 ms20.0 ms1.46 s302.5 ms
Read it by mode. Against the other tree-walker, joker, 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.
cljgo @HEAD (post-ADR-0067) · let-go v1.11.1 (built from source) · babashka v1.12.218 · joker v1.9.0 · Clojure CLI 1.12.5.1645 on OpenJDK 26.0.1. Re-measured 2026-07-23 via 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.
The good. On tak and fib the compiled leg (cljgo-aot) is the fastest result in the field34.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.
Startup, the whole arc. The old story was "startup is 28.9 ms because an emitted binary boots the whole interpreter (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.
The honest gap. Two rows remain behind the purpose-built cores: 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.)
Budgets are gates, not vibes. 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.
Footprint & density

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.

RuntimeStatic binary / installMax RSS
cljgo5.3 MB static binary14 MB
joker27.4 MB27 MB
babashka67.9 MB28 MB
JVM ClojureJVM + classpath102 MB
~7× less memory than JVM Clojure, measured — and that's the JVM's best case: a hello that exits. A real Ring service idles in the hundreds of MB because the JVM reserves heap ahead of need and is slow to return it, while a Go binary allocates what it uses and hands it back. On the same program cljgo also finished in 9 ms total wall-clock vs JVM Clojure's 298 ms (boot included — the honest number).
The density math, on a 4 GB VPS (leaving ~1 GB for the OS + Postgres): a cljgo service idles in the tens of MB and restarts in ~5 ms, so ~10 services fit comfortably where JVM Clojure fits 1–3 — and each cljgo deploy is a blip, not a 30-second warm-up. That is the one-line pitch for ops: Go's hosting bill, Clojure's codebase. (A CI-gated peak-RSS budget is on the roadmap so "low memory" is an enforced promise, not a slide.)
Get started

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
That gets you the REPL, 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
Where it stands

Status & roadmap

A working REPL and a native compiler. Milestones landed to date:

MilestoneStateWhat landed
M0–M1REPL: full syntax-quote reader, loop*/recur, dynamic vars, namespaces, macroexpansion, defmacro at the prompt, embedded core.clj, clojure.test
M2cljgo build → native binary, <10 ms startup, fixed-arity calling convention
M3Zero-ceremony Go interop, both modes — require-go, package fns/consts, members (.Method r …)/(.-Field r), ctors, (T,error)[v err], ! unwrap
core.asyncReal 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-coreThe 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-23The 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
depsDependency resolution — (dep …) in build.cljgo, content-addressed cache, committed build.lock.edn, hard error on version conflict, one resolver both legs
publishcljgo publish go (go-gettable module) + publish clojars (pure Clojure source), purity-gated at file:line — one library, both ecosystems
build.cljgoZig-style build graph — exe/install/run + go-require third-party Go (gorilla/websocket, zero bindings)
bri T0–T1The app framework — cljgo new/cljgo dev (server + nREPL), HTTP + hiccup HTML + routes/middleware, signed-cookie sessions + CSRF, layered config (cljgo config)
core batchesNumeric tower (bigint/bigdec/ratios/promotion/bit-*), transients, JVM-compatible hashing, reify, tagged-literals/reader-conditionals, richer error rendering, suite ratchet
NextADR 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:

  • Databri.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-go ceremony.
  • 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 / test mirror zig build. Replaces deps.edn for the AOT product.
  • Dependencies as code(dep …) lives in build.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. No deps.edn in 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; comptime computes 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.