The roadmap
We go from hello world to an async web service with a database in 16 lessons, and every lesson ends with a program you write by hand. Each phase builds the muscle the next one needs: you cannot write a sane async server until ownership and traits feel natural, so we don't rush there.
| # | Lesson | What you build | Go concept it replaces |
|---|---|---|---|
| 1 | Toolchain, basics, tests (today) |
greet + fizzbuzz with tests |
go mod, go test, gofmt, go vet
|
| 2 | Ownership, borrowing, slices, String vs &str
|
Word counter | GC + pointers |
| 3 | Structs, enums, match, Option
|
Stack data structure | structs, nil, iota |
| 4 | Error handling: Result, ?, custom errors |
Base64 encoder/decoder |
if err != nil, errors.Is
|
| 5 | Traits and generics | Generic stack + Display/Iterator impls |
interfaces, generics |
| 6 | Collections and iterators | Log file analyzer |
for range, maps, slices |
| 7 | Modules, crates, workspaces | Split the base64 tool into lib + bin | packages, internal/
|
| 8 | Lifetimes in depth | Zero-copy tokenizer | escape analysis |
| 9 | Real CLIs: clap, anyhow, thiserror
|
grep-like CLI tool |
flag, cobra
|
| 10 | Threads: std::thread, Arc, Mutex, channels |
Parallel file hasher | goroutines, sync, chans |
| 11 |
Send/Sync and fearless concurrency |
Thread pool from scratch |
sync.WaitGroup, worker pools |
| 12 | Raw HTTP over TcpListener
|
Tiny HTTP/1.1 server by hand |
net package |
| 13 | Async Rust: futures, tokio
|
Async chat server | goroutines + select
|
| 14 | Web with axum
|
JSON REST API |
net/http, chi/gin |
| 15 | Databases with sqlx + Postgres |
CRUD service |
database/sql, pgx |
| 16 | Production polish: tracing, config, testing, CI | Ship the CRUD service |
slog, testing, CI |
How each lesson works: I explain the concepts, you write the exercise yourself and paste your code here, then I review it like a senior engineer would on a PR. I give hints before answers, and a full solution only if you ask for one.
1. Setup and tooling
Install Rust with rustup, the official toolchain manager. It is like having go plus a version manager in one tool.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup component add rustfmt clippy # usually installed by default
rustc --version && cargo --version
The pieces you get:
| Tool | What it does | Go equivalent |
|---|---|---|
rustup |
Installs and switches toolchains (stable, beta, nightly) | go install golang.org/dl/... |
rustc |
The compiler. You almost never call it directly | go tool compile |
cargo |
Build, run, test, deps, docs, publish |
go command |
rustfmt (cargo fmt) |
The one true formatter | gofmt |
clippy (cargo clippy) |
700+ lints for bugs and non-idiomatic code |
go vet + staticcheck
|
rust-analyzer |
Language server for your editor | gopls |
Install the rust-analyzer extension in your editor now. It shows inferred types inline, which is the single biggest help while learning.
Creating a project
cargo new hello_rust # binary crate (has main.rs)
cargo new mylib --lib # library crate (has lib.rs)
hello_rust/
├── Cargo.toml # manifest: name, version, edition, dependencies (like go.mod)
├── Cargo.lock # exact resolved versions (like go.sum); commit it for binaries
├── src/
│ └── main.rs # crate root for a binary
└── target/ # build output; git-ignored
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2024"
[dependencies]
The edition is a Rust-specific idea. Editions (2015, 2018, 2021, 2024) let the language make small breaking syntax changes without breaking old code, and crates of different editions link together fine. Always use the latest for new code.
The commands you will type every day
| Command | What it does |
|---|---|
cargo check |
Type-checks without producing a binary. Fastest feedback; run it constantly |
cargo build / cargo build --release
|
Debug build (fast compile, slow code) / optimized build |
cargo run -- arg1 arg2 |
Build and run; everything after -- goes to your program |
cargo test |
Runs unit, integration and doc tests |
cargo fmt |
Formats all code |
cargo clippy -- -D warnings |
Lints, and treats warnings as errors (what CI should do) |
cargo doc --open |
Builds HTML docs for your crate and all its dependencies |
cargo add serde |
Adds a dependency to Cargo.toml |
Industry habit: before every commit run cargo fmt && cargo clippy -- -D warnings && cargo test. Clippy is a teacher, not a nag; read every lint it gives you, because each one links to an explanation.
2. Hello world, dissected
fn main() {
println!("Hello, world!");
}
Three lines, and four ideas worth understanding properly.
fn main() is the entry point. Like Go's func main() but with no package main line: the crate root file (src/main.rs) plays that role. main can also return Result<(), E>, which we will use from Lesson 4 so errors exit with a non-zero code.
println! is a macro, not a function. The ! marks a macro call. Macros run at compile time and generate code. That is why println! can take a variable number of arguments and check your format string at compile time: println!("{} {}", a) is a compile error, while Go's fmt.Printf only catches that in go vet. Formatting basics:
let name = "Syam";
let pi = 3.14159;
println!("Hi {name}"); // inline variable capture
println!("Hi {}", name); // positional
println!("{pi:.2}"); // 3.14, like %.2f
println!("{:?}", [1, 2, 3]); // Debug output, like %v
println!("{:#?}", some_struct); // pretty Debug, like %+v on multiple lines
eprintln!("to stderr"); // like fmt.Fprintln(os.Stderr, ...)
let s = format!("{name}!"); // like fmt.Sprintf
{} uses the Display trait (human-facing output) and {:?} uses Debug (developer-facing). A type must opt in to each one; we cover traits in Lesson 5.
Semicolons matter. They turn an expression into a statement. This sounds trivial, but it is the key to the next section.
The compile model. cargo build compiles your whole crate as one unit and produces a single native binary with no runtime and no garbage collector, so there is no GC pause and the binary starts instantly. The price is slower compiles than Go. That is why cargo check exists: it skips code generation and is usually several times faster.
3. Core basics, the Go programmer's way
Variables are immutable by default
let x = 5; // immutable binding, type inferred as i32
// x = 6; // compile error: cannot assign twice to immutable variable
let mut y = 5; // opt in to mutation
y += 1;
const MAX_USERS: u32 = 1_000; // compile-time constant, type always required
In Go everything is mutable and you trust yourself. In Rust, mut is a signal to the reader: "this value changes, watch it." Industry style is to make things mutable only when you need to; clippy and the compiler warn about unneeded mut.
Shadowing
let spaces = " "; // &str
let spaces = spaces.len(); // new variable, now usize; the old one is hidden
This is not mutation; it creates a new binding, and the type can change. It is idiomatic for transforming a value step by step (parse input, then validate it) without inventing names like spacesStr and spacesLen. Go forbids this in the same scope.
Scalar types
| Rust | Go | Notes |
|---|---|---|
i8 i16 i32 i64 i128 |
int8 … int64 |
i32 is the default integer |
u8 u16 u32 u64 u128 |
uint8 … uint64 |
u8 is a byte |
isize / usize
|
int / uint
|
Pointer-sized. Indexes and lengths are always usize
|
f32 / f64
|
float32 / float64
|
f64 is the default |
bool |
bool |
|
char |
rune |
4 bytes, one Unicode scalar value |
Two differences that bite Go programmers:
-
No implicit conversions, ever. Not even
i32toi64. You writex as i64for a lossy/primitive cast, ori64::from(x)for a lossless one. Preferfrom/try_fromin real code becauseassilently truncates (300_i32 as u8is 44). -
Integer overflow is a bug, not a feature. In debug builds
u8::MAX + 1panics; in release it wraps. When you want a specific behaviour say so:checked_add(returnsOption),wrapping_add,saturating_add.
Compound types
let t: (i32, &str, f64) = (1, "two", 3.0); // tuple, fixed size, mixed types
let (a, b, c) = t; // destructuring
let first = t.0;
let arr: [i32; 3] = [1, 2, 3]; // fixed-size array, like Go's [3]int
let zeros = [0u8; 1024]; // 1024 zero bytes
The growable list (Go's slice) is Vec<T>, and the empty tuple () ("unit") is what functions return when they return nothing.
Functions, and the big idea: everything is an expression
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon: this is the return value
}
Parameter and return types are always written; Rust never infers function signatures, so a signature is a contract you can read. The last expression in a block, written without a semicolon, is the block's value. Add a ; and it becomes a statement that evaluates to (), giving you the classic beginner error expected i32, found (). Use return only for early exits.
Because blocks are expressions, if and match produce values. Rust has no ternary operator because it doesn't need one:
let name = if args.len() > 1 { &args[1] } else { "world" };
Control flow
// loop: infinite loop that can return a value via break
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2;
}
};
// while
while counter > 0 {
counter -= 1;
}
// for over a range: 1..5 is 1 to 4, 1..=5 includes 5
for i in 1..=5 {
println!("{i}");
}
// for over a collection, like `for _, v := range items`
for item in &items { /* borrow each */ }
for (i, item) in items.iter().enumerate() { /* like `for i, v := range` */ }
There is no C-style for i := 0; i < n; i++. Ranges and iterators replace it, and they remove off-by-one and out-of-bounds bugs.
match: switch with superpowers
fn describe(code: u16) -> &'static str {
match code {
200 => "ok",
301 | 302 => "redirect",
400..=499 => "client error",
500..=599 => "server error",
_ => "unknown",
}
}
match must be exhaustive: the compiler refuses to build if a case is missing, which is why _ (the catch-all) exists. Unlike Go's switch, there is no fallthrough and arms are expressions. This becomes enormously powerful with enums in Lesson 3.
Ignore the &'static str for now. It means "a reference to a string that lives for the whole program", which is what string literals are. Lesson 2 is entirely about what & means.
4. Testing from day one
Rust's convention is to put unit tests in the same file as the code, inside a tests module that only compiles under cargo test. Go puts them in a separate _test.go file; Rust keeps them next to the code they check.
fn greeting(name: &str) -> String {
format!("Hello, {name}!")
}
#[cfg(test)] // compile this module only when testing
mod tests {
use super::*; // bring the parent module's items into scope
#[test]
fn greets_by_name() {
assert_eq!(greeting("Syam"), "Hello, Syam!");
}
#[test]
#[should_panic]
fn overflow_panics_in_debug() {
let x: u8 = 255;
let _ = x + std::hint::black_box(1);
}
}
-
assert_eq!(left, right)andassert_ne!print both values on failure;assert!(cond, "message {x}")for anything else. There is not.Errorf: a failing assertion panics, and the test runner catches it. - Tests run in parallel threads by default, like
t.Parallel()everywhere. -
cargo test greetsruns only tests whose name containsgreets, likego test -run. -
cargo test -- --nocaptureshowsprintln!output, which is hidden for passing tests. - Unit tests can call private functions, because the
testsmodule is a child of the module under test. Integration tests (Lesson 7) live in a top-leveltests/directory and only see your public API, like Go'spackage foo_test.
5. Your exercise: fizzbuzz CLI
Build a small CLI that takes a number and prints FizzBuzz up to it, with tests, and passes fmt and clippy cleanly. Write it yourself, no AI autocompletion, and keep the compiler's error messages open: reading them is half the skill.
cargo new fizz
cd fizz
cargo run -- 15 # prints 1, 2, Fizz, 4, Buzz, ... FizzBuzz
cargo run # prints to stderr: usage: fizz <N> and exits with code 1
cargo run -- abc # prints to stderr: error: 'abc' is not a valid number exit code 1
Requirements
- [ ]
fn fizzbuzz(n: u32) -> String: "Fizz" for multiples of 3, "Buzz" for 5, "FizzBuzz" for both, otherwise the number. Implement it with a singlematch, not an if/else chain. - [ ]
fn sum_of_multiples(limit: u32) -> u64: the sum of all numbers belowlimitdivisible by 3 or 5 (for 10 it is 23). Note the return type is wider than the input; convert withoutas. - [ ]
mainreads the first argument withstd::env::args(), parses it tou32, and handles a missing or invalid argument as shown above usingeprintln!andstd::process::exit(1). - [ ] After the FizzBuzz lines, print
sum of multiples below N: X. - [ ] At least 6 unit tests, including edge cases: 0, 1, 3, 5, 15, and a large number for
sum_of_multiples. - [ ]
cargo fmt --check && cargo clippy -- -D warnings && cargo testall pass. - [ ] No
mutyou don't need, and nounwrap().
Hints (read only when stuck)
- You can
matchon a tuple:match (n % 3, n % 5) { (0, 0) => ..., ... }. Think about which arm order is correct and what_patterns do inside a tuple. - To turn a number into a
String, look upto_string(). Notice every arm of amatchmust have the same type. -
std::env::args().nth(1)returns anOption<String>, which is eitherSome(value)orNone. You canmatchon it exactly like au16above. -
"42".parse::<u32>()returns aResult<u32, ParseIntError>: eitherOk(n)orErr(e). Also handled withmatch. We go deep on both in Lessons 3 and 4; for today pattern matching is enough. - For
sum_of_multiples,u64::from(x)widens au32losslessly. Write it first with aforloop and amuttotal, then, as a stretch goal, as one iterator chain usingfilter,mapandsum(look them up incargo doc --openor doc.rust-lang.org/std).
Top comments (0)