Skip to content
rusthacks.dev
Rust

Rust for Go Developers a Practical Translation Guide

Go and Rust share a preference for plain code and explicit errors. This guide shows where they diverge: ownership, traits, generics and the way concurrency is checked.

18 min read
Summarize in AI
Flat illustration for the post “Rust for Go Developers a Practical Translation Guide”, composed around traits.

Go and Rust agree on a surprising amount. Neither has exceptions, neither has class inheritance, both prefer small composable types, and both ship a formatter, a test runner and a package tool in the box. A Go developer who opens a Rust codebase finds structs, methods, explicit error returns and a build command that works on the first try. Then the first real program hits the borrow checker, and the agreement turns out to be shallower than it looked: Go hands memory and scheduling to a runtime, and Rust hands both to the compiler.

This guide translates the Go habits you already have, shows what the compiler says when a Go reflex does not survive the trip, and measures one place where the languages really do differ. Rust examples were compiled and run on Rust 1.99 with edition 2024 using only the standard library. Go examples ran on Go 1.25.6, and timings come from an Apple M1 Max with ten cores.

What carries over and what does not

The mapping is closer than for most language pairs, so it helps to separate the parts that translate directly from the parts that only look like they do.

In GoIn RustLooks the same, is not
struct plus methodsstruct plus an impl blockMethods declare whether they borrow, mutate or consume self
interfacetraitA type must declare that it implements a trait
error returnResult<T, E>The error is a typed value, and ? propagates it
nilOption<T>Absence is a type, so it cannot be forgotten
deferDropCleanup runs when the owner leaves scope, not at function end
goroutinestd::thread or an async taskNo scheduler ships with the language
garbage collectorownershipFreeing is decided at compile time
zero valueDefaultOpt-in, not automatic
genericsgenerics with trait boundsEach instantiation is compiled separately

The third column is where the translation effort goes. The rest of this post walks the rows in the order they cause trouble.

No garbage collector, so cleanup has an owner

In Go you write defer conn.Close() and the runtime frees memory on its own schedule. In Rust a value has exactly one owner, and when the owner goes out of scope the value is dropped. If the type implements Drop, its cleanup code runs at that moment. That makes Drop the closest relative of defer, with a difference worth seeing.

rust
struct Conn(&'static str);

impl Drop for Conn {
    fn drop(&mut self) {
        println!("closing {}", self.0);
    }
}

fn work(fail: bool) -> Result<(), String> {
    let _db = Conn("db");
    let _cache = Conn("cache");
    if fail {
        return Err("boom".to_string());
    }
    println!("work done");
    Ok(())
}

fn main() {
    println!("{:?}", work(false));
    println!("{:?}", work(true));
}
Text
work done
closing cache
closing db
Ok(())
closing cache
closing db
Err("boom")

Conn prints a message when it is dropped. In work, two connections are created and the function either finishes or returns early. In both runs the same thing happens: cache closes first and db second, which is reverse order of creation, and the early return Err(...) path closes them too. A Go programmer would write two defer lines and get the same order, but would have to remember to write them. Here the cleanup is attached to the type, so every function that holds a Conn gets it for free, and forgetting is not possible. The ownership model also explains the main surprise of this section: there is no pause. Memory is freed at known points in your code, so a tail-latency spike from collection does not exist, and memory use is whatever your program holds. The cost is that you have to say who holds what. Rust without a garbage collector goes through the consequences, and ownership explained is the base for everything below.

Zero values and nil become Default and Option

Go gives every type a usable zero value, and nil stands for a missing pointer, map entry or error. Rust has no implicit zero value, and safe code has no null.

rust
use std::collections::HashMap;

#[derive(Debug, Default)]
struct Settings {
    retries: u32,
    name: String,
    verbose: bool,
}

fn main() {
    let s = Settings::default();
    println!("{s:?}");
    println!("retries={} name={:?} verbose={}", s.retries, s.name, s.verbose);

    let ages: HashMap<&str, u32> = HashMap::from([("ada", 36)]);
    match ages.get("bob") {
        Some(age) => println!("bob is {age}"),
        None => println!("no bob"),
    }
    let age = ages.get("bob").copied().unwrap_or_default();
    println!("defaulted: {age}");
}
Text
Settings { retries: 0, name: "", verbose: false }
retries=0 name="" verbose=false
no bob
defaulted: 0

The first half derives Default for the struct, which produces zero, empty string and false, exactly what Go would give you for free. The difference is that you asked for it, so a struct with a field where zero is not a valid state can simply decline to implement Default and force callers to construct it properly. The second half is the map lookup. In Go, ages["bob"] returns zero and you write v, ok := ages["bob"] if you care to distinguish absence from zero. In Rust, get returns Option<&u32> and the compiler will not let you read the value until you have handled None. When zero genuinely is the answer you want, .copied().unwrap_or_default() says so on the line. Option explained has the full set of combinators.

Interfaces are structural, traits are declared

The biggest conceptual shift for Go developers is that interfaces are satisfied implicitly. Any type with a Speak() string method is a Speaker, and the type never mentions the interface.

go
package main

import (
	"errors"
	"fmt"
	"os"
	"strconv"
)

type Speaker interface{ Speak() string }

type Dog struct{}

func (Dog) Speak() string { return "woof" }

func announce(s Speaker) { fmt.Println(s.Speak()) }

func sumFile(path string) (int, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return 0, fmt.Errorf("read failed: %w", err)
	}
	n, err := strconv.Atoi(string(b[:len(b)-1]))
	if err != nil {
		return 0, fmt.Errorf("bad number: %w", err)
	}
	return n, nil
}

func main() {
	announce(Dog{})
	_, err := sumFile("missing.txt")
	fmt.Println(err, errors.Is(err, os.ErrNotExist))
}

Rust requires the type to say so with impl Speaker for Robot. Forget it and the compiler stops you at the call site.

rust
trait Speaker {
    fn speak(&self) -> String;
}

struct Dog;
struct Robot;

impl Speaker for Dog {
    fn speak(&self) -> String {
        "woof".to_string()
    }
}

fn announce(s: &impl Speaker) {
    println!("{}", s.speak());
}

fn main() {
    announce(&Dog);
    announce(&Robot);
}
Text
error[E0277]: the trait bound `Robot: Speaker` is not satisfied
  --> src/main.rs:20:14
   |
20 |     announce(&Robot);
   |     -------- ^^^^^^ unsatisfied trait bound
   |     |
   |     required by a bound introduced by this call
   |
help: the trait `Speaker` is not implemented for `Robot`
  --> src/main.rs:6:1
   |
 6 | struct Robot;
   | ^^^^^^^^^^^^
help: the trait `Speaker` is implemented for `Dog`
  --> src/main.rs:8:1
   |
 8 | impl Speaker for Dog {
   | ^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `announce`
  --> src/main.rs:14:22
   |
14 | fn announce(s: &impl Speaker) {
   |                      ^^^^^^^ required by this bound in `announce`

For more information about this error, try `rustc --explain E0277`.

The error names the exact missing relationship, Robot: Speaker, points at the definition of Robot, and helpfully shows that Dog does implement the trait, which usually tells you what you meant to write. The extra declaration looks like ceremony until you consider what it buys: a method that happens to share a name with a trait method cannot accidentally satisfy it, and you can implement your own trait for someone else's type (within the orphan rule), something Go forbids because methods must live in the type's own package. Here is the fixed version, this time using dynamic dispatch.

rust
trait Speaker {
    fn speak(&self) -> String;
}

struct Dog;
struct Robot {
    model: u32,
}

impl Speaker for Dog {
    fn speak(&self) -> String {
        "woof".to_string()
    }
}

impl Speaker for Robot {
    fn speak(&self) -> String {
        format!("unit {} online", self.model)
    }
}

fn announce(s: &dyn Speaker) {
    println!("{}", s.speak());
}

fn main() {
    let crowd: Vec<Box<dyn Speaker>> = vec![Box::new(Dog), Box::new(Robot { model: 7 })];
    for s in &crowd {
        announce(s.as_ref());
    }
}
Text
woof
unit 7 online

The function announce takes &dyn Speaker, a trait object, which is the exact analogue of a Go interface value: a pointer to the data plus a table of method pointers, called through at runtime. The vector holds Box<dyn Speaker> so that differently sized types can live in one list. This is the form to reach for when you need a heterogeneous collection or want to avoid a type parameter spreading through your signatures. The earlier version, &impl Speaker, is the other option, and it has no Go counterpart: the compiler generates a separate copy of announce for each concrete type, calls are direct and can be inlined, and you pay in binary size rather than in indirection. Go's own generics sit between the two. Choosing between them is covered in traits explained, with the compile-time cost of the generic form in monomorphization bloat.

if err != nil becomes the question mark

Go programmers write the same four lines hundreds of times. Rust encodes the same decision in the type system and gives you one character for the common case. The next program sums the numbers in a file and has two ways to fail: reading the file and parsing a line.

rust
use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum LoadError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl fmt::Display for LoadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            LoadError::Io(e) => write!(f, "read failed: {e}"),
            LoadError::Parse(e) => write!(f, "bad number: {e}"),
        }
    }
}

impl From<io::Error> for LoadError {
    fn from(e: io::Error) -> Self {
        LoadError::Io(e)
    }
}

impl From<ParseIntError> for LoadError {
    fn from(e: ParseIntError) -> Self {
        LoadError::Parse(e)
    }
}

fn sum_file(path: &str) -> Result<i64, LoadError> {
    let text = fs::read_to_string(path)?;
    let mut total = 0;
    for line in text.lines() {
        total += line.trim().parse::<i64>()?;
    }
    Ok(total)
}

fn main() {
    fs::write("nums.txt", "10\n20\n 12 \n").unwrap();
    fs::write("bad.txt", "10\ntwenty\n").unwrap();
    for path in ["nums.txt", "bad.txt", "missing.txt"] {
        match sum_file(path) {
            Ok(n) => println!("{path}: {n}"),
            Err(e) => println!("{path}: {e}"),
        }
    }
}
Text
nums.txt: 42
bad.txt: bad number: invalid digit found in string
missing.txt: read failed: No such file or directory (os error 2)

LoadError is an enum with one variant per failure source, which plays the role of Go's wrapped errors but is a closed set the caller can match on instead of probing with errors.As. The two From implementations are the important part. When a function returning Result<_, LoadError> applies ? to an io::Error, the operator calls From::from and converts it automatically, so fs::read_to_string(path)? and line.trim().parse::<i64>()? each do what a three-line if err != nil block does, with the conversion done for you. Go wraps with fmt.Errorf("...: %w", err) and tests with errors.Is; the Rust equivalent keeps the cause in a field and exposes it through source(). In output, the three runs print the sum, the parse failure and the missing-file error with the operating system's message.

For application code the boilerplate above is usually replaced by the anyhow crate, which gives you a single catch-all error with context, and library code uses thiserror to derive the Display and From impls. The trade-off between the two is the subject of error handling in Rust. One thing has no Go analogue at all: panic! exists, but it is for bugs, and idiomatic Rust does not use it for control flow the way some Go code reaches for panic and recover.

Goroutines and channels: the same shape, different guarantees

This is the section most Go developers came for. Start with the worker pool every Go tutorial contains: three workers read words from a channel, send back lengths, and the main goroutine collects and sorts them.

go
package main

import (
	"fmt"
	"sort"
	"sync"
	"unicode/utf8"
)

type result struct {
	word string
	n    int
}

func main() {
	words := []string{"gopher", "ferris", "goroutine", "borrow", "channel", "trait", "interface", "lifetime"}
	jobs := make(chan string)
	results := make(chan result)

	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for w := range jobs {
				results <- result{w, utf8.RuneCountInString(w)}
			}
		}()
	}

	go func() {
		for _, w := range words {
			jobs <- w
		}
		close(jobs)
	}()
	go func() {
		wg.Wait()
		close(results)
	}()

	var all []result
	for r := range results {
		all = append(all, r)
	}
	sort.Slice(all, func(i, j int) bool { return all[i].word < all[j].word })
	for _, r := range all {
		fmt.Printf("%-10s %d\n", r.word, r.n)
	}
}

The Rust program does the same job with the standard library. It is a complete file.

rust
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;

fn word_len(word: &str) -> usize {
    word.chars().count()
}

fn main() {
    let words = ["gopher", "ferris", "goroutine", "borrow", "channel", "trait", "interface", "lifetime"];

    let (job_tx, job_rx) = mpsc::channel::<&'static str>();
    let (res_tx, res_rx) = mpsc::channel::<(&'static str, usize)>();
    let job_rx = Arc::new(Mutex::new(job_rx));

    let mut handles = Vec::new();
    for _ in 0..3 {
        let job_rx = Arc::clone(&job_rx);
        let res_tx = res_tx.clone();
        handles.push(thread::spawn(move || {
            loop {
                let job = job_rx.lock().unwrap().recv();
                match job {
                    Ok(w) => res_tx.send((w, word_len(w))).unwrap(),
                    Err(_) => break,
                }
            }
        }));
    }
    drop(res_tx);

    for w in words {
        job_tx.send(w).unwrap();
    }
    drop(job_tx);

    let mut results: Vec<_> = res_rx.iter().collect();
    results.sort();
    for h in handles {
        h.join().unwrap();
    }
    for (w, n) in results {
        println!("{w:<10} {n}");
    }
}
Text
borrow     6
channel    7
ferris     6
gopher     6
goroutine  9
interface  9
lifetime   8
trait      5

Both print identical output. The structure maps closely: mpsc::channel is make(chan), thread::spawn is go func(), and dropping the sender is close, which is what ends the worker loop when recv returns an error. A few differences carry real meaning. First, the standard channel is multi-producer, single-consumer, so three workers cannot all receive from one channel directly. The program wraps the receiver in Arc<Mutex<...>> so that only one worker waits at a time. That works, and it is the pattern the book uses, but if you want a true multi-consumer channel you pick a crate such as crossbeam-channel, or you use a Tokio channel in async code (channels compared). Second, the original sender res_tx is dropped on purpose before collecting: res_rx.iter() only ends when every sender is gone, and a forgotten handle would hang the program forever, the Rust twin of forgetting to close a Go channel. Third, there is no WaitGroup: join on each handle does the job and also hands back a panic if the thread died.

The data race you cannot write

Go has a race detector you run with -race and hope your tests exercise. Rust makes a data race a compile error, through two marker traits: Send (this value may move to another thread) and Sync (references to this value may be shared between threads). The compiler derives them automatically, and thread::spawn demands that its closure be Send. A Go programmer's instinct for sharing a counter does not survive this.

rust
use std::rc::Rc;
use std::thread;

fn main() {
    let counter = Rc::new(5);
    let c = Rc::clone(&counter);
    let h = thread::spawn(move || {
        println!("{c}");
    });
    h.join().unwrap();
}
Text
error[E0277]: `Rc<i32>` cannot be sent between threads safely
 --> src/main.rs:7:27
  |
7 |       let h = thread::spawn(move || {
  |               ------------- ^------
  |               |             |
  |  _____________|_____________within this `{closure@src/main.rs:7:27: 7:34}`
  | |             |
  | |             required by a bound introduced by this call
8 | |         println!("{c}");
9 | |     });
  | |_____^ `Rc<i32>` cannot be sent between threads safely
  |
  = help: within `{closure@src/main.rs:7:27: 7:34}`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
 --> src/main.rs:7:27
  |
7 |     let h = thread::spawn(move || {
  |                           ^^^^^^^
note: required by a bound in `spawn`
 --> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/std/src/thread/functions.rs:125:0

For more information about this error, try `rustc --explain E0277`.

Rc is a reference-counted pointer whose count is not atomic, so two threads touching it would corrupt the count. It is therefore not Send, and the message says exactly that: Rc<i32> cannot be sent between threads safely. The fix is to use its atomic sibling, Arc, and, because the data will be mutated, guard it with a Mutex.

rust
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let hits = Arc::new(Mutex::new(0u32));
    let mut handles = Vec::new();
    for _ in 0..4 {
        let hits = Arc::clone(&hits);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                *hits.lock().unwrap() += 1;
            }
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    println!("hits = {}", *hits.lock().unwrap());
}
Text
hits = 4000

Four threads each increment the counter a thousand times and the total is always 4000. The type Arc<Mutex<u32>> also differs from Go's sync.Mutex in a way that matters: the mutex owns the data it protects, so the only way to reach the integer is through lock(). In Go the mutex and the field sit side by side and nothing stops you from touching the field without locking. The lock is released when the guard goes out of scope, which is another use of Drop. Send and Sync explained and the Arc and Mutex pattern go deeper.

The second trap is the closure that borrows. A goroutine can freely capture a local variable by reference, and the garbage collector keeps it alive. A Rust thread may outlive the function that spawned it, so a borrow of a local is rejected.

rust
use std::thread;

fn main() {
    let names = vec![String::from("a"), String::from("b")];
    let h = thread::spawn(|| {
        println!("{}", names.len());
    });
    h.join().unwrap();
}
Text
error[E0373]: closure may outlive the current function, but it borrows `names`, which is owned by the current function
 --> src/main.rs:5:27
  |
5 |     let h = thread::spawn(|| {
  |                           ^^ may outlive borrowed value `names`
6 |         println!("{}", names.len());
  |                        ----- `names` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:5:13
  |
5 |       let h = thread::spawn(|| {
  |  _____________^
6 | |         println!("{}", names.len());
7 | |     });
  | |______^
help: to force the closure to take ownership of `names` (and any other referenced variables), use the `move` keyword
  |
5 |     let h = thread::spawn(move || {
  |                           ++++

For more information about this error, try `rustc --explain E0373`.

The compiler proposes move, which transfers names into the closure. That works when the thread should own the data. When you want the thread to borrow and finish before the function returns, which is what the Go code was really doing, use a scoped thread.

rust
use std::thread;

fn main() {
    let names = vec![String::from("a"), String::from("b")];
    let mut total = 0;
    thread::scope(|s| {
        s.spawn(|| println!("reader sees {} names", names.len()));
        s.spawn(|| total += names.iter().map(|n| n.len()).sum::<usize>());
    });
    println!("total bytes = {total}, names still ours: {names:?}");
}
Text
reader sees 2 names
total bytes = 2, names still ours: ["a", "b"]

thread::scope guarantees that every spawned thread is joined before the scope returns, so borrowing local data is sound. One thread only reads names, the other adds up the byte lengths into total, and afterwards main still owns both. The reader and the writer touch different variables; if both threads had tried to mutate total, the compiler would have refused, because that is the shared mutation the model forbids.

Where goroutines have no direct equivalent

A goroutine is a runtime feature: the Go scheduler multiplexes many cheap tasks over a few OS threads and parks them on channel operations and I/O. Rust has no scheduler in the language. std::thread gives you real operating system threads, which are heavier, and for services that hold tens of thousands of connections you move to async with a runtime such as Tokio, where tokio::spawn is the nearest thing to go. The catch is that async Rust makes you choose a runtime, colours functions with async, and asks for Send futures. The Tokio runtime explained covers what the runtime does for you, and Rust versus Go for backends compares the two stacks as a decision. Go also has select over channels, which Tokio matches with tokio::select!, but with cancellation semantics you must understand before using it.

What you get for the extra work, measured

The honest question after all those compiler messages is what the speed difference is. To test it I wrote the same parallel program in both languages: eight workers each take every eighth starting number below twenty million and compute the Collatz chain length for it, then the main thread picks the longest. It is integer arithmetic with no allocation, which is the best case for both compilers and the worst case for finding a garbage-collector penalty. Both programs print 15733191 705. Three runs each on ten cores:

RunGo 1.25.6Rust 1.99 (release)
1838 ms550 ms
2764 ms535 ms
3793 ms584 ms

Rust finishes in about 70 percent of Go's time, roughly 1.4 times faster, on a workload where neither language's runtime has anything to do. That is a modest gap, and it is the right size to expect for compute-bound code: the Go compiler is good, and the win is mostly from LLVM's heavier optimisation. The binaries also differ. The Go executable is 2.4 MB and the Rust one is 481 KB, both unstripped with default settings, because Go links its runtime and scheduler into every program.

What this benchmark does not show matters as much. It allocates nothing, so it says nothing about garbage-collection pauses, where Rust's advantage is in tail latency rather than throughput. And I did not compare build times: the first Go build I timed took 18 seconds because it was populating the standard library cache, which is not a fair figure, I did not measure how Rust's compile time scales on a large project. Measure your own service before concluding anything from either direction.

When Go is the better answer

Staying in Go is the right call more often than the enthusiasm around Rust admits.

  • Network services built from standard parts. A typical HTTP or gRPC service that spends its time waiting on a database is I/O bound. The database dominates the request time whichever language sits in front of it, and Go builds in seconds and is easier to hire for.
  • Teams that need to onboard fast. Go has roughly twenty-five keywords and one way to do most things. Rust takes weeks before a new hire stops fighting the compiler, and the review burden for unsafe or lifetime-heavy code is real.
  • Tooling and glue. Go's cross-compilation is a single environment variable, and its static binaries make it a natural fit for small command-line tools. Rust does this too, but with more setup.
  • Code where a pause is acceptable. If a collection pause of a millisecond or so does not matter, you are paying Rust's learning cost to remove a cost you do not have.

Rust earns its keep where memory, latency or correctness are the product: code that cannot pause, code that parses untrusted input, code that shares state across many threads, and components you want to embed in other languages. A pragmatic split is to keep the service in Go and write the one hot or risky component in Rust. If you do build a service in Rust, the axum production service guide is the continuation.

A translation order that works

Port a small tool you have already written in Go, such as a log filter or a concurrent URL checker, rather than starting something new. Begin with the structs, methods and error enum, which feel familiar. Use &str and &[T] in parameters and String and Vec<T> in returns. Add threads last, with Arc and a channel, and read each compiler message to the end before changing anything. The messages above are typical: they name the rule, point at the line, and usually offer the fix.

Taggedrustgo
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.