Why I learned Rust in a weekend

I recently found myself around a team whose internal tooling was mostly Rust. Not the kernel-adjacent kind, the boring kind: a CLI that talks to a cloud API, a small daemon that watches things and posts to Slack, a handful of exporters. Nobody needed me to write a compiler. What I needed was to open one of those repos, read it without a guide, and fix it when it broke at 2am. That is a much smaller target than “learn Rust”, and it is also the target that matters for most infra people now, because a growing share of the Rust you meet was generated by an AI and merged by someone who read it about as carefully as you are about to.

So I set a narrow goal: read a Rust file and know what every line does, understand the compiler when it refuses to build, and spot the two or three patterns that turn into production incidents. I skipped most of the language on purpose. No traits beyond derive, no generics, no async internals, no unsafe, no macros. What is left fits in a weekend and in this post.

Toolchain in five commands

cargo new svc-status creates a project with a Cargo.toml and a src/main.rs. cargo check type-checks the code without producing a binary, and it is the fast inner loop; you will run it fifty times an hour. cargo run builds and runs. cargo clippy is the linter, and it catches most of the things a reviewer would otherwise catch. cargo add reqwest adds a dependency to Cargo.toml for you.

The one toolchain thing that cost me real time: crates hide functionality behind feature flags. If the compiler tells you a method does not exist on a type that clearly should have it, the cause is usually a missing feature in Cargo.toml, not a typo. reqwest will happily give you a response with no .json() method on it until you ask for the json feature.

[dependencies]
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Same story with serde: #[derive(Deserialize)] does nothing until the derive feature is on. AI-generated code gets this wrong constantly because the model writes the Rust and forgets the manifest.

Syntax that surprised me

Variables are immutable by default. let retries = 3; cannot be reassigned. let mut retries = 3; can. This is backwards from every language I use day to day, and the practical effect is that when you see mut in a signature or a binding, it is a signal that something in the next few lines changes state. That signal is worth having.

The bigger surprise is that the last expression in a block, written without a semicolon, is the block’s value. Functions return that way; most Rust code does not use the return keyword except for early exits.

fn default_retries() -> u32 {
    3
}

Add a semicolon and it stops compiling, because 3; is a statement that evaluates to (), the empty tuple, and the function now returns nothing.

error[E0308]: mismatched types
 --> src/main.rs:1:25
  |
1 | fn default_retries() -> u32 {
  |    ---------------      ^^^ expected `u32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
2 |     3;
  |      - help: remove this semicolon to return this value

You will see this error in code where someone refactored the last line and left the semicolon in place. The compiler tells you exactly what to do.

Since blocks are expressions, if is too. There is no ternary operator because you do not need one.

use std::env;

fn main() {
    let env_name = env::var("APP_ENV").unwrap_or_default();

    let port: u16 = if env_name == "production" { 443 } else { 8080 };

    println!("listening on {}", port);
}

Both branches must produce the same type, and there must be an else, otherwise the compiler cannot know what port is when the condition is false.

Shadowing is idiomatic, not a smell. Rebinding the same name with a new type is the normal way to parse a value through stages, and it saves you from port_str, port_parsed, port_final.

use std::env;

fn main() {
    let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
    let port: u16 = port.parse().unwrap_or(8080);

    println!("port is {}", port);
}

The first port is a String. The second is a u16. After line two the string is gone and the name means the number.

Loops use ranges. 0..retries is zero up to but not including retries, and 0..=retries includes it. There is no C-style for (i = 0; ...) and I did not miss it once.

fn main() {
    let retries = 3;

    for attempt in 0..retries {
        println!("attempt {} of {}", attempt + 1, retries);
    }

    let regions = vec!["eu-west-1", "us-east-1"];
    for region in &regions {
        println!("checking {}", region);
    }
}

Note the &regions in the second loop. Iterating over regions directly would consume the vector; iterating over &regions borrows it, and you can use it again afterwards. That distinction gets its own section below.

Last one: printing. {} uses a type’s Display implementation, which is what strings and numbers have and is meant for humans. {:?} uses Debug, which is meant for developers and is what you get for free on your own types by writing #[derive(Debug)] above them. {:#?} is the same, pretty-printed across lines. Rust 2021 also lets you write the variable name inside the braces.

#[derive(Debug)]
struct Service {
    name: String,
    region: String,
    replicas: u32,
}

fn main() {
    let svc = Service {
        name: "checkout-api".to_string(),
        region: "eu-west-1".to_string(),
        replicas: 3,
    };

    println!("{}", svc.name);
    println!("{:?}", svc);
    println!("{:#?}", svc);
    println!("{name} runs {n} replicas in {region}", name = svc.name, n = svc.replicas, region = svc.region);
}

Try println!("{}", svc) and you get error[E0277]: Service doesn't implement std::fmt::Display. Forget the derive and {:?} fails with the same error naming Debug. Either way, the fix is either add the derive or print a field instead of the whole struct.

Two string types

This is the thing that stops most people in the first hour. There are two string types and you will meet both in the first file you open.

String owns its bytes. It lives on the heap, it can grow, and when it goes out of scope the memory is freed. &str is a borrowed view: a pointer and a length into bytes that somebody else owns. A string literal like "eu-west-1" is a &str pointing into the binary itself. A slice of a String is a &str pointing into that String.

The split exists because there is no garbage collector. Something has to be responsible for freeing the bytes, and that something is whoever holds the String. Everyone else gets a &str and is not allowed to outlive the owner. Go hides this by making every string a header pointing at GC-managed bytes. Rust makes you say which one you have.

Four rules cover almost every case in application code:

  1. Take &str in function parameters. Callers can pass either a literal or a &String, and the compiler converts the second one for you.
  2. Use String in struct fields and return values. The struct or the caller ends up owning the data, so it needs the owning type.
  3. .to_string() converts up, from &str to String. You will also see String::from("...") and .to_owned(); they do the same thing.
  4. &s converts down, from String to &str.
use std::env;

struct Config {
    region: String,
    endpoint: String,
}

fn region_endpoint(region: &str) -> String {
    format!("https://ec2.{}.amazonaws.com", region)
}

fn load_config() -> Config {
    let region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
    let endpoint = region_endpoint(&region);

    Config { region, endpoint }
}

fn main() {
    let cfg = load_config();
    println!("{} -> {}", cfg.region, cfg.endpoint);

    let fallback = region_endpoint("eu-west-1");
    println!("{}", fallback);
}

region_endpoint takes &str because it only needs to read the region. It returns String because it builds a new value and somebody has to own it. Calling it with &region works because the compiler coerces &String into &str. Calling it with a literal works because a literal already is one. And Config { region, endpoint } is shorthand for Config { region: region, endpoint: endpoint }, which you will see everywhere.

When you get expected &str, found String, the fix is almost always to put an & in front of the argument. When you get the reverse, expected String, found &str, add .to_string(). That covers the majority of string errors in code I have reviewed.

There is no nil

Rust has no null. A value that might be absent has the type Option<T>, which is either Some(value) or None, and the compiler will not let you use the inner value without saying what happens in the None case.

In Go, a missing env var is os.Getenv returning "", or os.LookupEnv returning (value, ok). Nothing forces you to check ok. In Rust the missing case is part of the type, so ignoring it is a compile error rather than a runtime surprise.

Except for one escape hatch, and it is the thing to hunt for in AI-generated code. .unwrap() says “I am sure this is Some, give me the value, and crash the whole process if I am wrong”. Here is what that looks like in a daemon:

use std::env;

fn main() {
    let region = env::var("AWS_REGION").unwrap();

    println!("watching {}", region);
}

Deploy that to a node where AWS_REGION is not set and it dies on startup:

thread 'main' panicked at src/main.rs:4:41:
called `Result::unwrap()` on an `Err` value: NotPresent

Strictly, env::var returns a Result<String, VarError> rather than an Option, because there are two ways it can fail (unset, or set to bytes that are not valid Unicode). Same shape, same .unwrap(), same panic. .expect("AWS_REGION must be set") is identical except the panic message is yours. Neither belongs in a long-running process on a code path that can legitimately be missing.

What to write instead depends on what the missing value means. If there is a sensible default, use it. If there is a fallback source, try it. If it is genuinely fatal, fail on purpose with a real message rather than a stack trace.

use std::env;

fn resolve_region() -> String {
    env::var("AWS_REGION")
        .or_else(|_| env::var("AWS_DEFAULT_REGION"))
        .unwrap_or_else(|_| "us-east-1".to_string())
}

fn main() {
    let region = resolve_region();
    println!("watching {}", region);
}

.ok() turns a Result into an Option by throwing away the error, which is often what you want for env vars. From there the tools are match, if let, unwrap_or for a default value, and unwrap_or_else for a default that is computed by a closure.

use std::env;

fn main() {
    let region: Option<String> = env::var("AWS_REGION").ok();

    match &region {
        Some(r) => println!("using region {}", r),
        None => println!("AWS_REGION not set"),
    }

    if let Some(r) = &region {
        println!("region has {} chars", r.len());
    }

    let region = region.unwrap_or("us-east-1".to_string());
    println!("resolved to {}", region);
}

match must handle every case, so you cannot forget None. if let is for when you only care about one case. The &region in both means we are looking at the option without consuming it, so it is still usable on the next line.

Result<T, E> is the same idea for operations that can fail with a reason, and ? is how you propagate the failure upward without a match on every line. This is what you will see in every API client:

use std::fs;

fn read_port(path: &str) -> Result<u16, Box<dyn std::error::Error>> {
    let raw = fs::read_to_string(path)?;
    let port: u16 = raw.trim().parse()?;
    Ok(port)
}

fn main() {
    match read_port("/etc/svc/port") {
        Ok(port) => println!("port {}", port),
        Err(e) => eprintln!("could not read port: {}", e),
    }
}

Each ? means “if this failed, return the error from this function right now”. Box<dyn std::error::Error> is the catch-all error type you will see in CLIs; the anyhow crate is the same idea with nicer ergonomics. When you read a function full of ?, the mental model is Go’s if err != nil { return err } collapsed into one character.

Structs and enums that carry data

A struct is a struct. The one thing to know is that there are no zero values: every field must be set at construction, and leaving one out is a compile error (error[E0063]: missing field replicas in initializer of Service). In Go a forgotten field is silently 0 or "". In Rust it is a build failure, which is the right outcome for a config struct.

Methods live in a separate impl block. &self reads, &mut self changes, and a function in the block with no self at all is an associated function, which is how constructors are written.

#[derive(Debug, Clone)]
struct Service {
    name: String,
    region: String,
    replicas: u32,
}

impl Service {
    fn new(name: &str, region: &str) -> Self {
        Self {
            name: name.to_string(),
            region: region.to_string(),
            replicas: 1,
        }
    }

    fn endpoint(&self) -> String {
        format!("{}.{}.svc.internal", self.name, self.region)
    }

    fn scale_to(&mut self, replicas: u32) {
        self.replicas = replicas;
    }
}

fn main() {
    let mut svc = Service::new("checkout-api", "eu-west-1");
    svc.scale_to(3);

    println!("{} at {} replicas", svc.endpoint(), svc.replicas);
}

#[derive(Debug, Clone)] gives the struct a Debug printer and a .clone() method. You will see that line on nearly every struct in application code, and it is fine. Self inside the block is shorthand for the type being implemented. Note that svc had to be declared mut because scale_to takes &mut self; if it were not, the compiler would refuse with E0596.

Enums are where Rust earns its keep for infra code, and this is the part I would have wanted someone to show me first. Variants can carry data, and different variants can carry different data.

#[derive(Debug, Clone)]
enum ServiceStatus {
    Provisioning,
    Running { since: u64 },
    Failed(String),
    Migrating { from: String, to: String },
}

Think about how you would model that in Go. A Status string field, plus Since int64, plus FailureReason string, plus MigrateFrom and MigrateTo, with a comment explaining which ones are meaningful for which status. Nothing stops a Running service from having a FailureReason. Nothing stops a Migrating service from having an empty MigrateTo. Every consumer has to know the rules and every consumer gets them slightly wrong.

With the enum, Failed cannot exist without its reason and Running cannot carry one. The invalid states do not have a representation. And match forces you to handle every variant:

impl ServiceStatus {
    fn is_healthy(&self) -> bool {
        matches!(self, ServiceStatus::Running { .. })
    }

    fn describe(&self) -> String {
        match self {
            ServiceStatus::Provisioning => "provisioning".to_string(),
            ServiceStatus::Running { since } => format!("running since {}", since),
            ServiceStatus::Failed(reason) => format!("failed: {}", reason),
            ServiceStatus::Migrating { from, to } => format!("migrating {} to {}", from, to),
        }
    }
}

fn main() {
    let statuses = vec![
        ServiceStatus::Running { since: 1_757_200_000 },
        ServiceStatus::Failed("OOMKilled".to_string()),
        ServiceStatus::Migrating { from: "eu-west-1".to_string(), to: "eu-central-1".to_string() },
    ];

    for s in &statuses {
        println!("{} (healthy: {})", s.describe(), s.is_healthy());
    }
}

matches! is a macro that returns true if the value fits the pattern, and { .. } means “whatever the fields are”. It is the idiomatic way to write a one-variant check.

Now add a variant. Say the platform team introduces a drain phase and you add Draining to the enum. describe stops compiling:

error[E0004]: non-exhaustive patterns: `&ServiceStatus::Draining` not covered
  --> src/main.rs:16:15
   |
16 |         match self {
   |               ^^^^ pattern `&ServiceStatus::Draining` not covered
   |
   = note: the matched value is of type `&ServiceStatus`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown

Every match over that enum, in every file, fails the build until it says what to do with Draining. That is the feature. The Go version compiles and silently falls through a switch somewhere. The one caveat is that a _ => ... wildcard arm opts out of this check, so when you see one in a match over your own enum, ask whether it is deliberate.

Option and Result are just enums like this one, defined in the standard library. Some(T) and None. Ok(T) and Err(E). Everything above about match and if let is the same mechanism.

Ownership and borrowing in one page

Every value has exactly one owner. When the owner goes out of scope, the value is freed. That is the entire memory model, and everything else is consequences.

The first consequence: assigning a heap value to a new variable, or passing it to a function, moves it. The original name stops being valid. Small stack values (integers, booleans, &str, anything that is just a few bytes) are copied instead, so this only bites you with String, Vec, and your own structs.

#[derive(Debug, Clone)]
struct Service {
    name: String,
    region: String,
    replicas: u32,
}

fn print_summary(services: Vec<Service>) {
    for s in &services {
        println!("{} in {} ({} replicas)", s.name, s.region, s.replicas);
    }
}

fn main() {
    let services = vec![
        Service { name: "checkout-api".to_string(), region: "eu-west-1".to_string(), replicas: 3 },
        Service { name: "search".to_string(), region: "eu-west-1".to_string(), replicas: 2 },
    ];

    print_summary(services);

    println!("{} services total", services.len());
}
error[E0382]: borrow of moved value: `services`
  --> src/main.rs:22:36
   |
15 |     let services = vec![
   |         -------- move occurs because `services` has type `Vec<Service>`, which does not implement the `Copy` trait
...
20 |     print_summary(services);
   |                   -------- value moved here
21 |
22 |     println!("{} services total", services.len());
   |                                   ^^^^^^^^ value borrowed here after move
   |
note: consider changing this parameter type in function `print_summary` to borrow instead if owning the value isn't necessary

print_summary took ownership of the vector, so when it returned, the vector was dropped. main cannot use it afterwards. There are two fixes and the compiler suggests the first one.

Fix one: borrow. Change the parameter to &[Service] (a slice, the more general choice) or &Vec<Service>, and call it with &services. The function reads the data and gives it back.

fn print_summary(services: &[Service]) {
    for s in services {
        println!("{} in {} ({} replicas)", s.name, s.region, s.replicas);
    }
}

Fix two: .clone(). Call print_summary(services.clone()) and the function gets its own copy. I want to say this plainly because a lot of Rust advice online is written by people building databases: cloning is not a sin in a CLI. A tool that runs once and exits, copying a few hundred bytes of config, does not care. Do not contort the code to avoid a clone. When the borrow checker fights you and the value is small, clone it and move on.

The second consequence: at any moment a value can have either one mutable reference or any number of immutable references, never both. This is what stops you from holding a pointer into a Vec while something else resizes it.

fn main() {
    let mut services = vec![
        Service { name: "checkout-api".to_string(), region: "eu-west-1".to_string(), replicas: 3 },
    ];

    let first = &services[0];

    services.push(Service { name: "search".to_string(), region: "eu-west-1".to_string(), replicas: 2 });

    println!("first is {}", first.name);
}
error[E0502]: cannot borrow `services` as mutable because it is also borrowed as immutable
  --> src/main.rs:8:5
   |
6  |     let first = &services[0];
   |                  -------- immutable borrow occurs here
7  |
8  |     services.push(Service { ... });
   |     ^^^^^^^^^^^^^ mutable borrow occurs here
9  |
10 |     println!("first is {}", first.name);
   |                             ---------- immutable borrow later used here

push may reallocate the vector’s backing storage, which would leave first pointing at freed memory. In Go this is a slice aliasing bug you find in production. Here the fix is to take what you need out before mutating (let first = services[0].name.clone();) or to move the println! above the push so the borrow ends earlier.

The third consequence: a reference cannot outlive the thing it points to. So a function that creates a value cannot return a reference to it, because the value dies when the function returns.

fn make_name(service: &str, region: &str) -> &str {
    &format!("{}-{}", service, region)
}
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:47
  |
1 | fn make_name(service: &str, region: &str) -> &str {
  |                       ----          ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `service` or `region`

The compiler is asking which input the returned reference borrows from, and the honest answer is neither, because format! made a brand new String. The fix is to return String and let the caller own it. Any time you see -> &str on a function that builds something, that is the bug.

Which brings us to lifetimes, the one thing I decided to learn to read but not to write. When you see 'a in a signature, it is a label connecting the output reference to the input references:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

Read it as “the returned reference is borrowed from a or b, so it is valid for as long as the shorter-lived of the two”. That is all a lifetime annotation says. It does not change how long anything lives; it documents a relationship the compiler then enforces.

You will also see structs with lifetime parameters, like struct Config<'a> { region: &'a str }. In application code this is usually a smell. It means the struct is borrowing from something else and cannot outlive it, which makes it awkward to return, store, or send anywhere. Use String and let the struct own its data. The clone is cheap and the code gets simpler.

Here is the short list of errors I now recognise on sight:

Error Cause Fix
E0382 use of moved value Passed a String, Vec or struct to a function or variable, then used the original Pass &value and take &T or &[T] in the function, or .clone()
E0596 cannot borrow as mutable Called a &mut self method or mutated a binding declared without mut Add mut to the let
E0502 cannot borrow as mutable because it is also borrowed as immutable Holding a reference into a collection while pushing to or modifying it Copy out what you need first, or end the borrow before mutating
E0106 missing lifetime specifier Returning &str or &T from a function that creates the value Return String or T and let the caller own it
expected &str, found String Passed an owned String where the function wants a borrow Pass &s instead of s

What I skipped and why

Traits beyond derive, generics with bounds, Box<dyn Trait> as anything more than “the error type”, async and how tokio actually schedules things, unsafe, writing macros, and any lifetime annotation more involved than the one above. I read about each of them for long enough to recognise the syntax and then stopped.

The honest reason is that none of it was needed. The CLIs, API clients and daemons I was going to read use #[derive] for traits, concrete types instead of generics, #[tokio::main] and .await without caring what is underneath, and String fields instead of lifetimes. That is what infra Rust looks like, and it is also what AI-generated Rust looks like, because the models were trained on the same code.

The other reason is that the compiler is a better teacher than most books. Every error above names the rule it is enforcing and, most of the time, the exact fix. The day I need a trait object or a generic function, the error message will tell me, and I will learn that piece then.

What I got for the weekend: I can open a Rust repo and follow it, I know that .unwrap() is where the pager goes off, and I know that an enum with data on its variants is the thing to reach for when the Go version would have been a string and three nullable fields. For an SRE, that was the whole point.