I wrote three lines of Rust and the compiler told me no.
let endpoint = String::from("https://httpbin.org/status/200");
let label = endpoint;
println!("checking {endpoint}");That is not exotic code. In Python, JavaScript, Go, Java, in basically every language I had ever shipped, this prints a string. In Rust it is a compile error, and the error is blunt about it:
error[E0382]: borrow of moved value: `endpoint`
--> src/main.rs:3:25
|
1 | let endpoint = String::from("https://httpbin.org/status/200");
| -------- move occurs because `endpoint` has type `String`,
| which does not implement the `Copy` trait
2 | let label = endpoint;
| -------- value moved here
3 | println!("checking {endpoint}");
| ^^^^^^^^ value borrowed here after moveMy first reaction was the same one most people have: the compiler is broken, or I am holding it wrong. It turned out to be neither. That error is Rust telling me something true about memory that every other language had been hiding from me, and once it clicked, I stopped writing a whole category of bug.
This is the first post in a series where I build pulse, a small terminal API monitor: a TUI that polls a set of HTTP endpoints, shows their status and latency live, and refreshes on a loop. Nothing about that is hard in the abstract. What makes it interesting is that Rust forces you to be honest about who owns what, and a network tool is full of values that get passed around, shared between tasks, and torn down. So the tool is the excuse. The real subject is memory.
TL;DR: In Rust, every value has exactly one owner. Assigning a non-
Copyvalue moves ownership instead of copying it, which invalidates the original binding, that is theE0382error. When the owner goes out of scope, the value is dropped and its heap memory is freed, with no garbage collector. Understanding the stack/heap split and move semantics is the entire foundation; everything else in the series is built on it.
The Series
- Part 1, Ownership and the borrow checker (you are here): project setup, the first request, and why you can't use a value twice.
- Part 2, Borrowing, lifetimes, and parsing with serde
- Part 3, Async, Tokio, and sharing state across tasks
- Part 4, The TUI with ratatui, error handling, and RAII cleanup
- Part 5, Traits, iterators, zero-cost abstractions, and the release build
Scaffolding pulse
Rust ships with cargo, which is the build tool, package manager, and test runner in one. Starting the project is one command.
cargo new pulse
cd pulseThat gives you a Cargo.toml (the manifest) and src/main.rs (a hello-world). We need one dependency to make HTTP requests: reqwest. For now we use its blocking client, a normal synchronous call that waits for the response. We will rip that out and go fully async in Part 3, but blocking code is the right place to learn ownership without the noise of futures.
[package]
name = "pulse"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.12", features = ["blocking"] }One request:
fn main() {
let url = "https://httpbin.org/status/200";
let response = reqwest::blocking::get(url).expect("request failed");
println!("{url} -> {}", response.status());
}$ cargo run
Compiling pulse v0.1.0
Finished dev [unoptimized] target(s) in 1.84s
Running `target/debug/pulse`
https://httpbin.org/status/200 -> 200 OKThat works on the first try and it is genuinely the boring part. The interesting part started the moment I tried to give the URL a more permanent home.
The Bug Rust Won't Let You Write
A monitor watches more than one endpoint, so I reached for a struct that owns its configuration:
struct Target {
name: String,
url: String,
}
fn main() {
let url = String::from("https://httpbin.org/status/200");
let target = Target {
name: String::from("httpbin"),
url, // shorthand for `url: url`
};
// I still wanted to log the raw url I started with:
println!("configured {} at {url}", target.name);
}This is the E0382 error from the top of the post, in its natural habitat. The line url, moved the String into the struct. After that, the url binding is dead. The compiler will not let me read it.
In a garbage-collected language this code is fine because url and target.url would be two references to the same string object, and the runtime keeps the object alive as long as anyone can reach it. Rust does not have that runtime. There is no background process tracking who can reach what. Instead it enforces a single rule at compile time, and the rule is strict enough that it can free memory deterministically without ever scanning the heap.
The rule is ownership.
Ownership in One Paragraph
Every value in Rust has exactly one owner, the variable binding responsible for it. When you assign that value somewhere else, or pass it to a function, ownership moves. The old binding is invalidated. When the owner goes out of scope, the value is dropped and any memory it holds is freed. One owner, one cleanup, no ambiguity.
To see why moving matters, you have to look at what a String actually is in memory.
Stack and Heap: What a String Really Is
A Rust String is not the text. It is a small three-word header that lives on the stack: a pointer to the bytes, the length, and the capacity. The actual UTF-8 bytes live on the heap, which is the region for data whose size isn't known at compile time or that needs to outlive a single function call.
When you write let label = endpoint;, Rust copies the header, three machine words, but not the heap bytes. Now two stack headers point at the same heap buffer. If Rust let both stay valid, then when both went out of scope, both would try to free the same heap buffer. That is a double free: a classic memory-corruption bug that has caused real security vulnerabilities for decades.
Rust's fix is almost rude in its simplicity: after the copy, it marks the original binding as moved-out and refuses to let you touch it. Exactly one header is valid, so exactly one free happens.
The heap buffer was never copied, only the 3-word header moved, which is cheap. endpoint is now off-limits, so only label frees the buffer: no double free. This is why the move is both fast and safe, it is a memcpy of 24 bytes plus a compile-time flag.
This is the whole trick. Other languages avoid double frees with a garbage collector (track references at runtime, free when unreachable) or by making you call free yourself (and trusting you to get it right). Rust avoids them by proving, at compile time, that each value has one owner and is freed once. The cost is paid by you, the programmer, in the form of errors like E0382. The payoff is no GC pauses, no manual free, and no double-free CVEs.
Three Ways to Make the Compiler Happy
Once you accept the rule, the fix depends on what you actually meant.
1. You meant to give the value away. Then the move is correct and the error is pointing at a real mistake, you're trying to use something you already handed off. Reorder the code so you read the value before moving it:
let url = String::from("https://httpbin.org/status/200");
println!("starting with {url}"); // read first
let target = Target { name: "httpbin".into(), url }; // then move2. You genuinely need two independent copies. Then ask for one explicitly with .clone(). This allocates a second heap buffer and copies the bytes. It is not free, and Rust makes you say so out loud, there are no silent deep copies.
let url = String::from("https://httpbin.org/status/200");
let target = Target { name: "httpbin".into(), url: url.clone() };
println!("configured {} at {url}", target.name); // url is still valid3. You only need to look at the value, not own it. Then you borrow it with &, which hands out a reference without transferring ownership. This is the right answer most of the time, and it is so central that the entire next post is about it. A taste:
fn log_target(t: &Target) { // borrows, does not take ownership
println!("{} -> {}", t.name, t.url);
}
let target = Target { name: "httpbin".into(), url: "https://...".into() };
log_target(&target); // lend it
log_target(&target); // lend it again: target still owns everythingNotice the trade-off the language is forcing into the open. clone() is simple but costs an allocation. Borrowing is free but introduces the question how long does the reference stay valid?, which is exactly the question lifetimes answer in Part 2.
| Approach | Cost | When it's right |
|---|---|---|
| Reorder to read-before-move | Free | You really did mean to give the value away |
.clone() |
One heap allocation + copy | You need two independent owners |
Borrow with & |
Free (a pointer) | You only need temporary read access |
Drop: Cleanup You Never Write
The flip side of ownership is destruction. When an owner goes out of scope, Rust inserts a call to drop the value, and for a String that means freeing the heap buffer. You never write the free. You never forget it either.
fn check_once() {
let body = reqwest::blocking::get("https://httpbin.org/json")
.unwrap()
.text()
.unwrap(); // `body` owns a heap String here
println!("got {} bytes", body.len());
} // <- `body` goes out of scope here; its heap buffer is freed automaticallyThis pattern, a resource tied to a value's lifetime, released the instant the value drops, is called RAII (Resource Acquisition Is Initialization). It is not limited to memory. A file handle drops and closes the file. A lock guard drops and releases the lock. In Part 4 we use exactly this mechanism to guarantee the terminal is restored to a sane state even if the program panics mid-render. Memory is just the first thing RAII manages; it ends up managing everything.
No garbage collector decided when to free this. No free() call in your code freed it. The compiler inserted the cleanup at the closing brace because that is where the owner died.
What This Actually Buys You
It is easy to read all of this as bureaucracy, the compiler making you jump through hoops a GC would have handled silently. So it is worth being concrete about what you get in exchange.
| Concern | Garbage-collected language | Manual C/C++ | Rust |
|---|---|---|---|
| Double free | Impossible (GC owns it) | Your job to avoid | Impossible (compile error) |
| Use-after-free | Impossible | Your job to avoid | Impossible (compile error) |
| Memory leak from cycles | Possible | Your job to avoid | Possible but rare, opt-in |
| Pause times | GC pauses, unpredictable | None | None |
| When memory is freed | Eventually, runtime decides | When you call free | Deterministically, at scope end |
| Cost of the guarantee | Runtime overhead | Bugs and CVEs | Compile-time friction |
For a tool like pulse that I want to leave running in a terminal tab for days, "no GC pauses and deterministic cleanup" is a real feature, not a tax. The latency numbers it reports won't jitter because a collector decided to run. And the friction front-loads: you fight the borrow checker while writing, not your users while running.
Where We Are
We have a project that compiles, makes one HTTP request, and prints a status code. More importantly, we have the mental model that the rest of the series leans on:
- A value has one owner.
- Assigning or passing a non-
Copyvalue moves it and invalidates the source. - A
Stringis a stack header pointing at heap bytes; a move copies the header, not the bytes. - The owner going out of scope drops the value and frees its memory, RAII, no GC.
Right now pulse throws away the response body and only looks at the status line. That is wasteful, and it dodges the next hard question: how do you pull structured data out of a response and hold onto pieces of it without copying everything? That is borrowing and lifetimes, and it's where Rust's reputation for difficulty really comes from.
Next: Part 2, Borrowing, lifetimes, and parsing with serde, where the error message changes from "value moved" to the infamous "does not live long enough."
Hiring or have a project?
Let's build something that holds.
Full-stack engineering, system design, and legacy modernization. Available for freelance, contract, and full-time roles.