let-go: a Clojure dialect with a bytecode VM written in Go
Almost Clojure written in Go.
At a glance
- What is it?
- let-go is a Clojure dialect, with a bytecode compiler and a stack VM written in Go, shipping as a single small binary that starts in about ten milliseconds and passes the jank-lang Clojure test suite. It is not a drop-in replacement for JVM Clojure, and the README is blunt about the gaps that remain.
- Who is it for?
- let-go fits a developer who wants Clojure's shape in a program that starts instantly and ships as one static binary, and who is writing the code rather than porting a large library dependency tree. It does not fit a team that needs the JVM Clojure ecosystem as it stands, since the README lists missing coordinated STM, agents, clojure.spec, unchunked lazy sequences and limited host interop.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A joke in 2021 that turned into a usable dialect
let-go is described as almost Clojure written in Go, and the project description in the repository metadata calls it a Clojure dialect with a bytecode compiler and stack VM, written in Go. The author explains the origin directly: he started it in 2021 as an elaborate joke, an excuse to write Clojure while pretending to write Go, and it turned out useful. He uses it for CLIs, scripts and web servers, and built a daemonless container runtime on top of it. The goals list is checked off in a telling order, with quality entertainment first and the serious work following, including implementing most of Clojure with persistent data structures, lazy sequences, transducers, protocols, records, multimethods, core.async and BigInts, two-way Go interop for functions, structs and channels, and AOT compilation to bytecode and standalone binaries. The unclosed items are telling too, with an nREPL in the browser and a stretch goal of translating let-go bytecode to Go.
The size and startup numbers that define the design goal
The README leads with three facts: a single roughly 13MB binary, about 10ms cold start and no JVM, plus a pass of the jank-lang Clojure test suite. The benchmark table compares four legs on an Apple M1 Pro, with all benchmark files being valid Clojure that runs unmodified and the VM and AOT legs running the identical program, so only dispatch differs. Binary size comes out at 13MB for the VM, 18MB for AOT, 68MB for Babashka and 304MB for the JDK. Startup is 11.1ms for the VM, 10.7ms for AOT, 20.4ms for Babashka and 364ms for Clojure on the JVM. Idle memory is 15.2MB for both let-go legs, 27.0MB for Babashka and 97.7MB for the JVM. The claim in the README is that let-go stays compact and quick to launch, a small native binary, fastest startup in this run and low resident memory with no JVM dependency.
Where the bytecode VM wins and where AOT takes over
The runtime results are described in detail, and the shape of them matters for choosing a mode. On short-lived data work the VM is competitive, with map and filter at 11.3ms coming in 1.7 times ahead of Babashka, and persistent maps at 22.2ms running neck and neck with it. AOT lowering changes the call-heavy numeric cases completely, with fib at 35 dropping from 2.42s to 0.11s and tak from 2.40s to 94ms, an order of magnitude ahead of Babashka. The JVM is not dismissed, since a fully warmed HotSpot still holds the raw compute crown, with steady-state JVM fib at 35 around 76ms against AOT's roughly 99ms measured in-process, but it pays about 360ms of startup first, so on one-shot runs AOT delivers the result around five times sooner. Babashka still leads on reduction and transducer workloads, and the full per-benchmark numbers live in benchmark/results.md.
The compatibility claim, and exactly what it was measured against
The compatibility section is the one to read carefully, because the headline number is precise. The project is tested against the jank-lang clojure-test-suite, and the README states 5621 out of 5621 assertions pass across 232 files through the :clj reader lens, with no known failures, compile skips, panic skips or runtime skips. The core namespaces covered are clojure.core with macros, lazy sequences, transducers, protocols, records, multimethods and BigInt and BigDecimal, plus string, set, walk, edn, pprint, test and core.async, and alongside those let-go's own io, http, json, transit, os, System, syscall and pods namespaces. Two documents carry the detail, docs/guide/clojure-compatibility.md for the per-namespace status table and docs/known-divergences.md for intentional differences, temporary mismatches and the rationale behind the shared suite :lg overrides.
Reader conditionals keep shared code loadable on both runtimes
let-go ships namespaces of its own, such as let-go.semver, that JVM Clojure cannot load, and the solution is the same mechanism Clojure already uses for :cljs. The let-go-only parts are guarded behind :lg reader conditionals in a .cljc file, and JVM Clojure skips the :lg branches exactly as it skips :cljs:
(ns my.app
#?(:lg (:require [let-go.semver :as semver]))) ; only let-go loads thisThe guard is applied at read time, so a missing namespace never reaches compilation, which means a file that is broken on one runtime fails cleanly rather than half way through evaluation. The docs also mention a :lg and :clj ordering gotcha, and there is a portability guide covering the .cljc resolution rule. The related version guard uses the same pattern, with require-letgo asserting at load time that the running lg build is new enough, and failing with one clear line rather than a cascade of cannot resolve errors:
(ns my.app
#?(:lg (:require [let-go.semver :refer [require-letgo]])))
#?(:lg (require-letgo ">=1.9.0")) ; one clear failure line on too-old lgThe semver namespace provides values that sort correctly, npm and cargo style range matching through satisfies-range with comparators, x-ranges, caret and tilde and logical or, and it is documented in docs/guide/semver.md.
Babashka pods extend the reach into the JVM ecosystem
let-go can load Babashka pods, which opens the pod ecosystem, with SQLite, AWS, Docker and file watching named in the README, and it shares the ~/.babashka/pods/ cache with bb:
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.13")
(pod.babashka.go-sqlite3/query "app.db" ["select * from users"])This is the most interesting part of the compatibility story, because pods are written in many languages and are the practical route to reusing JVM-adjacent tooling from a Go binary. The full example, including the shared pod cache, is in docs/guide/pods.md. The wider set of things written in let-go includes a roguelike that runs in the browser and the terminal from the same source, and the daemonless container runtime built on the syscall namespace.
The known limitations, and how the project is packaged
The README has a dedicated section on the gaps, and the list is specific. There is no coordinated STM and no async agents, with ref and agent being atom-backed aliases, no clojure.spec, unchunked lazy sequences, limited JVM host interop on deftype and reify, and no subseq or rsubseq range queries. Behaviour differs in places too, with a pragmatic numeric tower, always-blocking channels, real-goroutine go blocks and re2 rather than Java regex. The stated non-goals are a drop-in JVM Clojure replacement and a linter or formatter for Clojure at large, and the README is clear that most idiomatic Clojure code runs unmodified but a real project with library dependencies will need adjustments. Packaging is a Go module named github.com/nooga/let-go requiring Go 1.27, with a Dockerfile that builds the binary in a golang:alpine stage and copies it into an alpine image whose entry point is /bin/let-go, and the repository also ships a HomebrewFormula, a goreleaser configuration and a Makefile that pins the Clojure toolchain.
Editorial conclusion
let-go fits a developer who wants Clojure's shape in a program that starts instantly and ships as one static binary, and who is writing the code rather than porting a large library dependency tree. It does not fit a team that needs the JVM Clojure ecosystem as it stands, since the README lists missing coordinated STM, agents, clojure.spec, unchunked lazy sequences and limited host interop. Before committing, read the known limitations list, check whether the libraries your project depends on are in the supported namespace set, and run the shared clojure-test-suite locally, because the compatibility claim in the README is measured against that suite and nothing wider.
Frequently asked questions
Is let-go a drop-in replacement for Clojure on the JVM?
No. The README says most idiomatic Clojure code runs unmodified, but a real project with library dependencies will need adjustments, and it lists no coordinated STM or async agents, no clojure.spec, unchunked lazy sequences, limited host interop on deftype and reify, and no subseq or rsubseq queries.
How compatible is let-go with Clojure?
The README reports 5621 out of 5621 assertions passing across 232 files of the jank-lang Clojure test suite through the :clj reader lens, with no known failures, compile skips, panic skips or runtime skips, and it lists the covered namespaces including clojure.core, core.async and let-go's own io, http, json, transit, os, syscall and pods.
What is the difference between the let-go VM and its AOT mode?
Both run the same program and differ only in dispatch, as the benchmark description puts it. The VM is competitive on short-lived data work, with map and filter at 11.3ms, while AOT lowering changes the call-heavy numeric cases, taking fib at 35 from 2.42s to 0.11s and tak from 2.40s to 94ms.
How do I write code that runs on both let-go and JVM Clojure?
Use :lg reader conditionals in a .cljc file, for example requiring let-go.semver only under the :lg branch. The guard applies at read time so a missing namespace never reaches compilation, and for version checks you can use require-letgo, which fails with a single clear line when the running lg build is too old.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/nooga-let-go)Was this useful?