Rust Gotchas
The kitchen scale on the counter reads up to 9.99 pounds. Put a ten-pound bag of flour on it and the dial spins past the maximum, wraps around the back, and settles on 0.01 pounds. Nothing broke. The scale did exactly what it was built to do — it just ran out of room and started over from the bottom. A signed 32-bit integer in Rust works the same way. It can hold any whole number from -2,147,483,648 up to 2,147,483,647, and the moment you try to add 1 to the top number, the value flips to the very bottom. The flour is still there; the dial just lies about it.

Rust thinks this lie is dangerous enough to crash your program over — but only when you compile in debug mode. The team that built Rust at Mozilla in 2010 was watching the same overflow bugs that took down Ariane 5 in 1996 and burned a hole through years of C code at every bank on Wall Street. They decided the compiler should panic on overflow during development so the bug surfaces while you are still at the kitchen table, and wrap silently in release builds so the shipping product runs at full speed. The catch is that the two modes disagree. The same code that crashes when you press play in your editor will quietly return the wrong answer when you ship it.
The way out is to tell Rust exactly what you want to happen when the scale runs out of room. There are four helper methods on every integer, and each one is a different policy.
fn show_overflow() {
let max = i32::MAX;
println!("--- integer overflow ---");
println!("i32::MAX = {max}");
println!("wrapping_add(1) = {}", max.wrapping_add(1));
println!("checked_add(1) = {:?}", max.checked_add(1));
let (val, did_wrap) = max.overflowing_add(1);
println!("overflowing_add(1) = ({val}, wrapped={did_wrap})");
println!("saturating_add(1) = {}", max.saturating_add(1));
}The run prints the four answers side by side. wrapping_add spins the dial around — i32::MAX + 1 becomes i32::MIN. checked_add returns None instead of giving you a wrong number. overflowing_add gives you both the wrapped value and a flag saying "this happened." saturating_add glues the dial to the maximum so adding more never moves it. Pick the policy that fits your kitchen. If you are counting cookies and they cannot go negative, saturating_add is right. If you are doing financial math, checked_add and a real error is the only sane choice.
The second gotcha is a different kind of trick the kitchen plays on you. There are two ways to keep a recipe. The first is a notecard you wrote yourself — your own paper, your own ink, sitting in your own drawer. You can scribble more steps on it, throw it away, hand it to a friend. The second is a magnet on the fridge pointing at someone else's cookbook. You can read the recipe through the magnet, but you do not own the cookbook and you cannot edit it.

In Rust, the notecard is a String. It lives on the heap, it can grow, and one variable owns it. The fridge magnet is a &str. It is a borrowed view — a pointer plus a length that says "look at these letters over there." A string literal like "Mantle" written in your source code is baked into the binary itself, and a &str is what you get when you reach for it. The same &str type works for both a window into a String you just built and a window into a literal that has been sitting in the binary since you compiled.
fn greet(name: &str) {
println!("hello, {name}");
}
fn show_string_vs_str() {
let owned: String = String::from("Aarit");
let view: &str = &owned;
let literal: &str = "Mantle";
println!("--- string vs &str ---");
println!("owned : {owned} (heap, can grow)");
println!("view : {view} (borrowed window into owned)");
println!("literal : {literal} (baked into the binary)");
greet(&owned);
greet(literal);
}Look at the greet function. It takes a &str. The same function works whether you hand it a borrowed view of a String or a literal sitting in the binary. This is why nearly every Rust function in the wild takes &str and not String — taking &str is taking the magnet, which works no matter where the recipe is written. Taking String would force the caller to give up ownership of their own notecard, which is rude unless the function genuinely needs to keep the recipe.
The mistake everyone makes once is the other direction. You write a function that takes String, then call it with "hello" and the compiler tells you the types do not match. The fix is to change the function signature to &str, not to wrap the literal in String::from. The wrapper allocates on the heap for no reason. The signature change is free.
The third gotcha is the one that sends every Rust beginner to Stack Overflow on day one. The kitchen has one butcher knife. You set it on the counter and call it a. Your sous chef walks in, picks it up, and you give them the slot label b. The knife is in their hand now. It is not on your counter. If you reach for a and try to chop something, your hand closes on empty air.

In code, this is let b = a when a is a String. The heap allocation does not get copied — the ownership slides from a to b, and slot a is invalidated by the compiler. Any later reference to a is a hard compile error: error[E0382]: borrow of moved value: a. The program will not build. This rule is the bedrock the rest of Rust's safety story sits on, and it is also why your first hour with the language is full of red squiggles you did not expect.
fn show_move_surprise() {
println!("--- move semantics ---");
let a = String::from("card");
let b = a;
// println!("a holds: {a}"); // compile error: borrow of moved value
println!("after let b = a, slot a is empty, b holds: {b}");
let a2 = String::from("card");
let b2 = a2.clone();
println!("with clone, a2 holds: {a2}, b2 holds: {b2}");
}The fix is one method call. a.clone() reaches into the heap, makes a real duplicate of the data, and hands you a brand-new String for slot b. Both slots are valid. Both names work. You paid for the duplicate in memory and time, which is exactly what Rust wants — the cost is in your code where you can see it, not hidden behind a garbage collector.
--- integer overflow ---
i32::MAX = 2147483647
wrapping_add(1) = -2147483648
checked_add(1) = None
overflowing_add(1) = (-2147483648, wrapped=true)
saturating_add(1) = 2147483647
--- string vs &str ---
owned : Aarit (heap, can grow)
view : Aarit (borrowed window into owned)
literal : Mantle (baked into the binary)
hello, Aarit
hello, Mantle
--- move semantics ---
after let b = a, slot a is empty, b holds: card
with clone, a2 holds: card, b2 holds: cardThe output line by line is the whole point of the lesson. The overflow block shows four different answers from the same input depending on which method you picked. The string block shows three names — owned, view, literal — feeding the same greet function. The move block shows that after let b = a, slot a is gone, and that .clone() is the one-word escape hatch when you actually need two of something.
One question worth asking — why does Rust not just pick the safe default everywhere, the way Python does? Because Rust is the language you reach for when the garbage collector and the silent allocation are themselves the bug. Hoare's whole point was that the costs should be visible at the call site, not hidden behind a runtime. Overflow has to be explicit because the alternative is the Therac-25 radiation overdose. Move has to be explicit because the alternative is the use-after-free that ate twenty years of C. The &str versus String split has to be explicit because the alternative is a hidden copy on every function call.
Next lesson — what cargo, clippy, and rustfmt do under the hood, and why the toolchain matters as much as the language.