Deployment and binary size
The first three articles in this track compared the languages while the program was running:
parsing under load, waiting on concurrent work, and failing on bad input. This one compares what
happens after the code is finished — the artifact that comes out of the build, how big it is,
whether it runs on a machine it was not built on, and what ends up in the container around it. Go
has a reputation here: one static binary, trivial cross-compilation, a FROM scratch image. The
reputation is not wrong, but the numbers say something more specific, and the numbers are the
point of this article, as they have been for the whole track.
The method is the same as always: the same program written twice, built several ways each, with every size and every line of output below taken from real runs on an Apple M2 Max with rustc 1.99.0 (edition 2024) and go1.27.3 darwin/arm64.
The program
Section titled “The program”A ten-line service stub: take an optional port argument, print where it would listen, exit. It is deliberately trivial, and that is the measurement design — when the program does almost nothing, every byte in the binary belongs to the toolchain and its runtime rather than to the code, so the sizes measure the floor each language ships, not the program.
Rust:
fn main() { let port: u16 = std::env::args() .nth(1) .and_then(|s| s.parse().ok()) .unwrap_or(8080); println!("deploy-me listening on port {port}");}Go:
package main
import ( "fmt" "os" "strconv")
func main() { port := 8080 if len(os.Args) > 1 { if n, err := strconv.Atoi(os.Args[1]); err == nil { port = n } } fmt.Printf("deploy-me listening on port %d\n", port)}Both fail silently on a bad argument and fall back to 8080 — the exact behavior the previous article spent its whole length on, kept here on purpose so the programs stay identical. This article is about everything wrapped around that behavior.
$ ./deploy-medeploy-me listening on port 8080$ ./deploy-me 9000deploy-me listening on port 9000$ ./deploy-me not-a-portdeploy-me listening on port 8080What you ship by default
Section titled “What you ship by default”Two commands, no configuration: cargo build --release and go build -o deploy-me-go main.go.
$ ls -l target/release/deploy-me deploy-me-go-rwxr-xr-x@ 1 rango staff 2430274 Oct 30 20:23 deploy-me-go-rwxr-xr-x@ 1 rango staff 436912 Oct 30 20:23 target/release/deploy-meOut of the box the Rust binary is 437 KB and the Go one is 2.3 MB — a 5.6× difference. The reason is what is inside. Both toolchains statically link their standard library into the executable; there is no “Rust runtime DLL” or “Go runtime DLL” to install. The difference is what the standard library contains. Go’s runtime ships a garbage collector, the goroutine scheduler, the channel machinery, and the reflection metadata the whole standard library leans on, and it ships them in full no matter how little of it the program uses. Rust’s standard library has no collector and no scheduler — async executors and collections live in libraries, and the linker only pulls in the parts of std the program actually touches. A ten-line Rust program pays for ten lines’ worth of runtime; a ten-line Go program pays for the whole runtime, because its design assumes you are about to use it.
Build time on a cold cache, for the same two files — relevant because it is what a CI pipeline pays on every deploy:
$ cargo clean && time cargo build --release Finished `release` profile [optimized] target(s) in 0.38s 0.42 real 0.24 user 0.11 sys$ time GOCACHE=/tmp/go-fresh-cache go build -o deploy-me-go main.go 2.00 real 5.62 user 1.30 sysBoth are fast because neither program has dependencies. The caveat matters and is worth stating
plainly: these numbers say nothing about a real service pulling in axum or a web framework. The
first article in this track measured where real costs live, and at zero dependencies the Rust
toolchain compiles a program faster than Go does. The moment Cargo.toml gains dependencies,
that advantage inverts, and it stays inverted — dependency compile time is the one deployment cost
Rust reliably pays more of.
Stripping
Section titled “Stripping”Both binaries ship with symbols and debug info by default — but for different reasons, and the default is more negotiable on the Rust side:
[package]name = "deploy-me"version = "0.1.0"edition = "2024"
[dependencies]
[profile.release]strip = truego build -ldflags "-s -w" -o deploy-me-go main.go$ ls -l target/release/deploy-me deploy-go-stripped-rwxr-xr-x@ 1 rango staff 1587810 Oct 30 20:23 deploy-go-stripped-rwxr-xr-x@ 1 rango staff 343424 Oct 30 20:23 target/release/deploy-meStripping takes Rust from 437 KB to 336 KB — a quarter of the binary was symbol table. Go goes
from 2.3 MB to 1.5 MB, a bigger cut but the same 4.6× ratio. The interesting part is that Go has
to be told to do this with linker flags, and doing it has a real cost: those symbols are what Go’s
runtime uses to print stack tracebacks. A stripped Go binary that panics reports addresses where
the default one reports function names and line numbers. Rust has no equivalent runtime
mechanism — backtraces come from the OS and the panic handler, and the strip profile setting
does not remove the unwind information they need. So the Go default (fat, debuggable) versus the
Rust default (leaner already, one line to make leaner) reflects each runtime’s relationship with
its own stack traces.
Static linking
Section titled “Static linking”“Static binary” means the file makes no requests to shared libraries at load time — copy it to a
machine with nothing installed and it runs. Both toolchains can produce one for Linux, and file
is the witness. Go first, with the one flag that matters, CGO_ENABLED=0, which turns off the C
interoperability layer and with it the dynamic libc dependency:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o deploy-go-linux main.go$ file deploy-go-linuxdeploy-go-linux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=xRZF9bneo26jbB5bvM7-/kWAA2JrUv6kJUT3zFaoE/o6zkobxOYnM4dNR_1LhP/CEdkzDA4FryiwvDf_t6z, BuildID[sha1]=72776342da805b55e80c014779fede3b1c7f7596, strippedFor Rust the target triple does the same job: x86_64-unknown-linux-musl means “link against
musl, the static libc”. One prerequisite — the standard library for that target, which rustup
downloads once (rustup target add x86_64-unknown-linux-musl) — and one linker setting, because
the default linker on macOS cannot link an ELF binary. rust-lld ships with rustc, so this
two-line stanza in .cargo/config.toml is the whole story:
[target.x86_64-unknown-linux-musl]linker = "rust-lld"cargo build --release --target x86_64-unknown-linux-musl$ file target/x86_64-unknown-linux-musl/release/deploy-metarget/x86_64-unknown-linux-musl/release/deploy-me: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, strippedBoth files are static ELF binaries that run on any glibc-free Linux. Two details hide in the
file output. Go says statically linked, Rust says static-pie linked — the Rust binary is
position-independent, so the kernel can place it anywhere in memory and it benefits from ASLR; Go
could be built that way too with -buildmode=pie, and the default is the fixed-address form. And
the sizes, from the same ls -l as above: Go 1,527,968 bytes, Rust 435,984 — the stripped Rust
binary got slightly bigger crossing to musl (435,984 versus 343,424 on macOS, the static musl
libc costing more than macOS’s libSystem) while the Go one got slightly smaller.
One asymmetry worth knowing before choosing targets: the -musl targets need no C toolchain at
all when rust-lld is the linker, but the -gnu targets do — they want a cross C compiler and
linker matching the target, which is the difference between a two-line config file and an
afternoon with a cross toolchain. Go needs neither for any of its targets: cross-compilation is
the next section.
Cross-compilation
Section titled “Cross-compilation”Build a Linux binary from a Mac, and a Windows one too. Go, in full:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o deploy-go-linux main.goGOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o deploy-go.exe main.go$ ls -l deploy-go-linux deploy-go.exe-rwxr-xr-x@ 1 rango staff 1527968 Oct 30 20:23 deploy-go-linux-rwxr-xr-x@ 1 rango staff 1668608 Oct 30 20:23 deploy-go.exeTwo environment variables, and the Go toolchain — which carries its own code generator for every supported OS/architecture pair — emits a working binary for each. This is the feature behind Go’s deployment reputation, and it is genuinely unmatched: no other mainstream toolchain ships a one-command matrix covering linux/darwin/windows across amd64, arm64, and a dozen smaller architectures, with nothing to install.
Rust covers the same matrix, one target at a time. Each target needs its standard library
downloaded once (rustup target add aarch64-unknown-linux-musl, or x86_64-pc-windows-gnu, and
so on), plus a linker line in .cargo/config.toml where the target is not the host’s. The
mechanics per target are as small as the Go command, but they multiply: one rustup line, one
config stanza, one cargo build --target — per platform, instead of two variables. That is the
whole difference. It is more ceremony, not a different outcome, and for a deploy matrix that
contains exactly one platform (Linux servers, overwhelmingly the common case) the ceremony is
paid once.
Container images
Section titled “Container images”With both binaries static, the container question answers itself, and the Dockerfiles are where the payoff shows. This one ships the Go build:
FROM golang:1.27 AS buildWORKDIR /srcCOPY . .RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /deploy-me .
FROM scratchCOPY --from=build /deploy-me /deploy-meENTRYPOINT ["/deploy-me"]And this one the Rust build:
FROM rust:1.99 AS buildWORKDIR /srcCOPY . .RUN cargo build --release --target x86_64-unknown-linux-musl
FROM scratchCOPY --from=build /src/target/x86_64-unknown-linux-musl/release/deploy-me /deploy-meENTRYPOINT ["/deploy-me"]FROM scratch is an empty image — no shell, no libc, no package manager. It works because both
binaries above are static ELF files that need nothing from the outside. Whatever the image costs
to pull is the binary plus a COPY line: the sizes in the tables above are effectively the image
sizes. No Docker ran on the machine this article was written on, so no image size is reported
here — but unlike most “it depends” honesty, this one has a hard floor: an image built from these
files cannot be smaller than 1.5 MB (Go) or 436 KB (Rust), and without adding a base layer it has
nothing in it to be much bigger.
Two caveats that survive the elation of a 436 KB image. First, a scratch image has no shell and
no CA certificates, which means HTTPS from inside the container needs the cert bundle copied in
(COPY --from=build /etc/ssl/certs/ca-certificates.crt ... — the golang and rust build
stages both have it), and debugging means docker run --rm -it --entrypoint /busybox/sh with a
busybox stashed somewhere. Second, the build stage in both files is enormous — the rust:1.99
image is a full toolchain with every target’s std, and the builder exists only to produce one
file. Multi-stage exists precisely so the fat part never reaches the registry; CI time still
pays for it.
What the numbers say
Section titled “What the numbers say”The same ten-line program, shipped six ways:
| binary | bytes | what it is |
|---|---|---|
| Rust, release, macOS arm64 | 436,912 | default, symbols included |
| Rust, stripped, macOS arm64 | 343,424 | strip = true in the profile |
| Rust, musl, linux amd64 | 435,984 | static-pie ELF, cross-compiled |
| Go, default, macOS arm64 | 2,430,274 | runtime, GC, scheduler included |
| Go, stripped, macOS arm64 | 1,587,810 | -ldflags "-s -w" |
| Go, static, linux amd64 | 1,527,968 | CGO_ENABLED=0, cross-compiled |
| Go, windows amd64 | 1,668,608 | two env vars away |
Go’s reputation survives contact with the numbers, but narrowed: it wins on cross-compilation unambiguously — one command, any target, nothing to install — and its floor is about 1.5 MB against Rust’s 343 KB, a 4× difference that matters on edge devices and not at all on a registry where the transfer is a one-time diff of a megabyte. Both ship a single static file that runs in an empty container. The real cost asymmetry is on the build side and points the other way: Go compiles a dependency-heavy service in tens of seconds where Rust pays minutes, on every CI run, forever. Deployment is the rare axis where the size story favors Rust while the iteration story favors Go — and neither, at these magnitudes, is a reason to pick one over the other. The next article looks at a difference that might be: the people available to write the code.
Run it yourself
Section titled “Run it yourself”Rust:
cargo new deploy-me && cd deploy-me# paste src/main.rs from the Rust section abovecargo build --release./target/release/deploy-me 9000Go — no module setup needed for the single file:
go build -o deploy-me-go main.go./deploy-me-go 9000For the static and cross-compiled builds, the exact commands are the ones shown in each section;
the musl target needs rustup target add x86_64-unknown-linux-musl and the two-line
.cargo/config.toml first. Everything else on this page came from rustc 1.99.0 (edition 2024)
and go1.27.3, as shown.
The track overview lists what is left: who is available to hire in each language and what onboarding costs, then the decision table that weighs everything this track has measured.