Skip to content

Compile & ship binaries

cljgo build is the ClojureScript model with Go as the JavaScript: the compiler emits plain Go source — one Clojure namespace becomes one Go package — then invokes go build on the generated module. The result is a single static native binary. No JVM, no runtime install, nothing to ship but the file.

$ cljgo run hello.clj # interpreted
$ cljgo build hello.clj # -> ./hello, a static native binary
$ ./hello # byte-identical output

Measured 2026-08-02 on the reference machine (Apple M5 Pro, go1.26.3): a hello binary is 7.1 MB stripped, starts in ~5 ms, peaks at 12.4 MB RSS. Numbers, methodology, and head-to-head comparisons are on the benchmarks page.

cljgo build is the one command that needs the Go toolchain on PATH (cljgo run and cljgo repl do not — see Install). Release binaries pin the published runtime module in the generated go.mod; the first build fetches it from the Go module proxy once per machine.

cljgo build [-o out] [-gen dir] [-runtime dir] <file.clj>
  • -o out — output binary path. Default: the file’s base name, or the parent directory name for a core.clj (examples/hello/core.cljhello). On Windows the default gets .exe; an explicit -o is honored verbatim, same rule as go build -o.
  • -gen dir — keep the generated Go module there instead of a temp dir. The output is honest, gofmt-ed Go source you can read; a // Code generated by cljgo build. DO NOT EDIT. header marks each file.
  • -runtime dir — the cljgo source tree used for the generated go.mod’s replace directive (defaults to $CLJGO_SRC; release binaries pin the published module instead).

Source files may be .clj, .cljc, .cljg, or .cljgo (ADR 0055). For a project that targets JVM Clojure and cljgo from one .cljc tree, see Dual-host .cljc projects.

The build strips by default (-trimpath -ldflags="-s -w"), and a compiled binary links the compiled clojure.core — never the interpreter. The tree-walk evaluator contributes zero symbols to the link set, CI-enforced (ADR 0046); that is why a compiled binary starts in ~5 ms instead of the interpreter’s ~32 ms boot.

With no source-file argument, cljgo build [step] loads the project’s build.cljgo (probed as build.cljgo > build.cljg), evaluates its (defn build [b] …) plan, and runs the requested step — the zig-build model (ADR 0021):

;; build.cljgo — what `cljgo new --template cli` generates
(defn build [b]
(let [app (exe b {:name "newapp"
:main "src/newapp/core.cljg"})]
(install b app)
(run b app)))
$ cljgo build # default step: install ./newapp
$ cljgo build run # build it and run it (mirrors `zig build run`)

Project builds also resolve declared dependencies and write/verify build.lock.edn — see Dependencies & publishing. Third-party Go modules declared with (go-require …) are fetched and linked into the binary here.

Every cljgo binary is pure Go, CGO_ENABLED=0. That constraint has a payoff: the Go toolchain cross-compiles to any platform from a single host, with no C cross-toolchain, no sysroot, no per-OS CI runner. cljgo dist (ADR 0077) turns that into one command:

$ cljgo dist
cljgo dist: building darwin/arm64 -> dist/myapp_darwin-arm64
cljgo dist: building darwin/amd64 -> dist/myapp_darwin-amd64
cljgo dist: building linux/amd64 -> dist/myapp_linux-amd64
cljgo dist: building linux/arm64 -> dist/myapp_linux-arm64
cljgo dist: building windows/amd64 -> dist/myapp_windows-amd64.exe
cljgo dist: 5 binaries in dist/

The output directory is ready to attach to a GitHub Release or a Homebrew tap: one native binary per platform plus a sha256sum -c-compatible checksums.txt.

dist/
myapp_darwin-arm64 # Mach-O arm64
myapp_darwin-amd64 # Mach-O x86-64
myapp_linux-amd64 # ELF x86-64
myapp_linux-arm64 # ELF aarch64
myapp_windows-amd64.exe # PE32+
checksums.txt

Flags:

  • no flags — the five mainstream desktop/server targets above (Apple Silicon + Intel Mac, x86-64 + ARM Linux, Windows): effectively every real end user.
  • --target os/arch,… — an explicit subset, e.g. cljgo dist --target linux/amd64,windows/amd64. Each pair is validated against go tool dist list, so a typo is a named error, not a cryptic build failure.
  • --all — every GOOS/GOARCH the toolchain supports (the long tail — freebsd, riscv64, wasm, … — opt-in, not in the default).
  • -o dir — output directory (default dist).
  • a <file.clj> positional — single-file input, same resolution as cljgo build; a bare invocation uses the project’s build.cljgo install artifact.

dist generates the (target-independent) Go module once and re-links it per target, so the five-way build does not recompile your Clojure five times. Since dist ships executables, a library project (no install step) is a clear error pointing you at cljgo publish.

This is a capability the JVM does not have: a .jar needs a JVM on the target machine, and GraalVM native-image needs a builder per target OS/arch. cljgo needs neither — which is one more reason the pure-Go constraint stays sacred (a future cgo dependency would break dist).

The one caveat is inherited from Go: a (go-require …) third-party module that itself uses cgo breaks cross-compilation. cljgo’s own runtime and the bri framework are pure Go, so the default path always cross-compiles.

  • Top-level forms are evaluated at compile time. The emitter discovers namespaces by evaluating require forms through the interpreter, so a bare top-level (println …) in your source prints during cljgo build as well as at runtime. Put program behavior in -main.
  • Compiled output is byte-identical to the interpreter. The dual-harness conformance suite (416 oracle-cited files) runs every test through both paths on every commit; a REPL-vs-binary divergence is treated as a release blocker. See the REPL guide and architecture.

Compiled code is also where the performance work lands: chunk-aware sequence ops, direct-call emission, and int64 numeric inference (ADRs 0063–0067) put emitted hot loops at ~5× handwritten Go — details on the benchmarks page.

Questions or feedback on this page? Comment below with your GitHub account — comments are public and live in the project's GitHub Discussions.