Clojure ⟶ plain Go · one analyzer, two backends

Clojure,
hosted on Go.

A Go-written compiler that AOT-emits plain Go source (the ClojureScript model, Go as the JavaScript), plus a tree-walk evaluator that is the REPL. Same code runs at the prompt and compiles to a static binary — 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
6.7 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. Full tables — let-go, babashka, joker, JVM Clojure, Glojure, and the web-framework shootout — live on the benchmarks page.

MetriccljgoReproduce
Tool binary27.5 MB strippedgo build -trimpath -ldflags="-s -w" ./cmd/cljgo
Compiled binary, hello6.7 MBcljgo build hello.clj
Compiled startup, hello5.0 ms (was 28.9 ms pre-AOT-core)hyperfine -N ./hello
Peak RSS, hello11.5 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
Against the field — the short version. We ran let-go's own benchmark suite unmodified, with every runtime installed and measured on the same machine (no normalization, no quoted numbers), plus an AOT-vs-AOT table against Glojure and let-go's lowered binaries.

Where it wins: the compiled leg takes every recursion and data-structure row — tak/fib at 34.6 / 24.7 ms, 13× and 17× faster than JVM Clojure — and ships the smallest binary of the three Clojure-on-Go compilers (6.7 MB vs Glojure's 7.5 and let-go's 12.8). Startup is a dead heat with let-go at 5.0 ms. On (reduce + (range 1000)) it peaks at 13 MB RSS against JVM Clojure's 102 MB~7× less memory, measured, and that's the JVM's best case: a hello that exits.

Where it loses: transducers and reduce still go to babashka's purpose-built core (16.4 vs 13.0 ms, 26.0 vs 20.0 ms). And in the AOT-vs-AOT table Glojure wins 6 of 8 rows — its codegen ships reduce-pipeline fusion and float64 specialization we haven't built yet. The interpreted leg (cljgo run) is a tree-walker and loses to everything except joker; that's the dev path, not what you deploy.

Every table, every version, both losses in full → benchmarks page.
Provenance, 2026-07-28. These figures were measured 2026-07-24/25, before v0.7.0, which shipped today with a wider int64 inference pass (ADR 0067's second op table) and cross-var direct calls (ADR 0064). They have not been re-measured — the arithmetic-heavy and call-heavy rows are the ones most likely to move. Nothing here is estimated forward; the benchmarks page carries the same caveat and the full methodology. Reproduce: bash benchmark/run.sh.
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

# 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 — those work from the binary alone, with no Go toolchain on PATH. Compiling to a native binary (cljgo build) does need the toolchain: it emits Go source and invokes go build. The binary pins the published runtime module in the generated go.mod, so the first build fetches it from the module proxy once per machine (~1 MB, a few seconds) — no cljgo checkout required. Prefer a prebuilt binary? Grab one from the latest release.

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: one fast native binary, batteries included, zero-config — Bun's ergonomics with Go's delivery and no runtime to distribute. Some of this has shipped, some is still ahead; the batteries stay native-fast and keep the single static binary:

  • Datacljg.data.cast: a pure-Go SQLite as the zero-install default DB, Postgres (pgx) for production, mass-assignment-safe casts, and migrations (cljgo migrate up/status/new, auto-applied in dev). (bri T2, shipped)
  • Jobs & cache — shipped as pure zero-dependency fundamentals: an in-process core.async job queue (cljg.jobs) and a TTL/singleflight cache (cljg.cache), each behind a protocol you can reify. A durable Postgres queue or Redis cache stays a you-bring-it backend, not a bundled dependency. (bri T3, shipped)
  • A curated Go-native stdlib — secrets (OS keychain), streaming file I/O, crypto/hashing, http-client, websocket — there by default, no require-go ceremony. (partly shipped)
  • 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.
  • 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.