Error handling in both languages
The first two articles in this track measured what happens when everything works: parsing, then
waiting. This one is about the other half of every backend — the paths taken when a call fails —
because that is where the two languages’ philosophies are most visible in everyday code. In Go,
every fallible function returns its error beside the value, and every call site says if err != nil out loud. In Rust, the value and the failure travel inside one type, Result, and a single
character — ? — hands an error to the caller. The first style puts the ceremony in the code and
trusts discipline; the second puts it in the type system and lets the compiler do part of the
checking.
The claim is easy to make and hard to feel, so this article does what the track always does: the same program written twice, run four ways each, with every line of output below taken from those runs.
The task
Section titled “The task”A config loader, the kind every service has: read a port number from port.txt, trim the
whitespace, parse it, and refuse privileged ports. Three things can go wrong — the file is
missing, the text is not a number, or the number is below 1024 — and the program handles each one
the same way in both languages: print the error, print its cause if there is one, and exit 1. On
success it prints the port and exits 0.
$ echo 8080 > port.txt && ./config # listening on 8080, exit 0$ rm port.txt && ./config # the missing-file error, exit 1$ echo 8o80 > port.txt && ./config # the unparsable-text error, exit 1$ echo 443 > port.txt && ./config # the privileged-port error, exit 1Both versions use only the standard library. Everything below ran on an Apple M2 Max with rustc 1.99.0 (edition 2024) and go1.27.3 darwin/arm64, one run per case, shown as it came out.
The Go version
Section titled “The Go version”package main
import ( "errors" "fmt" "os" "strconv" "strings")
func loadPort(path string) (uint16, error) { data, err := os.ReadFile(path) if err != nil { return 0, fmt.Errorf("read config: %w", err) } text := strings.TrimSpace(string(data)) port, err := strconv.ParseUint(text, 10, 16) if err != nil { return 0, fmt.Errorf("parse %q: %w", text, err) } if port < 1024 { return 0, fmt.Errorf("port %d is privileged, need 1024..=65535", port) } return uint16(port), nil}
func main() { port, err := loadPort("port.txt") if err != nil { fmt.Printf("error: %v\n", err) if cause := errors.Unwrap(err); cause != nil { fmt.Printf("cause: %v\n", cause) } if errors.Is(err, os.ErrNotExist) { fmt.Println("fix: create port.txt") } os.Exit(1) } fmt.Printf("listening on %d\n", port)}The four runs:
listening on 8080error: read config: open port.txt: no such file or directorycause: open port.txt: no such file or directoryfix: create port.txterror: parse "8o80": strconv.ParseUint: parsing "8o80": invalid syntaxcause: strconv.ParseUint: parsing "8o80": invalid syntaxerror: port 443 is privileged, need 1024..=65535Nothing here is surprising, and that is the point. loadPort returns two things — the port and
the error — and Go’s convention sorts out which one is real: a non-nil error means the value is
garbage, so every caller checks err before touching port. Context is added with
fmt.Errorf’s %w verb, which builds a new error that keeps the old one inside it;
errors.Unwrap peels one layer off, and errors.Is walks the whole chain looking for
os.ErrNotExist. The cause line for the privileged port never prints because that error was
built without %w — nothing underneath it — and the program says so honestly by printing nothing.
Read the messages closely: read config: open port.txt: no such file or directory is two errors
glued into one string, the context first. In Go the error is the context. strconv even
contributes its own prefix — strconv.ParseUint: parsing "8o80": invalid syntax names the
function, the input, and the reason, three facts the caller did not have to assemble.
The mistake Go lets you make
Section titled “The mistake Go lets you make”That second return value is a convention, not a check. Delete the check and the compiler has nothing to say:
func main() { port, _ := loadPort("port.txt") fmt.Printf("listening on %d\n", port)}The loadPort function is unchanged from the listing above; only main is different. Missing
file, garbage text — either way:
listening on 0Exit code 0. No warning from the compiler, nothing from go vet, which catches printf misuse and
unreachable code but not unchecked errors. The _ told Go the error was received on purpose, and
the program carried on with a port of zero — which, in some socket APIs, is not even an error
value but an instruction to pick a random port. The errors.Is branch that would have printed
fix: create port.txt is gone because the check that contained it is gone; the failure mode is
not a crash, it is a silent, confident wrong answer. Third-party linters like errcheck exist
precisely for this, but they are opt-in, and nothing in the toolchain that ships with Go runs them.
The Rust version
Section titled “The Rust version”use std::fs;use std::num::ParseIntError;
#[derive(Debug)]enum ConfigError { Read(std::io::Error), Parse { text: String, source: ParseIntError }, Privileged(u32),}
impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ConfigError::Read(e) => write!(f, "read config: {e}"), ConfigError::Parse { text, source } => write!(f, "parse {text:?}: {source}"), ConfigError::Privileged(raw) => { write!(f, "port {raw} is privileged, need 1024..=65535") } } }}
impl std::error::Error for ConfigError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { ConfigError::Read(e) => Some(e), ConfigError::Parse { source, .. } => Some(source), ConfigError::Privileged(_) => None, } }}
impl From<std::io::Error> for ConfigError { fn from(e: std::io::Error) -> Self { ConfigError::Read(e) }}
fn load_port(path: &str) -> Result<u16, ConfigError> { let text = fs::read_to_string(path)?; let text = text.trim(); let raw: u32 = text.parse().map_err(|source| ConfigError::Parse { text: text.to_string(), source, })?; if !(1024..=65535).contains(&raw) { return Err(ConfigError::Privileged(raw)); } Ok(raw as u16)}
fn main() { match load_port("port.txt") { Ok(port) => println!("listening on {port}"), Err(e) => { println!("error: {e}"); let mut source = std::error::Error::source(&e); while let Some(s) = source { println!("cause: {s}"); source = s.source(); } if let ConfigError::Read(io) = &e { if io.kind() == std::io::ErrorKind::NotFound { println!("fix: create port.txt"); } } std::process::exit(1); } }}Same four runs — the program reads port.txt from the working directory, in each of the four
states shown at the start:
listening on 8080error: read config: No such file or directory (os error 2)cause: No such file or directory (os error 2)fix: create port.txterror: parse "8o80": invalid digit found in stringcause: invalid digit found in stringerror: port 443 is privileged, need 1024..=65535The shape is different at every level. load_port returns one type, Result<u16, ConfigError>,
which is either Ok(port) or Err(what went wrong) — the two-return-value convention has become
one type the compiler understands. Inside the function, ? does the propagation: on Err it
returns immediately, on Ok it unwraps the value and moves on, so the missing-file case costs one
character instead of three lines. The parse step uses map_err instead of ? because it wants to
attach data — the offending text becomes a typed field on the variant, not a prefix glued to a
string. And ConfigError is an enum, so main’s match has to cover every variant; the
privileged-port case needed no separate message convention because it is a value,
Privileged(raw), carrying the rejected port as a u32.
The plumbing for the cause chain is the Error trait implementation: source() is Rust’s
errors.Unwrap, and the loop in main walks it the same way. ErrorKind::NotFound is the typed
equivalent of errors.Is(err, os.ErrNotExist) — matching on a variant of an enum rather than
searching a chain for a sentinel.
What ? adds on top of match
Section titled “What ? adds on top of match”The first ? in load_port converts an io::Error into a ConfigError without any visible
code. That conversion is the From implementation, and ? is defined to use it:
#[derive(Debug)]enum ConfigError { Read(std::io::Error),}
impl From<std::io::Error> for ConfigError { fn from(e: std::io::Error) -> Self { ConfigError::Read(e) }}
fn read_line() -> Result<String, std::io::Error> { Ok("8080".to_string())}
fn load_port() -> Result<u16, ConfigError> { let text = read_line()?; // the ? is a match plus From::from Ok(text.parse().unwrap())}
fn main() { assert_eq!(load_port().unwrap(), 8080);}Written out, expr? means: match on the Result, return the Err (through From::from, so it
fits the function’s own error type) or hand back the Ok value. This is why the main listing
needed impl From<std::io::Error> for ConfigError before the first ? would compile, and why
libraries whose error types implement From for the errors of their dependencies compose for
free. In Go the same propagation is three lines per call site — if err != nil { return 0, err }
— which is repetitive but visible, and wraps nothing automatically.
The mistake Rust lets you make
Section titled “The mistake Rust lets you make”Rust’s compiler knows load_port returns a Result, knows a Result might be an Err, and
says something when the value is dropped unexamined:
fn load_port(path: &str) -> Result<u16, String> { let _ = path; Ok(8080)}
fn main() { load_port("port.txt");}The build still succeeds — this is a warning, not an error — and the warning is the point:
warning: unused `Result` that must be used --> src/main.rs:7:5 |7 | load_port("port.txt"); | ^^^^^^^^^^^^^^^^^^^^^ | = note: this `Result` may be an `Err` variant, which should be handled = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by defaulthelp: use `let _ = ...` to ignore the resulting value |7 | let _ = load_port("port.txt"); | +++++++So the default path is better than Go’s: doing nothing produces a message in every build, with a
suggestion that names the escape hatch. But it is help, not enforcement — the escape hatch works.
let _ = load_port("port.txt") silences the warning, and so does a call that looks like
handling:
// load_port unchanged from the listing abovelet port = load_port("port.txt").unwrap_or(0);println!("listening on {port}");This compiles with no warnings, and with port.txt missing it prints listening on 0 and exits 0
— observed on the full listing, and the same confident wrong answer as the Go version. Rust does
not make the mistake impossible; it makes the naive version visible and the deliberate versions
look deliberate. unwrap_or(0) is hard to type by accident next to port, _ :=, and that is the
honest size of the difference.
Where the compiler actually bites
Section titled “Where the compiler actually bites”Warnings are the soft end of the type system. The hard end shows up the moment ConfigError
grows a new variant — say PortOutOfRange — and some match somewhere forgets it:
#[derive(Debug)]enum ConfigError { Read(std::io::Error), Parse { text: String, source: std::num::ParseIntError }, Privileged(u32),}
fn report(err: ConfigError) { match err { ConfigError::Read(e) => println!("io error: {e}"), ConfigError::Parse { source, .. } => println!("parse error: {source}"), }}
fn main() { let _ = report;}rustc reports:
error[E0004]: non-exhaustive patterns: `ConfigError::Privileged(_)` not covered --> src/main.rs:9:11 |9 | match err { | ^^^ pattern `ConfigError::Privileged(_)` not covered |note: `ConfigError` defined here --> src/main.rs:2:6 |2 | enum ConfigError { | ^^^^^^^^^^^...5 | Privileged(u32), | ---------- not covered = note: the matched value is of type `ConfigError`help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown |11 ~ ConfigError::Parse { source, .. } => println!("parse error: {source}"),12 ~ ConfigError::Privileged(_) => todo!(), |
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0004`.That is not a lint to opt into — the program does not compile, in any configuration, without a
wildcard arm. Every caller that matches on the enum has to acknowledge the new case, which turns
“add an error kind” from a documentation exercise into a compile error at every call site that
needs updating. Go has no analogous check: add an error type, and every caller keeps compiling;
whether a switch err.(type) somewhere handles it is found at run time, by tests or in
production. The honest footnote is that Rust’s guarantee has an off switch too — a _ => {} arm
silences it, exactly the way _ := silences Go’s error — but the default in Rust is a compile
error, and in Go there is no default at all.
Reading the two styles
Section titled “Reading the two styles”Count the code: the Go listing is 40 lines, the Rust one is 70, standard library only, doing the
same work. Roughly half of Rust’s extra length is the plumbing around the error type — Display,
Error, source(), From — before any error is produced. In real projects that plumbing is
boilerplate, which is why the crates thiserror (derives the impls) and anyhow (erases the
concrete type until the top of the program) are near-universal in Rust; the stdlib version here is
what they generate, which is why it is worth having seen once. Go needs no such layer because the
ritual already is the language, and no such layer exists to write.
What each style buys:
- Go makes every error path visible and greppable.
if err != nilappears at every call site, so reading the code is reading the error handling. The cost is the repetition, and the fact that nothing checks the check. - Rust compresses the happy path and checks the sad one.
?reads fast, and the enum + exhaustive-match pair turns forgotten cases into compile errors. The cost is upfront structure: define the error type, implement the traits, then write the program. - Go’s error is a message; Rust’s can carry data. The Go version reports the bad text because
strconvembeds it in its message; the Rust version reports it because it is a field. Data beats strings when a caller needs to do something with an error instead of logging it. - Neither language has exceptions on the normal path. Both have
panic, both ecosystems reserve it for bugs rather than expected failures, and both languages make you acknowledge failure at every fallible call — they differ on who does the acknowledging: you, or the compiler.
The performance question — does all this checking cost anything when no error happens? — is not measured here because there is nothing to measure: in both languages the happy path is a return value in a register, and the error-handling machinery below it is branches that are never taken. The first article in this track measured where the real costs are, and error handling was not on the list.
Run it yourself
Section titled “Run it yourself”Rust:
cargo new config && cd config# paste src/main.rs from the Rust section abovecargo run # echo/rm port.txt first to hit the other casesGo — no module setup needed, the program is standard library only:
go run main.goThe verify="fail" block above is the only one the compiler rejects on purpose; the two short
fragments (the Go mistake, the silent Rust version) elide load_port for space. Everything else
prints what this page shows on rustc 1.99.0 / go1.27.3.
With errors handled, the track moves to what the two toolchains hand you at the end: binary sizes, static linking, cross-compilation, and deploy images — where Go’s reputation says it wins and the numbers say something more specific. The overview on the track page lists everything that is coming.