Skip to content
Select theme

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.

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 1

Both 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.

main.go
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 8080
error: read config: open port.txt: no such file or directory
cause: open port.txt: no such file or directory
fix: create port.txt
error: parse "8o80": strconv.ParseUint: parsing "8o80": invalid syntax
cause: strconv.ParseUint: parsing "8o80": invalid syntax
error: port 443 is privileged, need 1024..=65535

Nothing 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.

That second return value is a convention, not a check. Delete the check and the compiler has nothing to say:

main.go
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 0

Exit 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.

Same four runs — the program reads port.txt from the working directory, in each of the four states shown at the start:

listening on 8080
error: read config: No such file or directory (os error 2)
cause: No such file or directory (os error 2)
fix: create port.txt
error: parse "8o80": invalid digit found in string
cause: invalid digit found in string
error: port 443 is privileged, need 1024..=65535

The 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.

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:

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.

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:

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 default
help: 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:

the silent version
// load_port unchanged from the listing above
let 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.

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:

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.

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 != nil appears 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 strconv embeds 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.

Rust:

Terminal window
cargo new config && cd config
# paste src/main.rs from the Rust section above
cargo run # echo/rm port.txt first to hit the other cases

Go — no module setup needed, the program is standard library only:

Terminal window
go run main.go

The 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.