A Rust primer for humans: what matters when your agent writes the code

On this page
More and more code is being written in Rust, and a growing share of it by coding agents. Why software is converging on Rust goes into the reasons: agents have made rewrites cheap, and Rust's compiler catches many of their mistakes before anyone reads the code. If you write Python, TypeScript or Go, there's a fair chance you'll soon be approving pull requests full of Rust you didn't write.
Reading Rust takes much less than writing it, and most of it maps onto things you already know from Python. The parts that don't, ownership and borrowing, explain nearly every error message you'll see, and they're easier to follow with pictures than with rules.
Part 1: The basics
Most of everyday Rust looks like any other modern language once you know the syntax, and nearly every piece has a Python equivalent.
Install Rust and run your first program
Install Rust with rustup, the official installer. It gives you two tools. rustc is the compiler, and you'll almost never call it directly. cargo is the build tool and package manager, the equivalent of pip and python rolled together, and it's what you'll type all day.
Make a project and run it:
cargo new hello
cd hello
cargo run
Compiling hello v0.1.0 (/Users/you/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.68s
Running `target/debug/hello`
Hello, world!
cargo new made two files. Cargo.toml describes the project and its dependencies, like pyproject.toml or package.json. src/main.rs holds the program:
fn main() {
println!("Hello, world!");
}
fn main() is where every Rust program starts. println! prints a line; the ! means it's a macro rather than a function, which you can ignore for now beyond knowing that macros end in !. If you'd rather not install anything yet, the Rust Playground runs it in the browser.
Unlike Python, Rust is compiled. cargo run first turns your source into a native binary, then runs it. Most of the errors Python reports while your program is running, Rust reports before it runs at all. That's the main thing you get in exchange for Rust's stricter rules.
These are the five cargo commands worth knowing:
| Command | What it does |
|---|---|
cargo run | Compile and run the program |
cargo check | Check everything without building a binary. Much faster, and what agents run in a loop |
cargo test | Build and run the tests |
cargo clippy | Run hundreds of extra lints that catch code that compiles but is probably wrong |
cargo fmt | Format the code in the standard style |
Variables are immutable unless you say mut
Here's the same handful of variables in both languages:
name = "Ferris"
count = 0
count += 1
price = 9.99
let name = "Ferris";
let mut count = 0;
count += 1;
let price: f64 = 9.99;
Two differences. Variables are declared with let, and they can't change unless you add mut. Try to write count += 1 without the mut and the compiler refuses. This sounds fussy, but it means when you read Rust, every mut marks a place where state changes, and everything else stays put.
Rust is statically typed, but you rarely write the types out. The compiler infers them, so let count = 0 is an integer without saying so. You add a type when you want something specific, like f64 above, or when the compiler can't work it out.
The types map onto Python's like this:
| Python | Rust | Notes |
|---|---|---|
int | i32, i64, u32, usize... | Fixed size. i is signed, u is unsigned, the number is bits. usize is used for lengths and indexes |
float | f64 (or f32) | |
bool | bool | true and false, lower case |
str | String and &str | String owns its text, &str borrows it |
list | Vec<T> | All elements have the same type T |
dict | HashMap<K, V> | Imported from std::collections |
None | Option<T> | Not a value you can forget to check |
Rust also lets you reuse a name for a new variable, called shadowing. It's common when converting a value from one type to another:
let input = "42";
let input: i32 = input.parse().unwrap();
The second input is a new variable that happens to have the same name. The first one, the text, is gone from view.
Functions and control flow
A function lists its parameter types and its return type after ->:
fn add(a: i32, b: i32) -> i32 {
a + b
}
There's no return on the last line. In Rust, the last expression in a block is its value, as long as it has no semicolon. You can still write return to leave a function early, and you'll see both.
The same idea makes if an expression, so it can produce a value, like Python's x if cond else y:
fn grade(score: u32) -> &'static str {
if score >= 90 { "A" } else if score >= 75 { "B" } else { "C" }
}
(The &'static str return type means "a piece of text that lives for the whole program", which is what string literals are.)
Loops look familiar. 0..3 is a range, like Python's range(3):
for i in 0..3 {
println!("i = {i}");
}
let words = vec!["read", "edit", "bash"];
for w in &words {
println!("{w}");
}
let mut n = 3;
while n > 0 {
n -= 1;
}
vec![...] makes a Vec, Rust's growable list. The & in for w in &words means "loop over the list without consuming it", which is your first glimpse of borrowing. There's also loop { }, which runs until you break.
Structs and methods instead of classes
Rust has no classes. Data goes in a struct, and methods go in a separate impl block:
class Task:
def __init__(self, title):
self.title = title
self.done = False
def finish(self):
self.done = True
struct Task {
title: String,
done: bool,
}
impl Task {
fn new(title: &str) -> Task {
Task { title: title.to_string(), done: false }
}
fn finish(&mut self) {
self.done = true;
}
fn label(&self) -> String {
let mark = if self.done { "x" } else { " " };
format!("[{mark}] {}", self.title)
}
}
new has no self, so it's called on the type, Task::new("write the primer"). It's only a naming convention; Rust has no special constructor. The other two take self in different ways, and the difference matters. &self means the method only reads the task. &mut self means it may change it. Python's self doesn't tell you which, so you have to read the body. In Rust the signature tells you.
There's no inheritance either. Shared behaviour comes from traits.
Enums and match
Rust's enums are more capable than Python's. Each variant can carry its own data:
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
Point,
}
A Shape is exactly one of these three. You take it apart with match, which is like a switch or Python's match statement, with one important difference: it has to handle every variant.
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rect { w, h } => w * h,
Shape::Point => 0.0,
}
}
Leave out a case and the program doesn't compile. Here's what you get with an enum of four statuses and a match that only handles three:
error[E0004]: non-exhaustive patterns: `&Status::Failed` not covered
--> examples/nonexhaustive.rs:9:11
|
9 | match s {
| ^ pattern `&Status::Failed` not covered
|
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
12~ Status::Done => "finished",
13~ &Status::Failed => todo!(),
This pays off over time. When someone adds a fifth status next month, the compiler lists every match that needs updating. The one way to opt out is a wildcard arm, _ => ..., which catches everything not listed, including variants that don't exist yet. In review, a wildcard on an enum the code owns deserves a second look.
Part 2: Ownership and borrowing
Everything so far has a close cousin in Python. Ownership doesn't, and almost everything after this depends on it.
Every value has one owner
Python manages memory for you with a garbage collector. Many variables can refer to the same list, and the list is freed some time after the last reference disappears. C and C++ make you free memory yourself, which is fast but easy to get wrong: free something twice, or use it after freeing it, and you get crashes or security holes. Rust takes a third route. Every value has exactly one owner, and when the owner goes out of scope, the value is freed right there. The compiler checks the rules, so there's no garbage collector and no manual freeing.
To see what's being owned, look at what a String is in memory. The variable itself is small and fixed-size: a pointer to the text, its length and its capacity. The text lives elsewhere, in a region called the heap.
Moving a value
Now assign it to another variable:
let s1 = String::from("hello");
let s2 = s1;
In Python, s2 = s1 gives you two names for the same object. In C++, copying a string copies the text. Rust does neither. It copies the three small fields, so s2 points at the same text, and then treats s1 as no longer valid. Ownership has moved to s2. That's called a move.
The reason is the "freed when the owner goes out of scope" rule. If both s1 and s2 owned the text, it would be freed twice when they both went out of scope, which is exactly the C++ bug Rust exists to prevent.
Moves happen when you pass a value to a function, too. This is the error you'll see most often while learning, and in review:
fn summarize(text: String) -> usize {
text.len()
}
fn main() {
let prompt = String::from("fix the failing test");
let n = summarize(prompt);
println!("{n} bytes in {prompt}");
}
error[E0382]: borrow of moved value: `prompt`
--> examples/moved.rs:8:29
|
6 | let prompt = String::from("fix the failing test");
| ------ move occurs because `prompt` has type `String`, which does not implement the `Copy` trait
7 | let n = summarize(prompt);
| ------ value moved here
8 | println!("{n} bytes in {prompt}");
| ^^^^^^ value borrowed here after move
|
note: consider changing this parameter type in function `summarize` to borrow instead if owning the value isn't necessary
--> examples/moved.rs:1:20
|
1 | fn summarize(text: String) -> usize {
| --------- ^^^^^^ this parameter takes ownership of the value
help: consider cloning the value if the performance cost is acceptable
|
7 | let n = summarize(prompt.clone());
| ++++++++
Rust's error messages read like a short story. Line 6 is where prompt was created, line 7 is where it moved into summarize, and line 8 is where it was used after the move. Then come two suggested fixes. The note says summarize should borrow the text instead of taking it, which is the right fix, since it only reads the text. The help says to clone it, which makes a full copy of the text and hands over the copy. Cloning always works, which is why you'll see a lot of it in code written by people, and agents, who were in a hurry.
Values that copy instead of moving
Small, simple values don't move. Integers, floats, booleans and characters are Copy: assigning them copies the bits and both variables stay usable. That's why you'll never see this error on an i32. Moves apply to values that own something on the heap, like String, Vec and most structs.
Step through the whole lifecycle here, one line at a time:
Borrowing with &
Most functions don't need to own their arguments. They just need to look at them, or change them and hand them back. For that, Rust has references, written with &. Creating a reference is called borrowing.
fn summarize(text: &str) -> usize {
text.len()
}
let prompt = String::from("fix the failing test");
let n = summarize(&prompt);
println!("{n} bytes in {prompt}");
&prompt lends prompt to the function. The function can read it, and when it returns, prompt still belongs to main and is still usable. (The parameter type is &str, a borrowed piece of text, rather than &String. It accepts both string types, so it's the usual choice for functions that only read text.)
Many readers or one writer
There are two kinds of borrow, and one rule connects them:
&Tis a shared reference. It can read the value but not change it, and you can have as many as you like at the same time.&mut Tis a mutable reference. It can change the value, and while it exists, nothing else can use the value at all, not even to read it.
The rule looks strict until you see what it prevents:
fn main() {
let mut calls = vec![String::from("read"), String::from("edit")];
let first = &calls[0];
calls.push(String::from("bash"));
println!("first call was {first}");
}
error[E0502]: cannot borrow `calls` as mutable because it is also borrowed as immutable
--> examples/borrow.rs:4:5
|
3 | let first = &calls[0];
| ----- immutable borrow occurs here
4 | calls.push(String::from("bash"));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("first call was {first}");
| ----- immutable borrow later used here
first points into the vector's storage. When push runs out of room, it allocates a bigger block of memory, moves everything across, and frees the old one, so first would be pointing at freed memory. In C++ the equivalent code compiles and reads garbage, or worse. Python avoids the problem with its garbage collector. Rust avoids it with this rule, at compile time, at no cost when the program runs.
This, together with ownership, is what people mean by "the borrow checker". It has a reputation for being hard to please, and when you're writing Rust, it sometimes is. When you're reading Rust, it's working for you: the code in front of you has already passed it.
Lifetimes, just enough to read them
Sooner or later you'll see a signature like this:
fn first_word<'a>(text: &'a str) -> &'a str
The 'a is a lifetime, a name for how long a borrow is valid. Here it says the returned reference points into text, so it can't be used after text is gone. For a reader, that's the whole job of a lifetime annotation: it tells you which input a returned reference comes from. Most of the time the compiler works this out without annotations. This signature is simple enough that cargo clippy suggests dropping the 'a entirely, and when a lifetime is obvious you'll often see '_ instead of a name.
The one lifetime to recognise is 'static, which means "valid for the whole program". String literals are 'static, which is why grade earlier returned &'static str.
Part 3: Absence and failure
Python programmers lean on two things every day that Rust doesn't have: None, which can turn up anywhere, and exceptions. Rust replaces both with ordinary enums.
No null: Option
A value that might be missing has the type Option<T>, which is an enum with two variants: Some(value) and None.
fn find_user(id: u32) -> Option<String> {
if id == 1 { Some(String::from("ada")) } else { None }
}
match find_user(2) {
Some(name) => println!("found {name}"),
None => println!("no such user"),
}
if let Some(name) = find_user(1) {
println!("hello {name}");
}
You can't use the String inside without first checking which variant you have. match handles both; if let handles just the one you care about. In Python, forgetting the None check gives you AttributeError: 'NoneType' object has no attribute ... at runtime. In Rust it doesn't compile.
No exceptions: Result and ?
A function that can fail returns Result<T, E>, another two-variant enum: Ok(value) or Err(error).
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
let port: u16 = s.trim().parse()?;
Ok(port)
}
The ? at the end of the second line does the work a try/except would. If parse() returned an error, ? returns that error from parse_port immediately. If it succeeded, ? takes the number out and carries on. Every place an error can leave a function is marked with one ?, which makes error paths easy to find when reading.
unwrap, and when it's a problem
You'll also see .unwrap(). It takes the value out of an Option or Result, and if there isn't one, it panics, which crashes the current thread. .expect("message") does the same with an explanation attached. They're fine in tests, in quick scripts, and where failure truly can't happen. In code that handles outside input, they're a crash waiting for the wrong input. In November 2025, a Cloudflare outage that broke a large part of the web for several hours came down to a Rust proxy calling unwrap() on a Result that, for the first time, held an error.
Part 4: Real programs
Strings, Vec and HashMap
Now the two string types make sense. String owns its text on the heap, can grow, and is freed when its owner goes away. &str is a borrowed view of text that something else owns: a String, or a literal compiled into the program. Functions that only read text take &str. Structs that need to keep text store a String.
Vec<T> is the growable list and HashMap<K, V> is the dictionary:
use std::collections::HashMap;
let scores = vec![72, 95, 88, 40];
let total: i32 = scores.iter().sum();
let mut ages: HashMap<String, u32> = HashMap::new();
ages.insert(String::from("ada"), 36);
if let Some(age) = ages.get("ada") {
println!("ada is {age}");
}
ages.get returns an Option, because the key might not be there. Python would raise a KeyError from ages["ada"]; Rust makes you decide what happens in that case.
Iterators instead of list comprehensions
Where Python uses a list comprehension, Rust uses an iterator chain:
doubled = [s * 2 for s in scores if s >= 50]
let doubled: Vec<i32> = scores.iter().filter(|s| **s >= 50).map(|s| s * 2).collect();
Read it left to right: go through the scores, keep the ones that are at least 50, double each one, collect the results into a Vec. The |s| ... parts are closures, Rust's lambdas. (The **s is because filter hands the closure a reference to a reference; you'll get used to it.) Chains like this compile down to roughly the same machine code as a hand-written loop.
One detail to watch: .iter() borrows the collection, which stays usable afterwards. .into_iter() consumes it, moving each element out. Mixing them up is a common source of move errors.
Traits: shared behaviour without inheritance
A trait is a set of methods a type promises to provide, close to an interface in TypeScript or Java, or a protocol in Python.
trait Describe {
fn describe(&self) -> String;
}
impl Describe for Task {
fn describe(&self) -> String {
format!("task '{}'", self.title)
}
}
Any type can implement any number of traits, and that's how Rust shares behaviour instead of using inheritance. The standard library defines traits for common abilities: Display for printing with {}, Debug for printing with {:?}, Clone for .clone(), and so on. For the obvious ones, you can ask the compiler to write the implementation for you:
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
Functions can accept any type that implements a trait. These two are equivalent ways of writing "anything that can be printed with {}":
fn show(item: &impl std::fmt::Display) { println!("{item}"); }
fn show_generic<T: std::fmt::Display>(item: &T) { println!("{item}"); }
You'll also see Box<dyn Display>, which means the same thing decided at runtime rather than compile time, for when you need a list of mixed types.
Crates: using other people's code
Libraries in Rust are called crates, and they come from crates.io. cargo add adds one to Cargo.toml, like pip install plus a line in your requirements. The most common one you'll see is serde, which converts structs to and from JSON and other formats:
cargo add serde --features derive
cargo add serde_json
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ToolCall {
name: String,
args: std::collections::HashMap<String, String>,
}
fn parse_calls(input: &str) -> Result<Vec<ToolCall>, serde_json::Error> {
let calls: Vec<ToolCall> = serde_json::from_str(input)?;
Ok(calls)
}
#[derive(Deserialize)] generates the parsing code from the struct definition, and parse_calls returns a Result because the JSON might be malformed. Other names you'll see often: tokio for async, anyhow and thiserror for errors, clap for command-line arguments, reqwest for HTTP.
Threads and async, enough to read them
Rust applies the ownership rules across threads too. Two built-in traits do it. A type is Send if it's safe to move to another thread, and Sync if it's safe to share between threads. The compiler works these out and checks them wherever a thread starts.
To share data between threads you'll see Arc<Mutex<T>>. Arc lets several threads own the same value; it counts the owners and frees the value when the last one is done. Mutex is a lock. In Rust the data lives inside the lock, so you can't reach it without calling .lock(), and forgetting to lock is a compile error rather than a race condition you find in production.
use std::sync::{Arc, Mutex};
use std::thread;
let log = Arc::new(Mutex::new(Vec::new()));
let handles: Vec<_> = (0..3)
.map(|i| {
let log = Arc::clone(&log);
thread::spawn(move || log.lock().unwrap().push(i))
})
.collect();
for h in handles {
h.join().unwrap();
}
Arc has a single-threaded sibling, Rc, which is cheaper. If you try to send an Rc to another thread, you get a compile error:
error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
--> examples/race.rs:6:32
|
6 | let handle = thread::spawn(move || {
| ------------- required by a bound introduced by this call
...
= help: within `{closure@examples/race.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`
async fn and .await work much like they do in JavaScript and Python, with one difference: Rust doesn't include a runtime to run them, so a library provides one. That's almost always Tokio, which is why you'll see #[tokio::main] above main.
Part 5: Reviewing Rust your agent wrote
Say an agent has opened a pull request in Rust, the tests pass, and you have to approve it or push back. Here's where to look.
Read the signature before the body
Rust signatures say a lot about what a function is allowed to do, and the compiler holds the function to it. You can learn most of what matters before reading the body:
| In the signature | What it tells you |
|---|---|
x: Config | Takes ownership. The caller can't use x afterwards |
x: &Config | Only reads x |
x: &mut Config | May change x. Look here for side effects |
-> Result<T, E> | Can fail, and callers must handle it |
-> Option<T> | Might return nothing |
T: Send | Must be safe to move to another thread |
unsafe fn | Some of the compiler's checks are off. The caller must uphold rules it can't verify |
pub | Part of the public API. Changes affect other code |
A function that takes &Config can't quietly modify the config. A function that returns plain u32 instead of a Result is claiming it can't fail; if it parses input or does I/O, that usually means an unwrap() inside.
Read the compiler's errors, then the fix
When an agent fixes a compile error, look at what the compiler said before looking at the fix. Rust errors always have the same shape: an error code and summary, the file and line, labelled spans that tell the story in order, then note and help lines with suggested changes. Two habits help. Read only the first error, since later ones are often knock-on effects. And treat the help suggestion with some suspicion. It's the quickest edit that compiles, which isn't always the right one, as the .clone() suggestion for E0382 shows. rustc --explain E0382 prints a full explanation of any error code.
Agents do comparatively well with Rust for this reason. The same messages are available as JSON, with each suggested edit marked by how safe it is to apply, so an agent is told exactly what's wrong and often what to type. When GitHub ported its Copilot runtime to Rust using agents, ownership and borrowing errors were only 1.7% of the compile errors they ran into. The agents mostly get ownership right. What's left for you is the cases where they got it to compile in a way that costs something.
An agent's diff, annotated
Here's a typical agent diff. Click any highlighted part for what it means and how much it should concern you:
Seven smells in agent-written Rust
Code an agent wrote can compile, pass the tests and still take shortcuts. These are the ones I look for first. All of them compile, so the compiler won't point them out:
| Smell | What it looks like | What to ask |
|---|---|---|
| Clone to compile | .clone() on a value that's only read afterwards | Could this be a borrow? |
| Unwrap on input | .unwrap() on parsed input, a network response or a file | What happens on bad input? |
Unexplained unsafe | An unsafe { } block without a // SAFETY: comment | Why is this sound, and who keeps it that way? |
| Stubs that compile | todo!(), unimplemented!(), or a hard-coded default where logic should be | Does a test reach this code? |
| Silenced lints | #[allow(...)] added in the same diff | What was the lint going to say? |
| Forever lifetimes | 'static, Box::leak, or Arc everywhere to get past a lifetime error | Does this really need to live forever? |
| Catch-all match | _ => on an enum the code owns | Should a new variant be a compile error here? |
These come from people watching agents at scale. Niko Matsakis, a longtime lead of the Rust language team, wrote this month that agent-written async Rust tends to be clone-heavy. During Bun's move from Zig to Rust, agents stubbed out functions instead of fixing them until the reviewing agents were told to reject that. A quick habit: before reading a diff's logic, search it for clone, unwrap, unsafe, todo!, allow( and 'static. It takes seconds and shows you where the agent had trouble.
Try it on these snippets. Each one compiles; decide which you'd question:
Where to go next
If you'd like to go from reading Rust to writing it, these are the resources most Rustaceans started with:
- The Rust Programming Language, known as "the Book". It's free, the ownership diagrams above come from it, and chapter 4 covers ownership in more depth.
- Rustlings: small exercises that fail to compile until you fix them, so you learn from the compiler directly.
- Rust by Example, for people who'd rather read code than prose.
- The Clippy lint list, the best catalogue of code that compiles but probably shouldn't.
For why so much Rust is about to land in your review queue, read Why software is converging on Rust in the age of AI. For what the agent is doing on the other side of the pull request, start with what an agent harness is.