Rust vs Go: Which One for CLI Tools?
Rust and Go are both excellent for CLI tools. Single binaries, fast execution, cross-platform. But they have different philosophies and trade-offs.
Quick Comparison
| Aspect | Go | Rust |
|---|---|---|
| Compile time | Fast | Slow |
| Binary size | ~10MB | ~2-4MB |
| Startup time | Fast | Fast |
| Learning curve | Easy | Steep |
| Memory safety | GC | Compile-time |
| Ecosystem | Good | Good |
Hello World CLI
Go
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: hello <name>")
os.Exit(1)
}
fmt.Printf("Hello, %s!\n", os.Args[1])
}
Rust
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: hello <name>");
std::process::exit(1);
}
println!("Hello, {}!", args[1]);
}
Similar complexity. Both work.
Argument Parsing
Go (with cobra)
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func main() {
var verbose bool
var config string
rootCmd := &cobra.Command{
Use: "myapp",
Short: "A sample CLI",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Config: %s, Verbose: %v\n", config, verbose)
},
}
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.Flags().StringVarP(&config, "config", "c", "config.yaml", "config file")
rootCmd.Execute()
}
Rust (with clap)
use clap::Parser;
#[derive(Parser)]
#[command(name = "myapp")]
#[command(about = "A sample CLI")]
struct Args {
#[arg(short, long, default_value = "config.yaml")]
config: String,
#[arg(short, long)]
verbose: bool,
}
fn main() {
let args = Args::parse();
println!("Config: {}, Verbose: {}", args.config, args.verbose);
}
Both have excellent argument parsing. Rust’s derive macros are cleaner.
File Operations
Go
package main
import (
"bufio"
"fmt"
"os"
)
func countLines(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
count++
}
return count, scanner.Err()
}
Rust
use std::fs::File;
use std::io::{BufRead, BufReader};
fn count_lines(filename: &str) -> Result<usize, std::io::Error> {
let file = File::open(filename)?;
let reader = BufReader::new(file);
Ok(reader.lines().count())
}
Rust’s ? operator and iterators make error handling elegant.
Concurrency
Go
func processFiles(files []string) {
var wg sync.WaitGroup
results := make(chan Result, len(files))
for _, file := range files {
wg.Add(1)
go func(f string) {
defer wg.Done()
result := processFile(f)
results <- result
}(file)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Println(result)
}
}
Rust
use rayon::prelude::*;
fn process_files(files: Vec<String>) {
let results: Vec<Result> = files
.par_iter()
.map(|f| process_file(f))
.collect();
for result in results {
println!("{:?}", result);
}
}
Go’s goroutines are first-class. Rust’s rayon makes parallel iteration trivial.
Binary Size
# Go (default)
$ go build -o hello-go hello.go
$ ls -la hello-go
-rwxr-xr-x 1.9M hello-go
# Go (stripped)
$ go build -ldflags="-s -w" -o hello-go-small hello.go
$ ls -la hello-go-small
-rwxr-xr-x 1.3M hello-go-small
# Rust (release)
$ cargo build --release
$ ls -la target/release/hello
-rwxr-xr-x 300K hello
# Rust (optimized)
# Cargo.toml: [profile.release] lto = true, strip = true
$ cargo build --release
$ ls -la target/release/hello
-rwxr-xr-x 200K hello
Rust produces smaller binaries by default.
Compile Times
# Go
$ time go build -o myapp .
real 0m0.5s
# Rust (debug)
$ time cargo build
real 0m15s
# Rust (release)
$ time cargo build --release
real 0m45s
Go is much faster to compile. For development iteration, this matters.
Cross-Compilation
Go
# Build for Linux from Mac
GOOS=linux GOARCH=amd64 go build -o myapp-linux
# Build for Windows
GOOS=windows GOARCH=amd64 go build -o myapp.exe
Dead simple. Works out of the box.
Rust
# Add target
rustup target add x86_64-unknown-linux-gnu
# Build (may need linker configuration)
cargo build --target x86_64-unknown-linux-gnu --release
Works, but sometimes requires additional setup for C dependencies.
Error Handling
Go
func doSomething() error {
data, err := fetchData()
if err != nil {
return fmt.Errorf("fetch failed: %w", err)
}
result, err := processData(data)
if err != nil {
return fmt.Errorf("process failed: %w", err)
}
return saveResult(result)
}
Verbose but explicit.
Rust
fn do_something() -> Result<(), Error> {
let data = fetch_data()?;
let result = process_data(data)?;
save_result(result)?;
Ok(())
}
Concise with ? operator.
My Recommendations
Choose Go When
- Fast iteration matters: Go’s compile speed makes development smoother
- Team familiarity: Go is easier to learn and onboard
- Simple networking: Go’s net/http is excellent
- Existing Go ecosystem: If you’re already using Go
# Good Go CLI examples
- kubectl
- docker
- hugo
- gh (GitHub CLI)
Choose Rust When
- Smallest binary size: Rust wins here
- Performance critical: Zero-cost abstractions
- Memory constrained: No GC overhead
- Long-lived processes: Predictable memory
# Good Rust CLI examples
- ripgrep (rg)
- fd
- bat
- exa
- starship
The “It Depends” Answer
For most CLIs, both are excellent. Your choice often comes down to:
- What you already know
- What your team knows
- Ecosystem fit for your use case
If you know neither: Start with Go. It’s easier to be productive quickly.
If you want to learn both: Build the same CLI in both. You’ll understand the trade-offs firsthand.
Final Thoughts
Go and Rust are both excellent for CLI tools. Go is simpler and compiles faster. Rust is more performant and produces smaller binaries.
The best choice is the one you’ll actually use.
The best tool is the one you can finish the project with.