Skip to content
Select theme

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.

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:

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-me
deploy-me listening on port 8080
$ ./deploy-me 9000
deploy-me listening on port 9000
$ ./deploy-me not-a-port
deploy-me listening on port 8080

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-me

Out 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 sys

Both 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.

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 = true
Terminal window
go 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-me

Stripping 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 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:

Terminal window
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o deploy-go-linux main.go
$ file deploy-go-linux
deploy-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, stripped

For 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"
Terminal window
cargo build --release --target x86_64-unknown-linux-musl
$ file target/x86_64-unknown-linux-musl/release/deploy-me
target/x86_64-unknown-linux-musl/release/deploy-me: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, stripped

Both 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.

Build a Linux binary from a Mac, and a Windows one too. Go, in full:

Terminal window
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o deploy-go-linux main.go
GOOS=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.exe

Two 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.

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 build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /deploy-me .
FROM scratch
COPY --from=build /deploy-me /deploy-me
ENTRYPOINT ["/deploy-me"]

And this one the Rust build:

FROM rust:1.99 AS build
WORKDIR /src
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=build /src/target/x86_64-unknown-linux-musl/release/deploy-me /deploy-me
ENTRYPOINT ["/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.

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.

Rust:

Terminal window
cargo new deploy-me && cd deploy-me
# paste src/main.rs from the Rust section above
cargo build --release
./target/release/deploy-me 9000

Go — no module setup needed for the single file:

Terminal window
go build -o deploy-me-go main.go
./deploy-me-go 9000

For 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.