A practical foundation for writing Rust apps well from the first commit. Not a textbook. Focuses on the differences from other languages, the day-1 decisions that shape everything else, and the small set of crates that cover most real apps.
anyhow vs thiserror)Cargo.toml, clippy, and rustfmtbash
# 1. Install the toolchain (rustup is the toolchain manager)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 2. Confirm components (rustfmt and clippy ship with stable, rust-src enables IDE features)
rustup component add rustfmt clippy rust-src
# 3. Create a project
cargo new my-app # binary (src/main.rs)
cargo new --lib my-lib # library (src/lib.rs)
# 4. The dev loop (memorize these four)
cargo check # fast type-check, no codegen
cargo run # build and run (binary)
cargo test # build and run tests (incl. doctests)
cargo clippy --all-targets # lint, incl. tests/ and benches/ (run before pushing)
cargo fmt # format
# 5. Manage dependencies without editing Cargo.toml by hand
cargo add tokio --features full
cargo remove tokio
cargo update # recompute Cargo.lock within existing semver ranges
cargo update only moves within the version ranges already in Cargo.toml. Crossing a major version ( 1.x to 2.0) needs a Cargo.toml edit or cargo add <crate>@2.
rust-analyzer is mandatory. It is the language server every editor uses (VS Code, Zed, Neovim, Helix, RustRover uses its own engine but is comparable). In VS Code, install the rust-analyzer extension and set rust-analyzer.check.command to "clippy" so you get lint feedback on save.
Want a file watcher later? cargo install --locked bacon, then run bacon in your project. Not needed on day 1.
Rust trades two things you take for granted in most languages (a garbage collector and exceptions) for compile-time guarantees about memory, data races, and error handling. The shape of the language follows from that trade.
Think of values like physical objects. A book, a file, a network connection. At any moment, one variable owns it. You can:
let b = a; hands ownership to b. a is gone.&a lets others look at it. Many readers allowed.&mut a lets one person modify it. Exclusive access.a.clone() makes a deep copy. Both keep their own.When the owner goes out of scope, the value is dropped (memory freed, file closed, lock released). No GC, no manual free. This is RAII, enforced by the compiler.
At any moment, a piece of data has either:
&mut T), or&T),never both. This single rule is what eliminates data races and most use-after-free bugs. The borrow checker enforces it. When it complains, it is telling you your data ownership story is unclear, not that the language is being difficult.
There is no try/ catch. Functions that can fail return Result<T, E>. Functions that can return nothing useful return Option<T>. The compiler forces you to handle both. The ? operator propagates errors up the call stack with one character:
rust
fn read_config() -> Result<Config, anyhow::Error> {
let text = std::fs::read_to_string("config.toml")?; // ? = early-return on Err
let config = toml::from_str(&text)?;
Ok(config)
}
There is no null. Option<T> is None or Some(value). The compiler will not let you forget the None case.
A trait defines behavior. Types impl traits. So far so familiar. The differences:
fn f<T: Display>(x: T), the compiler generates a separate copy of f for each concrete T you call it with (monomorphization, like C++ templates). Zero runtime overhead.dyn Trait (typically Box<dyn Trait> or &dyn Trait). One vtable lookup per call.Deref to "extend" a type, stop and use composition or an enum.impl YourTrait for SomeoneElsesType or impl SomeoneElsesTrait for YourType, but not both foreign. This keeps dependency resolution sane.The most common newcomer mistake is treating compiler errors as obstacles to silence. They are not. Almost every borrow-check error reveals a real issue with who owns what. When you get stuck, the question is rarely "how do I make this compile" and almost always "what is the actual ownership relationship I want here?" Read the error. The compiler is unusually informative.
Before writing a function, ask: does it need to own, read, or modify the input?
rust
fn consume(s: String) // owns: function takes responsibility, caller loses it
fn read(s: &str) // reads: function looks at it, caller keeps it
fn modify(s: &mut String) // mutates: function changes it in place
Defaults that work 90% of the time:
&str over String, &[T] over Vec<T> (these are slices, accept both owned and borrowed callers).String, Vec<T>). Returning references means lifetimes; avoid until you need them.String, Vec<T>). Storing &str in a struct is the single most common newcomer trap and it cascades lifetime annotations through every type that holds your struct.One-line answers to the choices that come up first.
DecisionDefaultWhen to pick the otherString vs &str (struct field)StringAlmost never &str until you have a real reason and understand lifetimesString vs &str (function param)&strUse String only if you must own/store it insideVec<T> vs &[T] (param)&[T]``Vec<T> only if you must ownBox<T> vs Rc<T> vs Arc<T>``Box<T> (single owner, heap)Arc<T> for shared ownership across threads. Avoid Rc<T> as default; use Arc<T> so you do not refactor when you go asyncRefCell<T> vs Mutex<T>``Mutex<T> (or RwLock<T>)Same reason: works in async/threads, while RefCell does notOption<T> vs Result<T, E>``Option<T> for "no value", Result<T, E> for "failed for a reason"If the absence carries meaning the caller should handle, Result``dyn Trait vs impl Trait / <T: Trait>Generic ( <T: Trait> or impl Trait) - static dispatchBox<dyn Trait> when you need a heterogeneous collection ( Vec<Box<dyn Animal>>)Errors in app codeanyhow::Result<T> everywhere-Errors in library codethiserror-derived enumNever Box<dyn Error> in public library APIs - forces callers to downcast&self vs &mut self vs self``&self for getters, &mut self for setters, self for builders/consuming ops-Module layoutInline modules until a file gets long, then splitOne module = one file is a Java/C# instinct, not a Rust one
These appear in nearly every Rust program. Learn them in week 1.
? for error propagation. Replaces nine lines of match with one character.
rust
let body = reqwest::get(url).await?.text().await?;
Iterator chains over manual loops. Compile to the same machine code as hand-written loops (LLVM inlines closures). Idiomatic Rust is functional in style.
rust
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
match exhaustiveness. Add a new variant to an enum and every match that does not handle it becomes a compile error. Use this. It is one of the most powerful refactoring tools in any language.
Let the compiler drive refactors. The same trick works on structs: add a mandatory field with no Default and every existing constructor becomes a compile error - a free, exhaustive worklist of every site to update.
if let and let else for the common single-arm match.
rust
if let Some(name) = user.name { println!("hi {name}"); }
let Some(name) = user.name else { return Err(anyhow!("no name")); };
// `name` is in scope from here on, no nesting
From / Into for type conversions. Implement From, get Into for free. ? uses From to convert error types automatically.
Combinators on Option / Result. Reach for .map, .and_then, .unwrap_or, .unwrap_or_else, .ok_or before reaching for match.
Derive macros. #[derive(Debug, Clone, PartialEq)] gets you 80% of the boilerplate for free. Add #[derive(Serialize, Deserialize)] for JSON.
Recent sugar. Stabilized features worth knowing, newest first: bool::ok_or and ok_or_else (1.98) turn a condition straight into a Result ( ready.ok_or(Error::NotReady)?). cfg_select! is a compile-time match over cfg predicates, replacing most uses of the cfg-if crate, and if let guards work on match arms ( match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... }) - both 1.95. assert_matches! and debug_assert_matches! (1.96) assert on a pattern rather than equality, which is the natural assertion for an enum. Two older ones you will see constantly and should not mistake for exotic: let-chains ( if let Some(x) = a && x > 3 { ... }), stable in edition 2024 since 1.88, and async closures ( async || { ... }, with the AsyncFn family of bounds), stable since 1.85.
From Python or JavaScript:
let b = a; for a heap value (like String, Vec) moves it. a is no longer usable. Use &a to borrow or a.clone() to copy.Option<T> is forced on you.Result<T, E> and ?. The compiler will not let you ignore errors.mut to mutate. Same for references: & vs &mut.let input = input.trim(); shadows the old binding with a new one, possibly of a different type. It is not mutation, and it is how Rust code converts a value in place without inventing input2.usize. They also overflow differently depending on the build: debug "includes checks for integer overflow that cause your program to panic at runtime", while --release drops them and performs " two's complement wrapping". A test suite that passes can still wrap in production, so use checked_*/ saturating_*/ wrapping_* where the arithmetic can actually reach the edge, rather than relying on the debug panic to find it. strict_add and friends (stable since 1.91) panic on overflow in every build, and overflow-checks = true under [profile.release] keeps the debug behavior in production. And as between integer types silently truncates ( 300_u32 as u8 is 44) - use u8::try_from(x)? wherever the value might not fit.String is not indexable. s[0] does not compile - see references/ownership-and-types.md for what to reach for instead.From Go:
? instead of if err != nil.nil. Option<T>.tokio. The async model is cooperative ( await is an explicit yield point), not preemptive.interface{} becomes traits. Default to generics for static dispatch; Box<dyn Trait> only when you need it.panic! should be reserved for unrecoverable bugs in app code; do not use it as Go-style "log and continue".From Java or C#:
<T: Trait> is monomorphized. dyn Trait is the opt-in dynamic version.Option<T>.Result<T, E> and ?.Arc<T> is the closest thing to a Java reference.From C++:
Clone is explicit and Copy is a marker trait for cheap bitwise copies.& is a compile-time-checked borrow, not a raw pointer. Raw pointers exist ( *const T, *mut T) but require unsafe to dereference.Box<T> ( unique_ptr), Rc<T> ( shared_ptr, single thread), Arc<T> ( shared_ptr, thread-safe).serde, tokio::main, etc. work.These cover most real apps. Add them as needed; they are not all required.
CrateWhat it gives youserde + serde_jsonSerialization. #[derive(Serialize, Deserialize)] and you are donetokioAsync runtime. #[tokio::main], tokio::spawn, async I/OanyhowApp error type. anyhow::Result<T>, bail!, context()``thiserrorLibrary error enums. #[derive(thiserror::Error)]``clapCLI argument parsing. #[derive(Parser)] and you have a CLIreqwestHTTP client. Async by default, blocking feature availabletracing + tracing-subscriberStructured logging. The default for any async code (replaces log)axumWeb framework. Built on tokio + hyper + tower. The 2026 defaultsqlxDatabase access. Async, compile-time checked queries. PostgreSQL, MySQL, SQLitechronoDates and times. The maintainer announced soft-deprecation in Jan 2026 and recommends jiff for new code. jiff (BurntSushi) is the successor but still pre-1.0 as of September 2026 (0.2.x, with the 1.0 tracking issue open and no date). Pick chrono for serde/ sqlx integration today, jiff if you can tolerate pre-1.0 churn
See references/crate-shortlist.md for one minimal example each.
These are the mistakes that show up in every newcomer's code review. Avoid them.
&str (or any reference) in a struct. Causes lifetime annotations to cascade through every caller. Use String until you have a profiler-backed reason not to.Rc<RefCell<T>> (or Arc<Mutex<T>>) to simulate Python/JS object graphs. First ask whether you need shared mutable state at all - usually plain ownership, or passing data through a channel, is cleaner. When you genuinely do, prefer Arc<Mutex<T>> over Rc<RefCell<T>> so adding threads later is not a refactor.Box<dyn Error> in library public APIs. Forces callers to downcast. Define a typed error enum with thiserror. Box<dyn Error> is acceptable inside a binary, never in a published library..unwrap() and .expect() outside prototypes and tests. Use ? and propagate - for an Option, .ok_or_else(|| anyhow!(...))? does the conversion. When a value genuinely cannot be absent, prefer .expect("why it cannot fail") over .unwrap(): the message is the comment that documents the invariant..clone() until it compiles. Sometimes cloning is right, but if you are scattering .clone() to silence the borrow checker, the design is wrong. Step back and ask the 3 questions about who owns what.Deref. Deref is for smart-pointer-like wrappers, not for OOP-style "extends". Use composition.unsafe. App developers should essentially never need it. unsafe does not turn off the borrow checker; it lets you do five specific things (deref raw pointers, call unsafe functions, access mutable statics, implement unsafe traits, access union fields) with the contract that you have manually verified the invariants..lines() or read_line. These allocate without bound. BufRead::read_line's own docs warn that "it is possible for an attacker to continuously send bytes without ever sending a newline or EOF" - and .lines() inherits that behavior, since each item is a read_line under the hood. Either way a hostile or malformed peer can drive your process out of memory. Bound the read with Read::take(n), and use BufRead::skip_until (stable since 1.83) to discard an over-long line. for line in reader.lines() is the first thing every tutorial teaches and almost none mention this.std::fs::write with data you cannot lose. It truncates and then writes in place, so a crash or power loss mid-write leaves a zero-length or half-written file - and a successful return does not mean the bytes reached the disk. For state files and manifests: write to a temp file in the same directory, call File::sync_all(), rename it over the target, and on Unix also sync_all() the parent directory so the rename itself survives a reboot. ( tempfile::NamedTempFile::persist does the rename, not the syncs.)You do not need these on day 1. Some you may never need.
Pin, Future internals, manual poll impls. Just write async fn and .await.unsafe and FFI. Almost never for app code.for<'a>), variance, PhantomData. Expert territory.Cell, OnceCell, LazyLock, MaybeUninit. Reach for these when you have a specific reason.Single-crate, edition 2024, opinionated lints. Drop into a fresh project.
toml
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[dependencies]
[dev-dependencies]
[profile.release]
lto = "thin"
codegen-units = 1
# =============================================================================
# Lints. Loose-but-helpful: deny obvious bugs, warn on common smells, leave
# room to learn. Upgrade to clippy::pedantic later if you want the full ride.
# =============================================================================
[lints.rust]
# "deny", not "forbid": forbid cannot be lifted by a local #[allow] with a
# SAFETY comment, and that bites on ordinary mmap, not just FFI.
unsafe_code = "deny"
unreachable_pub = "warn"
[lints.clippy]
all = { level = "deny", priority = -1 }
# Idiomatic helpers
# Note: uninlined_format_args moved to clippy::pedantic (allow-by-default)
# during the 1.89/1.90 cycle, so an explicit warn keeps the nudge active.
uninlined_format_args = "warn"
semicolon_if_nothing_returned = "warn"
implicit_clone = "warn"
# Smells in non-prototype code
dbg_macro = "warn"
todo = "warn"
print_stdout = "warn" # use `tracing::info!` instead in real apps
When you do need to silence a lint, prefer #[expect(clippy::lint_name, reason = "why")] (stable since 1.81) over #[allow]: expect warns once the lint stops firing, so stale suppressions surface instead of piling up. Clippy's allow_attributes and allow_attributes_without_reason (both in the opt-in restriction group) enforce it. One catch: a lint that fires on only one OS needs #[cfg_attr(unix, expect(...))], or the expect is unfulfilled - and warns - everywhere else.
toml
style_edition = "2024"
edition = "2024"
That is enough. rustfmt's defaults are good. Some teams add use_small_heuristics = "Max" to keep more code on single lines. Fancy options like imports_granularity and group_imports are still nightly-only as of October 2026 (rustfmt tracking issues #4991 and #5083).
Run cargo fmt before you start editing (or commit any pre-existing drift on its own) so formatting noise stays out of your diff, and make cargo fmt --check its own CI step.
Pins the toolchain per project so everyone on the team uses the same Rust - but only with an exact version. channel = "stable" means "whatever stable this machine last installed", so two machines disagree silently, and a compiler cache keyed on the exact rustc never shares artifacts between them. Bump the pin deliberately (see references/dev-environment.md).
toml
[toolchain]
channel = "1.99.0"
components = ["rustfmt", "clippy", "rust-src"]
profile = "minimal"
The file is a rustup feature. A rustc from Nix, a distro package, or Homebrew ignores it, so on those machines you get whatever compiler is installed. Even under rustup it is not the top of the precedence list: a rustup override set for the directory (and RUSTUP_TOOLCHAIN, and cargo +toolchain) wins over it, so a forgotten override silently makes the file dead weight. rustup show tells you which toolchain is active and why; rustup override unset clears it.
text
/target
Commit Cargo.lock. cargo new tracks it, and the Cargo guide's advice is "When in doubt, check Cargo.lock into the version control system". The FAQ deliberately stops short of making that universal - "whether you do is dependent on the needs of your package" - but for an application it is unambiguously right, and committing it is now the ordinary default for libraries too.
text
my-app/
src/
main.rs # binary entry point: fn main()
lib.rs # OR a library crate root
config.rs # module: declared as `mod config;` in main.rs/lib.rs
api/ # nested module
mod.rs # OR `api.rs` next to api/ folder (2018+ style preferred)
users.rs
bin/ # extra binaries: src/bin/admin.rs -> `cargo run --bin admin`
examples/ # runnable examples: `cargo run --example demo`
tests/ # integration tests (each file is its own crate)
smoke.rs
Cargo.toml
Cargo.lock
rust-toolchain.toml
rustfmt.toml
.gitignore
Inline modules with mod { ... } until a file gets long, then split. Do not pre-split.
Everything is private by default, including to a parent module. mod config; makes the module exist; it does not make anything inside it reachable. That is the source of the most common early "why can't I call this" error, and the fix is a visibility keyword on the item (and on the module, if it is nested): pub exposes it to the outside world, pub(crate) exposes it only within your own crate. pub(crate) is the right default for anything that is not part of a library's published API - it is what lets the unreachable_pub lint in the table below tell you something useful.
The layout above is one crate. The moment you want a second - a shared library plus a CLI, a server plus its client - you need a Cargo workspace, along with the Cargo.toml machinery that goes with growing past a single crate: feature flags, build scripts, and what rust-version actually controls. That is references/project-shape.md.
actix-web while axum is the 2026 default; the patterns translate cleanly.For looking up syntax: Rust by Example ( https://doc.rust-lang.org/rust-by-example/).
For curated crate recommendations: blessed.rs ( https://blessed.rs/crates).
Detailed material lives in references/. Read each when you hit the topic.
String/ &str/ Cow, why a string is not indexable, slices, HashMap and the entry API, smart pointers, the self-referential struct trapResult, ?, anyhow vs thiserror patterns, wrapping at the boundary rather than before it, custom error enums, when panic! is appropriatedyn vs impl Trait vs generics, common derives, From/ Into/ Display/ Debug, closures and the Fn/ FnMut/ FnOnce family, blanket impls, the orphan rulempsc before async, tokio, #[tokio::main], .await, Send/ Sync, bounded concurrency, cancellation and graceful shutdown on SIGTERM, common pitfalls (blocking in async, MutexGuard across .await)[workspace.lints], feature flags, build.rs, what rust-version controls, #[non_exhaustive]target/ in check, platform-aware linker guidance, CI, file watchersdist vs a hand-rolled release-plz + cargo-zigbuild pipeline, [profile.dist], cross-compiling every target from one runner, fanning out to binstall/Homebrew/Nix, and guarding what the published crate contains