Greetings loafers! (λ-gophers haha, get it?)
let-go is a Clojure dialect with a bytecode compiler and stack VM, written in Go. A single ~10MB binary, ~7ms cold start, no JVM. Roughly 95% Clojure-compatible on the jank-lang test suite.
I started this in 2021 as an elaborate joke: an excuse to write Clojure while pretending to write Go. It turned out useful. I use it for CLIs, scripts, and web servers, and I built a daemonless container runtime on top of it. You can compile let-go programs to standalone binaries or self-contained WASM web pages. It even runs on Plan 9.
It is not a drop-in replacement for Clojure JVM. It does not load JARs and does not aim to. Most idiomatic Clojure code runs unmodified, but a real project with library dependencies will need adjustments. See Known limitations below.
Goals (in no particular order)
- Quality entertainment
- Implement most of Clojure: persistent data structures, lazy seqs, transducers, protocols, records, multimethods, core.async, BigInts
- Comfy two-way Go interop (functions, structs, channels)
- AOT compilation to bytecode and standalone binaries
- Boot the runtime inside a single
requestAnimationFrame(10ms left over at 60fps) - Compile programs to self-contained WASM web pages with terminal emulation
- Make it legal to write Clojure at your Go dayjob
- nREPL in the browser (let-go VM in WASM, editor over WebSocket)
- Stretch: let-go bytecode → Go translation
Non-goals: drop-in JVM Clojure replacement; linter/formatter for Clojure-at-large.
Benchmarks
let-go vs Babashka, Joker, go-joker, gloat, and Clojure JVM. All benchmark files are valid Clojure that runs unmodified. Apple M1 Pro.
| let-go | babashka | joker | go-joker | gloat | clojure JVM | |
|---|---|---|---|---|---|---|
| Binary size | 10MB | 68MB | 26MB | 32MB | 26MB | 304MB (JDK) |
| Startup | 6.7ms | 18ms | 12ms | 13ms | 16ms | 363ms |
| Idle memory | 13.5MB | 27MB | 22MB | 23MB | 23MB | 92MB |
let-go wins decisively on the small things: smallest binary, fastest startup (~50× under JVM, ~3× under Babashka), lowest memory. It also wins on short-lived data work like map/filter (7.9ms vs Babashka's 21.5ms) and persistent maps (20.8ms vs 23.7ms).
On bigger numerical workloads other implementations pull ahead. go-joker's WASM JIT compiles inner numeric loops and beats us on fib (1.47s vs 2.08s), tak, reduce, and transducers. The JVM dominates on long compute runs once HotSpot warms up. We're about even with Babashka on most algorithmic benchmarks and 10×+ faster than upstream Joker (bytecode VM vs tree-walk).
Full per-benchmark numbers and methodology: benchmark/results.md.
Compatibility
Tested against jank-lang/clojure-test-suite:
4696 / 4921 assertions pass (95.4%) across 217 files. Remaining gaps are
mostly numeric edge cases (overflow detection on +/-/*/inc/dec,
BigInt promotion at the Long boundary, BigDecimal) plus a handful of stub
namespaces.
Standard namespaces
| Namespace | Status |
|---|---|
clojure.core |
macros, destructuring, lazy seqs, transducers, protocols, records, multimethods, atoms, regex, metadata, BigInt |
clojure.string |
full |
clojure.set |
full |
clojure.walk |
prewalk, postwalk, keywordize-keys, stringify-keys, walk |
clojure.edn |
read, read-string |
clojure.pprint |
pprint, cl-format |
clojure.test |
deftest, is, testing, are, fixtures |
clojure.core.async |
channels, go/go-loop, alts!, mult/pub, pipe/merge/split (real goroutines, not IOC) |
io |
polymorphic readers/writers, slurp/spit, lazy line-seq, encoding, URLs, with-open |
http |
Ring-style server + client, streaming responses |
json |
read-json, write-json (float-preserving, record-aware) |
transit |
transit+json codec with rolling cache |
os |
sh, stat, ls, cwd, getenv/setenv, exit, os-name, arch, user-name, hostname, separators |
System |
JVM-shaped: getProperty, getProperties, getenv, exit, currentTimeMillis, nanoTime. Exposes let-go.version, let-go.commit, user.home, user.dir, os.name, os.arch, etc. |
syscall |
direct Linux syscalls (mount, unshare, mknod, prctl, capset, seccomp, AppArmor) |
pods |
Babashka pods over JSON / EDN / transit |
Babashka pods
let-go can load Babashka pods, which opens up the whole pod ecosystem: SQLite, AWS, Docker, file watching, etc.
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.13") (pod.babashka.go-sqlite3/execute! "app.db" ["create table users (id integer primary key, name text)"]) (pod.babashka.go-sqlite3/query "app.db" ["select * from users"]) ;; => [{:id 1 :name "Alice"}]
It shares ~/.babashka/pods/ with bb, so install pods with babashka and use
them from lg. See the pod registry
for what's available.
Known limitations
Not implemented
- Refs / STM (atoms + channels cover practical concurrency)
- Agents (use
goblocks and channels) - Hierarchies (
derive,underive,ancestors,descendants,parents): stubs only with-precision:BigDecimalworks (Mliterals,bigdec, exact arithmetic) butwith-precisionis a no-op- Chunked sequences: lazy seqs are unchunked
- Reader tagged literals (
#inst,#uuid) deftype(usedefrecord)reify(protocols can only be extended to named types)- Spec (no
clojure.spec) alter-var-root- Numeric overflow detection:
+/-/*/inc/decwrap silently on int64 overflow; use+'/-'/*'for BigInt math subseq/rsubseq: sorted collections work (sorted-map,sorted-set,rseq); range queries don't
Behavioral differences
concat*(used internally by quasiquote) is eager; user-facingconcatis lazy<!/<!!are identical, same for>!/>!!(Go channels always block)goblocks are real goroutines, not IOC state machines (cheaper, and they can call blocking ops directly)- No BigDecimal: numeric tower is
int64+float64+BigInt - Regex is Go flavor (
re2), not Java regex letfnuses atoms internally for forward references
Examples
Things written in let-go:
- xsofy: a roguelike that runs in the browser and the terminal from the same source
- lgcr: a daemonless container runtime, built on the
syscallnamespace
In this repo:
Try it online
Bare-bones browser REPL, running a WASM build of let-go.
Install
Homebrew (macOS / Linux)
brew tap nooga/let-go https://github.com/nooga/let-go brew install let-go
Download
Prebuilt binaries for Linux, macOS, and Windows on amd64/arm64 in Releases.
From source (Go 1.22+)
go install github.com/nooga/let-go@latest
Usage
lg # REPL lg -e '(+ 1 1)' # eval expression lg myfile.lg # run file lg -r myfile.lg # run file, then REPL
Compile and distribute
let-go can compile programs to bytecode (.lgb files) and bundle them as
standalone executables.
lg -c app.lgb app.lg # compile to bytecode lg app.lgb # run bytecode lg -b myapp app.lg # bundle into a self-contained binary ./myapp # runs anywhere, no lg needed
The standalone binary is a copy of lg with your bytecode appended. Copy it
to another machine and it runs.
lg -w site app.lg # compile to a WASM web app
open site/index.htmlThe output is a self-contained index.html (~6MB, inlined WASM, gzipped)
plus a service worker that supplies the COOP/COEP headers GitHub Pages needs
for SharedArrayBuffer. Programs that use the term namespace get full
terminal emulation via xterm.js: ANSI colors, cursor positioning, raw
keyboard input.
The *compiling-aot* var is true during -c/-b/-w compilation and
false at runtime, useful for keeping side effects out of compile time:
(defn -main [] (start-server)) (when-not *compiling-aot* (-main))
*in-wasm* is true when running inside a WASM build.
nREPL
let-go ships an nREPL server that works with CIDER (Emacs), Calva (VS Code), and Conjure (Neovim).
lg -n # default port 2137
lg -n -p 7888It writes .nrepl-port to the working directory so editors auto-discover it.
Supported ops: clone, close, eval, load-file, describe,
completions, complete, info, lookup, ls-sessions, interrupt.
- Emacs (CIDER):
M-x cider-connect-clj,localhost, port from.nrepl-port - VS Code (Calva): open a let-go project (the bundled
.vscode/settings.jsonregisters a connect sequence). Use "Calva: Start a Project REPL and Connect (Jack-In)" → "let-go", or "Calva: Connect to a Running REPL Server" if nREPL is already up. - Neovim (Conjure): auto-connects when
.nrepl-portexists.
Embedding in Go
let-go embeds cleanly as a scripting layer for Go programs. Define Go values and functions, hand them to the VM, run user-supplied Clojure against your data. Go structs roundtrip as records, Go channels are first-class let-go channels, and Go functions are callable from let-go.
import ( "github.com/nooga/let-go/pkg/api" "github.com/nooga/let-go/pkg/vm" ) c, _ := api.NewLetGo("myapp") c.Def("x", 42) c.Def("greet", func(name string) string { return "Hello, " + name }) v, _ := c.Run(`(greet "world")`) fmt.Println(v) // "Hello, world"
Registered structs become records on the let-go side. Unmutated values unbox
back to the original Go type for free; mutated ones go through vm.ToStruct[T].
type Item struct{ Name string; Price float64; Qty int } vm.RegisterStruct[Item]("myapp/Item") c.Def("item", Item{Name: "Widget", Price: 9.99, Qty: 5}) c.Run(`(:name item)`) // "Widget" c.Run(`(* (:price item) (:qty item))`) // 49.95
Go channels and vm.Chan plug into go / <! / >! directly:
inch := make(chan int) outch := make(vm.Chan) c.Def("in", inch) c.Def("out", outch) c.Run(`(go (loop [i (<! in)] (when i (>! out (inc i)) (recur (<! in)))))`)
pkg/api/interop_test.go has the full set of
embedding examples (defs, structs, channels, function calls).
Testing
go test ./... -count=1 -timeout 30sEver wanted a 20MB pure-Go JS runtime that typechecks and runs TypeScript? Check my other project: https://github.com/nooga/paserati
