10 Days of Rust for Python Developers: A Recap
I ran a 10-day beginner Rust challenge on LinkedIn, one small exercise per day, written for Python developers. The goal was to give developers enough exposure to decide whether Rust deserves a real push.
This post is the recap. Each day pulls one snippet from the solutions branch and one usable idea.
Day 1: last expression is the return value
fn greet() -> String {
"Hello, Rustacean!".to_string()
}
fn double_counter() -> i32 {
let mut counter = 1;
for _ in 0..5 {
counter *= 2;
}
counter
}
Two ideas land on day one. Drop the semicolon and the last expression is the return value, no return needed. And variables are immutable by default; you opt into mutation with mut. This gets you in the mindset to ask yourself which data is allowed to change.
Day 2: format! is f-strings, with a Debug placeholder
fn describe_types() -> String {
let int = 42;
let float = 3.14;
let flag = true;
let letter = 'Z';
let pair = (7, "Rust");
format!(
"int: {}, float: {}, bool: {}, char: {}, tuple: {:?}",
int, float, flag, letter, pair
)
}
Static types with inference. {} for Display, {:?} for Debug, which you need for tuples and other compound types.
But wait, this is more like Python's str.format() than f-strings, right? It turns out, you can get to f-string levels in Rust as well:
- format!(
- "int: {}, float: {}, bool: {}, char: {}, tuple: {:?}",
- int, float, flag, letter, pair
- )
+ format!("int: {int}, float: {float}, bool: {flag}, char: {letter}, tuple: {pair:?}")
I just committed this change. Note that the pair tuple still requires the :? specifier inside the brackets to use the Debug trait. Cleaner, and this inline syntax has been stable since Rust 1.58.
Day 3: if and match are expressions
fn grade_message(score: i32) -> String {
let grade = if score >= 90 {
"Excellent"
} else if score >= 75 {
"Good"
} else if score >= 50 {
"Pass"
} else {
"Fail"
};
match grade.chars().next() {
Some('E') => "Top".to_string(),
Some('G') => "Decent".to_string(),
Some('P') => "Basic".to_string(),
_ => "None".to_string(),
}
}
Branches return values, so you can bind the result. And match is exhaustive. Remove an enum variant and every match in the codebase tells you where to fix it. One of those compiler strictness things I've come to appreciate about Rust.
Day 4: &str vs String is a mental shift
fn first_word(s: &str) -> &str {
s.split_whitespace().next().unwrap_or("")
}
fn shout(s: &str) -> String {
format!("{}!", s.to_uppercase())
}
&str is a borrowed view into existing data; String is owned and heap-allocated. The function signature tells you which one you're getting. Rust makes this explicit. Annoying at first, but it lets you manage memory deliberately and avoid unnecessary allocations.
Day 5: struct + impl, not classes
struct Temperature {
celsius: f64,
}
impl Temperature {
fn new(celsius: f64) -> Self {
Self { celsius }
}
fn to_fahrenheit(&self) -> f64 {
self.celsius * 9.0 / 5.0 + 32.0
}
fn is_fever(&self) -> bool {
self.celsius >= 38.0
}
}
Python classes bundle data and behavior. Rust splits them: struct for state, impl for methods. &self is read-only; mutation needs &mut self, visible right in the signature. No inheritance, just composition and traits. How much OOP do you really need? More on this in what Rust structs taught me about state ownership.
Day 6: Option<T> instead of None
#[derive(Debug, PartialEq)]
enum Direction {
North,
South,
East,
West,
}
fn parse_direction(c: char) -> Option<Direction> {
match c {
'N' => Some(Direction::North),
'S' => Some(Direction::South),
'E' => Some(Direction::East),
'W' => Some(Direction::West),
_ => None,
}
}
There is no null in Rust. Option<T> is either Some(value) or None, and the compiler forces you to handle both. This rules out the AttributeError: 'NoneType' object has no attribute 'x' runtime crashes we all know from Python. Custom enums make invalid states unrepresentable.
A challenge taker hit this exact wall on Day 4 and asked:
Just finished day 4 and Rust's generic/option type really gives me a headache, probably because I don't fully know the syntax yet.
&str.split_whitespace()returns an iterator. If this were Go (or Python), I'd just iterate it as usual and return the value. Butiter.next()returnsOption<T>. The compiler just tells me to add.expect("REASON")and everything works. I feel lost on this. Can you help me understand?
My reply:
The
Optionis there to protect you. Something is eitherSomeorNone, and the compiler forces you to handle it..expect()works fine, until thefirst_word("")test runs: empty string, no words,None, panic.More idiomatic:
unwrap_or,match, orif let, e.g.s.split_whitespace().next().unwrap_or("").What feels cumbersome at first is exactly what you'll thank Rust for later: it forces you to handle it. Strict as hell, usually for a good reason :)
Day 7: iterators feel Pythonic, with ? for early exit
fn score_summary(scores: &[i32]) -> Option<(i32, i32, f64)> {
let min = *scores.iter().min()?;
let max = *scores.iter().max()?;
let sum: i32 = scores.iter().sum();
let avg = sum as f64 / scores.len() as f64;
Some((min, max, avg))
}
Vec<T> is list[T]. .iter().min() returns Option<&i32> because the slice might be empty, and the ? operator acts as a declarative early return, short-circuiting to None if a value is missing. Iterator chains are zero-cost abstractions: they are lazy by default and compile into optimized machine code equivalent to manual loops.
Day 8: errors are values, ? is a one-character try/except
fn parse_score(s: &str) -> Result<u32, String> {
let n: u32 = s
.trim()
.parse()
.map_err(|_| format!("'{}' is not a valid number", s.trim()))?;
if n > 100 {
return Err(format!("{} is out of range (0-100)", n));
}
Ok(n)
}
Result<T, E> puts failure in the function signature, and as we've seen, ? propagates the error without try/except boilerplate. The compiler stops callers from ignoring it; the mindset shift I keep coming back to.
Related: Rust made me a better Python developer and the Rust compiler as an AI agent guardrail.
Day 9: closures and iterator chains
fn top_scorers(records: &[&str], threshold: u32) -> Vec<String> {
let mut pairs: Vec<(String, u32)> = records
.iter()
.filter_map(|record| {
let (name, score_str) = record.split_once(':')?;
let score: u32 = score_str.trim().parse().ok()?;
(score >= threshold).then(|| (name.trim().to_string(), score))
})
.collect();
pairs.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
pairs
.into_iter()
.map(|(name, score)| format!("{} ({})", name, score))
.collect()
}
Ok here it got more complicated, but the pattern is common: iterator chains with filter_map and closures.
filter_map keeps Some and drops None, so ? inside the closure quietly skips malformed records. The closure here captures threshold from the enclosing scope, the same way Python closures (and lambdas) pick up surrounding variables. If you like the functional programming side of Python, Rust's iterators and closures will feel familiar, but with the added safety of the type system.
Day 10: HashMap::entry().or_insert(0) is Rust's Counter
fn word_count(text: &str) -> HashMap<String, usize> {
let mut map: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
let clean: String = word
.chars()
.filter(|c| c.is_alphabetic())
.flat_map(|c| c.to_lowercase())
.collect();
if !clean.is_empty() {
*map.entry(clean).or_insert(0) += 1;
}
}
map
}
fn summarize(text: &str, n: usize) -> Vec<String> {
let counts = word_count(text);
top_n(&counts, n)
.into_iter()
.map(|(word, count)| format!("{word} ({count})"))
.collect()
}
Honestly, you cannot beat the conciseness of collections.Counter in Python, but *map.entry(clean).or_insert(0) += 1 is the closest Rust equivalent. The entry API is a powerful pattern for counting and grouping. The snippet above has a lot to unpack thanks to Rust's iterators and closures, the same way list and generator comprehensions do in Python.
Full file (with use std::collections::HashMap; and a top_n helper) on the solutions branch.
Idiomatic refactors from the submissions
I got some code shared in DMs, which gave me a chance to walk through a few more idiomatic patterns.
1. match on Result becomes ?
// Submitted
for record in records {
match parse_score(record) {
Ok(score) => scores.push(score),
Err(err) => return Err(err),
}
}
// Idiomatic
for record in records {
scores.push(parse_score(record)?);
}
The ? operator is exactly that match. One character does the same work.
2. .unwrap() chains become ? inside filter_map
// Submitted
.filter(|r| r.split_once(':').unwrap().1.parse::<u32>().unwrap() >= threshold)
// Idiomatic and safer
.filter_map(|r| {
let (name, score_str) = r.split_once(':')?;
let score: u32 = score_str.trim().parse().ok()?;
(score >= threshold).then(|| (name.trim().to_string(), score))
})
.unwrap() panics on None or Err. Inside a filter_map, ? quietly skips the bad record instead of crashing the program.
3. One .collect(), not three
// Submitted
records
.iter()
.filter(|r| r.contains(':'))
.collect::<Vec<_>>()
.iter()
.filter(|r| ...)
.collect::<Vec<_>>()
.iter()
.map(|r| ...)
.collect::<Vec<_>>()
// Idiomatic
records
.iter()
.filter_map(|r| ... )
.map(|r| ... )
.collect::<Vec<_>>()
Each .collect() allocates a new Vec. Iterators are lazy on purpose: keep chaining and collect once at the end.
4. One split_once call, destructure once
// Submitted
let name = record.split_once(':').unwrap().0.to_string();
let score = record.split_once(':').unwrap().1.parse::<u32>().unwrap();
// Idiomatic
let (name, score_str) = record.split_once(':')?;
let score: u32 = score_str.trim().parse().ok()?;
split_once returns an Option<(&str, &str)>. Destructure it once, name both halves, and move on. The repeated call hides intent and adds two unwraps that can panic.
5. sort_by_key + Reverse instead of .sort() + .reverse()
This was a refactoring I did myself after writing the initial version. Diff:
- pairs.sort_by(|a, b| b.1.cmp(&a.1));
+ // `sort_by_key` + `Reverse` reads cleaner than a manual `b.cmp(&a)` comparator.
+ pairs.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
Here I switched from sort_by with a custom comparator to sort_by_key with Reverse; this feels similar to Python's sorted(..., key=..., reverse=True).
Let the linter teach you
Most of the patterns above get flagged by cargo clippy -- -D warnings.
Run it once and it will point you toward the idiomatic version, with a link to the rule.
Pair it with cargo fmt as you'd do in Python with ruff (and Prek).
What compounded
Ten days is not enough to write production Rust. It is enough though to feel a new discipline take hold.
Four shifts stuck for me when I started learning Rust:
- Immutable by default. You stop reaching for
mutand start asking what actually needs to change and why. - Errors as values. The function signature is more explicit about what can fail.
- Exhaustive
match. Refactoring with more confidence, because there are no unhandled cases. - Borrowed vs owned data. You think about who can mutate data, and whether you need to own it or just borrow a view.
I hope your takeaway isn't the syntax, but the new mental models Rust gives you.
This only touched the surface of Rust though. There is so much more to learn: lifetimes, traits, generics, async, error handling, and more. I'll cover those in future challenges.
Now that you have enough to be dangerous, what do you want to build with Rust? Reach out to me on LinkedIn or send me an email.
The 10-day repo is here, with starter code and a solutions branch including commented walkthroughs.
If you want to go deeper, the next round will be a small project we build together over a couple of weeks. That is where it stops being exercises and starts being software. Details at scriptertorust.com.
What surprised you most? The comments are how I shape what comes next.
AI is an accelerator, not a compass. I coach developers to ship faster and understand what they ship. See the path →