Post

Rust vs Go for a CLI Tool: Startup, Binary Size, Build Time and the Day-Two Costs

Rust 1.81 vs Go 1.23 for the same log-grep CLI: cold start, binary size, build time, memory, cross-compiling and the maintenance costs that decide it.

Rust vs Go for a CLI Tool: Startup, Binary Size, Build Time and the Day-Two Costs

The Python vs Rust hot loop and Go vs .NET concurrency posts looked at raw runtime speed. A command-line tool asks a different question. Nobody notices whether a CLI finishes a 200 ms job in 180 ms or 250 ms; they notice whether it starts instantly, whether the binary they curl from a release page just runs, and whether the team can still add a flag a year later without an afternoon of fighting the compiler.

So I built the same small tool twice - loggrep, which scans a log file for a pattern, parses the timestamp on each matching line and prints a per-minute histogram - in Rust 1.81 and Go 1.23, and compared them on the axes that matter for a CLI: startup, binary size, build loop, memory, distribution and maintainability.

The tool under test

  • Positional args: a pattern and one or more file paths; flags for --since, --json and --color.
  • Streams the file line by line; regex match, timestamp parse, bucket count.
  • Input for the benchmark: a 1.2 GB, 9.4 million line application log.

Idiomatic stacks only: Rust with clap, regex, memchr and anyhow; Go with the standard flag package, regexp and bufio. Release builds (cargo build --release with lto = "thin", go build -ldflags='-s -w') on an Apple M2 laptop, re-checked on a Linux x86-64 box.

Runtime: both are fast, Rust is faster

Terminal screenshot of hyperfine comparing loggrep-rs at 184.3 ms mean against loggrep-go at 246.9 ms mean, 1.34 times faster, followed by ls -lh showing 3.2M and 8.9M binaries and clean build times of 41.02 s for cargo and 2.8 s for go build

 Rust 1.81Go 1.23
Scan 1.2 GB log (mean)184 ms247 ms
Cold start, --help1.1 ms2.4 ms
Peak RSS14 MB31 MB
Stripped binary3.2 MB8.9 MB

Rust’s edge comes from two places: regex with memchr prefilters is about 30% faster than Go’s regexp on this literal-heavy pattern (the regex engine comparison has the detail), and there is no garbage collector to warm up or runtime to initialise. The Go cold start includes spawning the scheduler and GC worker goroutines; 2.4 ms is still invisible to a human, and in shell-completion scripts or git hooks that call the tool hundreds of times it is the difference between 0.1 s and 0.25 s. Noticeable, not decisive.

Build loop: Go wins by an order of magnitude

This is the number that surprised people on the team. A clean release build of the Rust version takes 41 seconds; the Go version takes under 3. Incremental debug builds narrow the gap (1.8 s Rust vs 0.6 s Go for a one-line change), but every CI run, every cargo update and every fresh clone pays the full price, mostly compiling regex, clap and their dependency tree.

Go’s build is fast because the compiler does less: no monomorphisation of generics per call site, no LLVM optimisation passes to speak of, and a dependency tree that is a quarter the size because the standard library already covers flags, regex and buffered I/O.

 RustGo
Clean release build41.0 s2.8 s
Incremental debug build1.8 s0.6 s
Direct dependencies50
Transitive crates/modules380
Lines of code412356

Distribution: static binaries both ways, with one catch

Both produce a single self-contained executable, which is the whole reason to pick either over Python or Node for a CLI. Cross-compiling is where they differ.

Go cross-compiles with two environment variables: GOOS=linux GOARCH=arm64 go build works from any host and produces a fully static binary by default when cgo is off. Six targets in one 20-second CI job.

Rust needs a target installed (rustup target add x86_64-unknown-linux-musl) and, for anything that links C, a cross linker. cargo-zigbuild or cross makes this painless, but it is a second tool to install and keep updated. For this tool, with no C dependencies, the musl target gave a 3.4 MB static binary that ran on every distro we tried.

Error handling and maintainability

The code that changes most in a CLI is argument parsing and error reporting, so this is where day-two cost lives.

Rust with clap’s derive macros turns a struct into a parser with help text, validation, env-var fallbacks and shell completions, and the compiler rejects a flag that is read but never declared. Error paths use ? with anyhow context, so the user sees failed to open app.log: No such file or directory with no extra code.

Go’s flag package is smaller and shows it: subcommands, repeated flags and completions are hand-rolled or need cobra. Every I/O call is followed by if err != nil { return fmt.Errorf("open %s: %w", path, err) }, which is explicit and greppable but roughly doubled the line count of the file-handling code. On the other hand, a new contributor read the Go version end to end in ten minutes; the Rust version’s lifetimes in the streaming iterator took longer to explain.

 RustGo
Arg parsingclap derive, completions includedflag stdlib; cobra for subcommands
Error ergonomics? + anyhow/thiserrorexplicit if err != nil
Compiler catcheslifetimes, exhaustiveness, unused resultsunused imports and variables
Time to first contributionhours (borrow checker)minutes

Which would I pick?

For a tool that runs in a hot path - pre-commit hooks, shell prompts, build steps invoked thousands of times a day - or that processes gigabytes per invocation: Rust. The startup, memory and throughput wins are real, clap gives a better user-facing CLI for free, and a 41-second CI build is a fixed cost you pay once per change.

For an internal tool that a mixed team will own, that needs to ship for six platforms from one CI job, and where the workload is seconds rather than minutes: Go. The build loop and cross-compilation story are simply better, and the performance gap is one nobody will measure.

Either way, ship a static binary, strip it, and benchmark with hyperfine before arguing about the language. Most CLI slowness is unbuffered output or a regex compiled inside a loop, and both languages let you fix that in five minutes.

This post is licensed under CC BY 4.0 by the author.