Pure-Go SQLite: the no-CGO trick behind a single cross-platform binary
Part of my series on RiskRancher, the open-source vulnerability manager I build. Source: github.com/Kuebiko-LLC/risk-rancher-core.
Intro
In the last post I said RiskRancher is one Go binary and a SQLite file. That sounds simple, but there’s a specific technical choice that makes it actually work: the SQLite driver is pure Go, with no CGO. If I’d used the usual driver, the “download the binary for your OS and run it” story would have fallen apart. Let me explain why, because this is the kind of decision that quietly determines whether a project ships easily or painfully.
I. The problem with the normal SQLite driver
The popular Go SQLite driver (mattn/go-sqlite3) wraps the real C SQLite library, which means it needs CGO. CGO is Go calling C, and the moment you use it, a few nice properties go away:
- You need a C compiler to build, on every platform you target.
- Cross-compiling gets hard. Building a Linux binary from your Mac is no longer just
GOOS=linux go build; now you need a cross C toolchain too. - The binary isn’t cleanly static. It can pick up a dependency on the system’s C library, so “works on my machine” and “works on the server” drift apart.
For a tool whose whole pitch is “grab the binary and run it,” every one of those is a problem. I’d be shipping a thing that’s annoying to build and might not run where you put it.
II. The pure-Go driver
RiskRancher uses modernc.org/sqlite instead, which is SQLite translated into pure Go. No C, no CGO, no compiler to install. That one swap changes everything about building and shipping:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o rr-linux-amd64 ./cmd/rr
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o rr-darwin-arm64 ./cmd/rr
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o rr-windows-amd64.exe ./cmd/rr
Three platforms, one machine, no cross toolchains. Each output is a single static binary with nothing to link against at runtime. That’s how the releases page can just list rr-linux-amd64, rr-darwin-arm64, and rr-windows-amd64.exe and have each one simply run.
III. It’s also what makes air-gapping believable
There’s a security angle here too. A static, pure-Go binary has no surprise runtime dependencies to reach out for. You’re not hoping the right version of a C library is on the air-gapped server, and there’s no dynamic linking that could pull something unexpected. The binary is self-contained in the strongest sense: what you carried onto the box is exactly what runs. For a tool meant to run on isolated networks, that self-containment is a feature, not a nicety.
IV. The trade-off, honestly
Pure-Go SQLite isn’t free. The translated engine is generally a bit slower than the hand-tuned C original, especially on heavy write bursts. For RiskRancher’s workload, ingesting and triaging findings for a team, that difference is well within what SQLite handles comfortably, and I’d happily trade a little raw speed for binaries that build in one command and run everywhere. If you were building a write-heavy OLTP system, you might choose differently. Know your workload and pick accordingly.
Conclusion
The single-binary promise rests on an unglamorous dependency choice: a pure-Go SQLite driver so the whole thing builds with CGO_ENABLED=0 and cross-compiles from one machine to every platform. It made building easy, shipping easy, and air-gapping believable, at the cost of a little speed I don’t miss. If you’re building a Go tool you want people to download and run anywhere, drop CGO before it drops you. Thanks for reading, and may your builds stay static.