Rust vs Go: Which One for CLI Tools?

rust go dev

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

AspectGoRust
Compile timeFastSlow
Binary size~10MB~2-4MB
Startup timeFastFast
Learning curveEasySteep
Memory safetyGCCompile-time
EcosystemGoodGood

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

# Good Go CLI examples
- kubectl
- docker
- hugo
- gh (GitHub CLI)

Choose Rust When

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

  1. What you already know
  2. What your team knows
  3. 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.

All posts