Rust Ownership: Moves, Borrows, Slices, and Builders
Master Rust ownership: moves vs Clone vs Copy, & vs &mut with NLL, slices, function transfers, and the builder pattern in one guide.
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
- ✓Rust toolchain 1.90 with cargo new project working
- ✓Comfortable with functions, structs, and cargo test basics
- ✓Willingness to compile failing examples on purpose
- Ownership has three rules: each value has one owner, ownership moves on assignment, and Drop runs when the owner goes out of scope
- Copy duplicates bits for stack-only types like i32, Clone runs code for heap types like String, and moves transfer heap buffers without copying a byte
- Borrows split access: many shared & readers or one &mut writer, never both live at once, and NLL ends borrows at last use instead of scope end
- Slices borrow views: &[i32], &str, and ranges like &v[1..4] reference data without owning it, with bounds checked at runtime
- Fix cannot-borrow-as-mutable with iter_mut in loops, split borrows of disjoint struct fields, and RefCell only as a last resort with runtime cost
- Builder pattern chains mutating methods returning Self so ownership flows through construction and lands in a validated value
Imagine every toy in a playroom has exactly one name tag showing who owns it right now. When you hand a toy to a friend, your name tag comes off and theirs goes on, so you cannot play with it anymore unless they hand it back. That is a move. Photocopying a coloring page is Clone, because now two identical pages exist and both kids can draw separately. Tracing your hand shape is Copy, because it is so quick and simple that everyone just does it without asking. Borrowing is asking to look at the toy without taking the name tag: many friends can look at once, but only one friend may change it at a time, and nobody may change it while others are looking. The builder pattern is like ordering a sandwich step by step, where each choice hands the half-built sandwich back until you say done and take the finished meal.
Every Rust beginner meets the borrow checker the same way. The code looks correct, the logic is sound, then rustc refuses it with cannot borrow as mutable and points at two lines that seem unrelated. You've shuffled ampersands for an hour and the error just moves. It's not your logic that's wrong. It's your mental model of who owns what, and the compiler is the only teammate honest enough to say so.
That friction is the whole point of the language. A C++ service double-frees once a quarter and nobody knows until production crashes. A Go service shares a map across goroutines and races under load. You'll dodge both classes entirely once ownership clicks, because the compiler proves memory safety before your binary ever runs. The pain you feel today is the outage you don't get paged for next year.
Here's the deal: ownership fits in three rules, borrowing fits in one sentence, and everything else is practice. One owner per value. Moves transfer that ownership. Borrows either share widely or mutate exclusively, never both. Non-lexical lifetimes end each borrow at its last use. Get those four ideas straight and every diagnostic starts reading like instructions instead of insults.
Don't expect abstract theory here. You'll move Strings and watch the old binding die, derive Clone where duplication is honest and Copy where bits allow it, slice vectors and text without allocating, push values through functions and catch them on return, then fix the classic mutable-borrow-in-a-loop three different ways. Each pattern comes with code you can compile and break on purpose.
By the end you'll read borrow errors the way seniors do. E0382 means use-after-move, so clone or restructure. E0499 means two writers, so split or sequence the borrows. E0502 means reading while writing, so end one borrow first. You'll also build a validated struct with the builder pattern, which is where ownership stops feeling like rules and starts feeling like design.
Ownership in Three Rules: Move Semantics You Can Predict
Ownership answers one question: when does each value die. Rust assigns exactly one owner binding to every value. Assigning the value to another binding or passing it to a function moves ownership there, and the previous binding stops compiling if you touch it. When the owner leaves its scope, Rust drops the value and frees its resources in reverse declaration order. Those three sentences explain every E0382 you will ever see. Internalize them and the compiler stops feeling adversarial.
Moves are cheap by design. A String holds a pointer, length, and capacity on the stack plus a heap buffer elsewhere. Moving it copies those three words and marks the source dead; the heap bytes never move. That is why functions that take String feel strict: they accept the buffer and promise to free or forward it. Passing &str instead borrows the bytes without touching ownership. Choose by intent: take ownership when the callee must keep or destroy the data, borrow when it only inspects.
Scope discipline turns drops into a feature. Values die in reverse order at the closing brace, temporaries die at statement end, and early return drops everything declared so far. That determinism replaces finally blocks and close calls: file handles flush, mutex guards unlock, and sockets shut down exactly when their owner exits. Design with that in mind by binding guards to names instead of discarding them. A lock guard bound to _ unlocks immediately, which surprises everyone exactly once.
Shadowing differs from moving and confuses newcomers. let s = String::from("a"); let s = s.len(); reuses the name with a new type and the old String drops at that point. The name lives on while the value died on schedule. Moves keep the value alive under a new owner; shadowing ends one value and starts another under the same name. Read diagnostics literally: moved here names the new owner, value dropped here names the scope exit. Both tell you where ownership went.
Vectors and structs move the same way scalars do, with larger consequences. Pushing a String into a Vec moves it; the local binding dies even though the bytes survive inside the collection. Returning a Vec from a builder moves the whole buffer to the caller without copying elements. Cloning the Vec before the move keeps a duplicate at full allocation cost. Trace each large value from construction to drop on paper once per week until the paths feel obvious. That exercise builds the instinct faster than any chapter.
Function boundaries are where moves bite most. A helper that takes Config by value consumes the caller's copy, so calling it twice without clone fails the second time. The fix is rarely Clone; it is usually a borrow or a split that passes only the needed field. Audit signatures during review: by-value parameters must justify ownership with storage or destruction. Everything else borrows. That single review habit removes most use-after-move errors before they reach CI.
Practice deliberately with small programs that print drops. Implement a struct with a Drop that logs its name, move instances through two functions, and watch the log order match scope exits. Then trigger E0382 on purpose by reading the moved binding and fix it three ways: borrow, clone, and return-back. Ten minutes of intentional breaking teaches more than an hour of careful reading. The compiler is a patient tutor when you ask it to fail.Trace moves with the compiler as your lab partner. Compile with cargo check, read the moved here note as a forwarding address, and follow it to the new owner before changing anything. Annotate three moves per file with one-line comments naming the owner transfer for a week. That annotation habit makes invisible transfers visible until your eyes track them automatically. Teams that label moves in complex handlers cut review questions about lifetimes in half within one sprint.
Ownership reviews belong in every pull request template. Ask whether each by-value parameter stores or destroys, whether each clone survived a size check, and whether each returned value moves or borrows. Three questions, two minutes, most borrow bugs caught before CI. Reviewers who ask them consistently produce codebases where E0382 appears in learning exercises, not in production tickets.
Clone vs Copy: Honest Duplication Against Silent Bit Copies
Clone and Copy both duplicate, but they promise opposite things. Clone is an explicit method call that may allocate, run user code, and take measurable time. Copy is a compiler marker for types whose bits can duplicate freely: integers, floats, bools, chars, and tuples or arrays composed only of Copy members. Copy happens silently on assignment; Clone never happens unless you type .clone(). That asymmetry is deliberate. Expensive duplication should always be visible in the source.
Deriving guides both. A struct with only Copy fields can derive Copy plus Clone, gaining silent duplication for small values such as coordinates or handles. Adding a single String field removes Copy eligibility and the compiler says so immediately. That error is a design nudge: heap owners must choose between moves and explicit clones. Wrapping the struct in Arc or splitting the heap field out are usually better answers than cloning the whole bundle per call.
Costs separate textbook examples from production incidents. Cloning a 240-byte request context 140 times per second costs nothing measurable. Cloning a 42 MB config at the same rate allocates 5.9 GB per second and OOM-kills pods within the hour. Measure with std::mem::size_of::<T>() before cloning in loops or handlers; the number takes 10 seconds to print and reframes the decision instantly. Anything above a few kilobytes in a hot path deserves a borrow or shared ownership instead.
References themselves are Copy, which surprises newcomers productively. Copying &String duplicates the pointer without touching the heap allocation or the owner. That is why functions can accept &str freely in tight loops: each borrow is a word-sized copy with no Drop implications. Arc<T> extends the idea across threads by bumping an atomic count on clone instead of duplicating data. Use &T within one scope, Arc<T> across threads, and Clone only when an independent owned duplicate must outlive the source.
Clippy enforces the distinction in CI. The clone_on_copy lint flags .clone() on Copy types where the call wastes characters without changing semantics. The large_enum_variant and large size thresholds flag types that grew past cheap-duplication limits. Enable them with deny warnings and oversized clones fail the pull request instead of the on-call rotation. Lints turn tribal knowledge about sizes into executable rules.
Generic code exposes the boundary through trait bounds. A function over T: Clone accepts owned duplication for any clonable type, while T: Copy restricts callers to bit-duplicatable ones. Prefer borrowing with AsRef<str> or Borrow<Q> for lookups so callers pass either owned or borrowed forms. That flexibility removes clones at call sites you never see. Library authors who bound on Clone when they could borrow force thousands of downstream allocations.
Practice by auditing one module for clones this week. Run grep for .clone() across src/, print size_of for each cloned type, and classify every hit as load-bearing or lazy. Convert lazy ones to borrows, wrap shared config in Arc, and benchmark the hot path with criterion before and after. Most modules lose half their clones in the first pass. The remainder carry comments explaining why duplication is correct.Count bytes before defending a clone in review. Attach the size_of output plus requests-per-second math to any hot-path .clone() so reviewers see cost, not just correctness. A 240-byte clone at 140 RPS costs 33 KB per second and deserves approval; a 42 MB clone at the same rate costs 5.9 GB per second and deserves a borrow. Numbers turn style debates into arithmetic. Reviewers approve arithmetic in seconds and argue taste for hours.
Teach Copy boundaries with a deliberately failing example. Add a String field to a Copy struct, read the compiler's cannot derive message, and discuss whether the type should split or share. That five-minute exercise prevents the quarterly argument about cloning large structs. Types that stay Copy stay small by construction, which is exactly the invariant you want enforced automatically.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Drop and RAII: Cleanup That Runs Itself on Schedule
Drop is Rust's destructor hook with a schedule you can trust. When an owner leaves scope, fields drop in reverse declaration order, then the owner's own drop code runs. Early returns, question-mark propagation, and panics all trigger the same unwinding path, so cleanup cannot be skipped by an forgotten branch. That guarantee replaces finally blocks: guards release locks, flush files, and close sockets exactly once, on every exit path, without explicit calls.
RAII patterns exploit that schedule for resources beyond memory. MutexGuard unlocks when its binding dies, which is why temporary guards are dangerous:Mutex::lock returns a guard that must bind to a name to survive the statement. Scoped threads join their closures, temp files delete themselves, and transaction guards roll back unless committed. Each type encodes its cleanup policy in Drop so callers cannot forget. Review any discard of a guard value as a likely bug.
Drop order shapes struct design. Declare fields in acquisition order so destruction reverses correctly: connection before statement handle, directory before lock file, buffer before flush guard. Self-referential fields are forbidden by the borrow checker precisely because move plus drop order would invalidate internal pointers. Split such designs into arena plus index or owner plus Rc with Weak back-links. Fighting the checker here means the design needs restructuring, not unsafe.
Manual drop has one idiom and several traps. std::mem::drop(value) moves the value into a function that does nothing, running Drop immediately at that statement. Calling a method named drop on the value does something else entirely and rarely compiles. After explicit drop the binding is dead, so later uses fail with E0382 by design. Use explicit drop to release a lock before heavy computation or to close a file before renaming it on Windows, where open handles block renames.
Leak and forget exist for rare cases. Box::leak creates a &'static reference by intentionally skipping Drop, useful for process-lifetime config. ManuallyDrop wraps values whose cleanup you schedule yourself, common in FFI and unsafe initializers. Both transfer responsibility to you permanently; leaked memory never frees and forgotten guards never unlock. Gate them behind comments naming the owner of the new obligation. Unjustified leaks in reviewed code should fail review.
Tests prove drop behavior cheaply. Wrap a counter in Rc<Cell<usize>>, implement Drop to increment it, move values through scopes and early returns, then assert counts. Panic paths deserve one test with should_panic plus counter verification during unwind. These tests run in milliseconds and document cleanup contracts that otherwise live only in prose. A guard without a drop test is a promise without evidence.
Production failures from Drop cluster around blocking destructors. A Drop that locks, sleeps, or performs I/O stalls unwinding threads and turns a quick scope exit into a 2-second stall. Keep destructors to pointer frees and flag sets; move blocking teardown into explicit async close methods callers await. Profile with flame graphs when exits feel slow: Drop frames at the top mean cleanup escaped its budget.Log drop order once with a named-guard program and keep the output in your notes. Watching b drop before a on every exit path, including early returns and ? propagation, converts the abstract rule into a memory. Refer to that log when designing structs with ordered resources. Acquisition order in declarations becomes destruction wisdom without further thought.
Keep destructors honest with a one-line policy: free pointers, set flags, never block. Post the policy beside your style guide and flag any Drop containing locks, sleeps, or I/O in review. Blocking teardown belongs in explicit async close methods with timeouts. A team that enforces the policy never profiles a mysterious 2-second stall inside scope exits.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Shared Borrows and NLL: Many Readers, Zero Cost
Shared borrows let many readers coexist with zero runtime cost. Each &T is a pointer plus a compile-time promise that nobody mutates through it while readers live. The checker proves that promise by tracking regions: a borrow starts at creation and ends at its last use, not at the closing brace. That end-at-last-use rule is non-lexical lifetimes, and it rescues patterns that older Rust rejected, such as conditional mutation after the final read. Reads followed by writes compile when the phases no longer overlap.
Function signatures encode borrow duration through lifetimes. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str ties the output to both inputs, forcing the caller to keep them alive. Elision covers the common shapes: one input lifetime flows to the output, methods tie &self to the return. Explicit annotations appear only when multiple inputs compete or structs store borrows. Read each signature as a contract about who keeps what alive, and call sites become self-explanatory.
Reborrowing keeps shared access ergonomic. Passing &*box_str to a function expecting &str, auto-deref on method calls, and match ergonomics on &Option<T> all create short derived borrows the checker tracks precisely. These reborrows end with their use, so chaining readers never extends the original borrow accidentally. Trust the desugaring: method call v.len() on &Vec borrows through the reference for exactly that expression.
Copy under shared borrows works because reading never invalidates. Copying an i32 out of a &[i32] leaves the slice untouched. Dereferencing &String to &str views the same bytes without ownership changes. Even cloning a single element clones from the shared view while the container stays borrowed immutably. Problems start only when a writer appears while readers live; pure reader graphs compose freely at any scale.
Diagnostics for shared borrows read as timelines. The error names where the borrow started, where it was last used, and where the conflicting write attempted. Fix by moving the last read earlier, the write later, or cloning the small read value so phases separate. Hoisting let n = v.len() above a push is the canonical repair. Each fix shortens overlap rather than adding syntax.
Interior patterns stay out of this section deliberately except as contrast. Cell, RefCell, and Mutex permit mutation through shared handles with runtime or locking costs. Pure &T needs none of that machinery: no counters, no locks, no panics. Reach for shared borrows first in every design, prove read-mostly access with them, and introduce interior mutability only where aliasing plus mutation is genuinely required.
Practice with read-then-write drills. Borrow a vector immutably for a search, use the result, then push based on it. Vary the order until E0502 appears, then repair by hoisting or scoping. Watch NLL accept reads whose last use precedes the write while rejecting truly overlapping phases. Five such drills build the timeline intuition that makes borrow errors feel mechanical.Sketch borrow timelines on paper for the first month. Draw each & span from creation to last use, mark NLL endpoints, and place writes in the gaps. Three sketches per week build the visual instinct that makes E0502 repairs obvious. Engineers who sketch resolve borrow conflicts at reading speed; those who guess resolve them at compiler speed, one error per rebuild.
Pair elision knowledge with one explicit-lifetime function per module. Writing a single fn select<'a> by hand teaches what the other twenty elided signatures assume. That ratio keeps annotations meaningful instead of noisy. Explicitness where it matters beats verbosity everywhere.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Mutable Borrows: One Writer, No Witnesses
Mutable borrows grant exclusive access with a hard rule: exactly one live &mut and zero live & to the same value. The checker enforces it by treating the &mut as a move of permission that returns when the borrow ends. While it lives, the original binding cannot be read, written, or moved. That strictness is what makes data-race freedom provable: no thread observes a half-written value because no observer can coexist with the writer.
Sequencing replaces nesting in most repairs. Two mutations that the borrow checker rejects usually overlap only because the first borrow lingers past its need. Ending the first borrow with a narrower scope or an earlier last use lets the second begin legally. Braces are the honest tool: wrap the first &mut phase in its own block so Drop and borrow end together. Explicit scopes document phases better than comments do.
Disjoint field borrows split what looks indivisible. Borrowing s.x and s.y mutably together is legal because the fields occupy different memory; borrowing s mutably twice is not. Methods that take &mut self borrow the whole struct, so splitting requires field-level access or free functions over parts. Design structs with independent fields grouped together so hot paths can borrow halves concurrently. Layout enables parallelism here.
Collection APIs encode splitting safely. split_at_mut divides a slice into two exclusive halves with no overlap possible. chunks_mut and iter_mut hand out one &mut per element per step. Entry APIs on maps borrow one bucket exclusively while leaving others reachable. These methods exist because raw indexing with two live &mut would be unsound. Prefer the API over index arithmetic whenever two writers operate near each other.
Reborrowing extends &mut ergonomically without forking permission. Calling a &mut method through an existing &mut reborrows a shorter borrow that returns on completion. Match ergonomics on &mut Option<T> binds the inner value mutably for exactly the arm body. These short reborrows compose: a loop can reborrow per iteration while the outer borrow spans the loop. Trust the short spans and keep outer borrows tight.
Across threads the rule hardens into Send plus Sync. &mut T is Send when T is Send, allowing transfer to another thread, but sharing &mut across threads needs a lock because aliasing plus mutation must serialize. Mutex and RwLock move the exclusivity check to runtime with blocking; channels move ownership between threads instead of sharing. Choose channels for pipelines, locks for shared state, and scoped threads for fork-join phases.
Drill the patterns until they feel boring. Write two &mut to one struct and repair with field splits. Write overlapping slice writes and repair with split_at_mut. Write read-plus-write and repair with hoisting. Each repair shrinks overlap or proves disjointness. Boring is the goal: exclusive access should feel as routine as semicolons.Rehearse the &mut exclusivity rule with scoped drills. Wrap each mutation phase in braces for a week even where NLL would accept narrower code. The visible phases train your sense of writer lifetimes until the braces feel redundant. Then remove them where the team agrees. Training wheels work when you plan their removal.
Split one real struct this month along its borrow halves. Measure contention before and after with a simple throughput test. A 2x gain on the first split convinces skeptics faster than any chapter. Disjoint design compounds: each split unlocks the next parallel phase.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Slices: Borrowed Views That Never Allocate
Slice types borrow contiguous views without owning a byte. &[i32] views elements, &str views UTF-8 bytes, and both carry length at runtime through fat pointers. Indexing with &v[1..4] or &s[..5] creates the view in O(1) by adjusting pointer plus length. No allocation happens, no Drop runs for the view, and the owner keeps freeing the buffer on schedule. That zero-cost window is why parsers, routers, and search loops pass slices instead of vectors.
Bounds checks protect every access. Slicing past the end panics with index out of bounds rather than reading adjacent memory, which turns C-style overflows into loud, catchable failures. Use .get(1..4) for an Option instead of panicking on untrusted input such as packet offsets or user ranges. Prefer first, last, split_at, and chunks over manual arithmetic; each method encodes its edge cases once so call sites stay clean. Panics on programmer error, Options on runtime input.
String slices add UTF-8 validity to the contract. &str always references valid Unicode, so byte indices must land on char boundaries or the program panics. Iterate with .chars() for scalar values, .char_indices() for byte offsets that stay valid, and .lines() for text protocols. Never slice user text by raw byte counts without checking is_char_boundary. Grapheme clusters need the unicode-segmentation crate because even chars split composed emoji.
Mutable slices power in-place algorithms. &mut [i32] sorts, rotates, and fills without reallocating, while chunks_mut and windows feed pipeline stages. sort_unstable skips allocation for 20% more speed when element order ties do not matter. Binary search through .binary_search on sorted slices finds entries in log time with zero hash overhead. These methods make slices the default working view for numeric and buffer code.
Lifetimes tie every slice to its owner. fn head(v: &[i32]) -> &[i32] elides one input lifetime to the output, promising the view dies before the vector. Structs holding slices declare the bound explicitly with struct View<'a> { data: &'a [u8] }. When the signature fights back, the data genuinely cannot outlive its source; return an owned Vec or Arc<[T]> instead. The compiler rejects dangling views at declaration time, not in production.
Patterns destructure slices ergonomically. Match on [first, rest @ ..] to name the head plus tail, [a, b] for exact pairs, and &[] for emptiness. Function parameters take &[T] for read-only views or Vec<T> by value only when ownership must transfer. Iterating with .iter().enumerate() yields index plus reference without moving elements. These shapes replace most index loops with declarative forms the checker verifies.
Drill slices with a tokenizer exercise. Write a function splitting &str on commas into Vec<&str> borrowing the input, then a second version returning owned Strings. Compare lifetimes, allocation counts, and test complexity. The borrowed version runs faster with lifetime annotations; the owned version composes freely with threads. Knowing both costs lets you pick per call site instead of per habit.Prefer borrowed views at every parser boundary and measure the allocation delta. Replacing Vec<u8> chunks with &[u8] windows typically removes one allocation per record, which at 90 K records per second is 90 K allocations saved. That number belongs in the pull request description. Zero-copy claims need numbers; numbers need benchmarks.
Document UTF-8 assumptions wherever byte indexing survives. A one-line comment naming why the index lands on a char boundary plus a debug_assert turns a future panic into a caught invariant. Text code without boundary notes rots as inputs diversify. Notes preserve the reasoning that tests only approximate.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Ownership Through Function Calls: Params That Take and Keep
Function signatures declare ownership transfer explicitly. A parameter of type String takes the buffer; &str borrows it; &mut String borrows exclusively; impl Into<String> accepts either owned or borrowed at a small generic cost. Callers read the transfer in the signature without opening the body. That readability is why review checklists start with parameter kinds: each by-value parameter must justify keeping or destroying what it receives.
Borrowing parameters compose across call chains. A validator taking &Config can run inside a handler holding &Config, which itself borrows from Arc<Config> owned by main. Lifetimes thread through automatically via elision when one input dominates. Multi-input functions annotate explicitly only when outputs could derive from either side. Chains of borrows cost nothing at runtime and free the caller to reuse the value after the call returns.
Mutable parameters centralize change. Helpers taking &mut Vec<Event> append without returning the collection, keeping ownership with the caller across ten pipeline stages. That shape beats return-the-collection style for long chains because intermediate moves vanish. Reserve &mut for genuine mutation; functions that only read through &mut signal confusion and block concurrent readers for no reason. Read the mut as a promise to change.
Impl Trait parameters widen call sites without overloads. Accepting impl AsRef<str> takes String, &str, and PathBuf uniformly, removing conversion clones at boundaries. Returning impl Iterator defers collection while keeping ownership inside. These abstract parameters monomorphize per caller, so binary size grows slightly while source shrinks. Use them at public APIs where caller ergonomics dominate; keep concrete types in private helpers where monomorphization would bloat.
Consumption patterns end chains deliberately. Builder finishers take self by value, Drop guards take ownership to schedule cleanup, and channel senders move messages into queues. Each documents finality: after this call nothing remains to use. Pair consuming calls with must_use on their return types so discarded results warn. Finality plus warnings prevents half-finished constructions from compiling silently.
Closures capture under the same rules with inferred precision. A closure borrowing its environment implements Fn, one mutating captures implements FnMut, and move closures take ownership for threads and async tasks. Pass closures as impl Fn(&Event) -> bool to keep bounds flexible. Misread captures cause the classic borrow errors inside iterators; naming the capture mode explicitly with move often resolves them by clarifying intent.
Audit one call chain this week for ownership flow. List each function from handler to store, mark every parameter as take, borrow, or mutate, and flag takes that never store. Convert unjustified takes to borrows and measure the allocation delta with a quick benchmark. Most chains lose two takes per pass. The remainder document exactly where data changes hands.Convert one unjustified take per week and record the saved allocations. A running tally in the team notes shows progress: twelve takes removed, 400 KB per second of memcpy gone, P99 down 15%. Visible tallies motivate the thirteenth conversion. Ownership hygiene compounds exactly like performance budgets.
Name consuming functions with verbs that signal finality: shutdown, seal, finish, into_inner. Callers reading seal(config) expect the move; callers reading process(config) expect a borrow. Naming alignment removes half of use-after-move surprises before compilation. Verbs are the cheapest documentation a signature can carry.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Return-Value Moves: Give Ownership Back Without Copying
Returning by value moves without copying. The callee constructs the String or Vec in its frame, then ownership transfers to the caller through return-value optimization paths that reuse the buffer directly. No bytes duplicate, no clone runs, and the local binding dies as the caller's binding is born. That is why factory functions return String freely instead of filling &mut out-parameters. Trust the move; it costs a pointer handoff.
Option and Result wrap moves for fallible construction. Returning Result<Config, Error> moves the success value or the error along the same channel, and the ? operator forwards both through layers without intermediate clones. Each propagation moves once per frame. Designs that cloned defensively at every ? pay per layer for safety the checker already proves. Move through the chain and clone only at the documented boundary.
Borrowed returns tie lifetimes to inputs instead. fn pick<'a>(a: &'a str, b: &'a str) -> &'a str promises the output lives within both inputs, so callers keep them alive. Elision writes the common case: one input lifetime flows out automatically. Multiple competing inputs need explicit annotations naming the source. Read each borrowed return as a custody agreement: the output borrows, the inputs guarantee.
Never return references to locals. A function creating String then returning &str points at freed memory, and the checker rejects it before it ever runs. Fix by returning the owned String, accepting an input buffer to fill, or leaking intentionally for static config with documented process lifetime. Each alternative names the true owner explicitly. Dangling prevention is the borrow checker's oldest guarantee.
Impl Trait returns hide construction while moving concretely. fn lines() -> impl Iterator<Item = String> moves the generator without naming its 200-character type. Async functions desugar similarly, moving futures to the caller for polling. These returns compose in chains that allocate once at collection. Keep the hidden types small and document complexity so callers size their buffers.
Must_use plus meaningful names prevent discarded returns. Marking #[must_use] on builders and parsers warns when callers forget the moved result. Naming the return site with let config = load()? instead of bare load()?; keeps ownership visible. Discarded moves are logic bugs the compiler will flag when asked. Ask it on every public constructor.
Exercise return shapes with a loader trio. Write one function returning String, one returning &str tied to input, and one returning Result<String, String>. Call each, observe which bindings survive, and trigger the dangling error deliberately with a local reference. Three functions in 20 minutes teach more about moves than a week of reading signatures.Return-shape drills pay off within days. Write the loader trio, misassign a borrowed return to a longer-lived binding, and read the lifetime diagnostic as a custody dispute. Resolving three such disputes teaches input-tied outputs permanently. Custody thinking replaces annotation guessing.
Envelope fallible construction in Result from the first prototype. Code that returns String today and needs errors tomorrow rewrites every call site; code starting with Result grows a variant instead. One generic now saves ten edits later. Fallibility is a design default, not a patch.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
cannot-borrow-as-mutable: Recipes That Fix It Every Time
This error means a shared borrow is alive where you need exclusivity. The diagnostic names both spans: the earlier borrow and its last use, plus the attempted mutation. Read those lines as a timeline before editing code. Most fixes shorten the first borrow, delay the second, or prove the two sides touch different memory. Memorize the three recipes below and 90% of occurrences resolve in minutes.
Recipe one reaches for iter_mut in loops. Code holding v.iter() or &items while pushing fails because the shared iteration borrows across the mutation. Switching to items.iter_mut() with *x updates grants one exclusive borrow per element per step, which the checker approves. Index loops work too when mutation needs positions: for i in 0..v.len() with v[i] writes borrows per access. Reserve Hir-level restructuring for cases where neither fits.
Recipe two splits borrows into disjoint pieces. Struct fields borrow independently, so (&mut s.cache, &mut s.stats) compiles where two &mut s fail. Slices divide with split_at_mut, maps with entry or get_mut on distinct keys proven sequential, and arrays with indexed access per iteration. Whole-struct methods taking &mut self defeat splitting; refactor them into field-level free functions when hot paths need halves. Disjointness is the proof the checker accepts.
Recipe three scopes or hoists to separate phases. Wrapping the shared phase in braces ends its borrows at the closing brace, freeing the value for mutation after. Hoisting reads like let n = v.len() above a push moves the last use earlier, which NLL honors. Cloning a small read value such as a flag or id breaks the dependency entirely for a few bytes. Prefer scoping for long reads, hoisting for single values, cloning for tiny scalars.
RefCell is the escape hatch with runtime cost and panic risk. It permits mutation through shared references by checking borrows at runtime, panicking on violations that the compiler would have rejected. Single-threaded UI trees and test doubles justify it; request handlers and shared caches do not. Each borrow_mut narrows to the shortest scope with explicit drop, and production code documents why compile-time borrowing failed. Treat every RefCell as tech debt with an expiry ticket.
Maps and sets need their own idioms. The entry API updates one key while holding no borrow on others: map.entry(k).or_insert_with(Vec::new).push(v). Retain with mutate-then-sweep phases instead of removing during iteration. DashMap or RwLock moves sharing across threads with documented locking budgets. Sequential entry updates never trigger the error; holding get results across inserts always does.
Build a personal drill set of five failing snippets and fix each three ways. Loop with iter versus iter_mut, struct double-borrow versus field split, read-plus-push versus hoist, map get-then-insert versus entry, and shared callback versus RefCell with narrow scope. Time yourself monthly. Seniors clear all five in under 15 minutes because the recipes are mechanical, not creative.Keep a personal gallery of five borrow failures with three fixes each. Revisit it monthly and time the repairs. Fifteen-minute clears mark senior fluency; thirty-minute clears mark solid progress. Galleries turn repeated errors into rehearsed responses.
Demote every RefCell with a dated comment explaining the blocker and the planned restructure. Quarterly reviews of those comments retire half of them as designs mature. Expiry dates prevent temporary glue from becoming load-bearing architecture. Runtime checks should shrink over time, not spread.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Builder Pattern Exercise: Ownership as a Design Tool
Builders turn ownership into a construction protocol. Each configuration method takes self by value, sets one field, and returns Self for chaining. The terminal build method consumes the builder and returns Result<Server, BuildError> after validating invariants. Intermediate states cannot be used as servers because the type system never gave them that type. Invalid configurations fail at build time with messages, not in production with panics.
Start from a struct with three required fields and two optional ones. Define ServerBuilder with Option wrappers for everything, implement with_ methods taking self mutably by value, and write build to check each required field with ok_or errors. Chain config in main across five calls and handle the Result with ?. The exercise takes 30 minutes and teaches more about moves than any diagram: every with_ visibly consumes and returns ownership.
Validation belongs in build, not in setters. Setters store raw values cheaply; build checks port ranges, non-empty hosts, and compatible flag combinations in one place. Return a dedicated error enum with a variant per violation so callers match precisely. Tests assert each violation independently plus one happy path. Centralized validation keeps setters trivial and guarantees every constructed Server passed the same gate.
Contrast with struct-update and Default patterns. Public-field structs allow any combination including invalid ones, and Default fills zeros that look valid but misbehave. Builders cost 40 lines of boilerplate and repay it the first time a misconfigured staging deploy is caught by build instead of by customers. Derive_builder or typed-state generics remove the boilerplate when the team adopts builders widely. Start manual, automate after the third builder.
Interior mutability has no place inside builders. Construction is single-threaded and sequential, so &mut borrows and owned moves suffice everywhere. RefCell in a builder signals a design error: shared ownership during assembly means phases overlap that should sequence. Restructure into stages with distinct types when assembly genuinely branches. Compile-time moves already sequence construction perfectly.
Type-state builders encode phases in generics for critical protocols. A TlsBuilder<NoCert> refuses handshake methods until with_cert transitions it to TlsBuilder<Ready>, moving ownership through each state. Invalid sequences fail compilation with missing-method errors instead of runtime checks. Apply this to payment flows and connection setups where order violations cost money. Standard builders validate values; type-state builders validate sequences.
Ship the exercise as a template for your team. Publish the builder with doc examples that compile as tests, add a CHANGELOG entry naming the validated invariants, and review the next three configs against it. Ownership stops being a hurdle when construction patterns channel it. Builders are where the borrow checker starts feeling like a collaborator.Publish your builder exercise as team scaffolding after the third use. Template the validated pattern with doc tests so new services inherit the gate. Three adoptions repay the extraction cost; ten make misconfiguration incidents history. Templates scale judgment that prose cannot.
Extend to type-state only where order violations cost real money: payments, TLS setup, migration runners. Elsewhere the standard builder's runtime validation suffices. Selective rigor keeps compile times sane while protecting the paths that fund the company. Match enforcement strength to failure cost.Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory. Keep a dated log of every ownership fix with the error code plus chosen recipe. A five-line entry per fix turns repeated borrow fights into rehearsed responses: E0382 means borrow or return back, E0499 means split or sequence, E0502 means hoist or scope. Future refactors then reference evidence instead of memory.
Tuesday 2 AM: Shared Config Clone Hid a 6 GB Leak, OOM Killed 9 Pods
config.clone() per request to satisfy the borrow checker quickly. Reviewers approved it as the standard fix for lifetime complaints. Nobody measured the 42 MB size because the struct grew over 8 months from 3 fields to 37, and sizeof crept up silently with each merged pull request.- Measure what Clone copies: a 42 MB config cloned 140 times per second allocates 5.9 GB per second and OOM-kills 9 pods. Prefer &T borrows or Arc<T> sharing, and reserve Clone for small values you sized deliberately.
- Never silence the borrow checker with .clone() to make lifetimes compile. Restructure the function to borrow, split the struct, or wrap sharing in Arc, then prove allocation cost with a benchmark.
- Gate costly clones in CI with size-aware lints plus allocation benchmarks. A 4 KB per-request budget fails the pull request in seconds instead of paging on-call at 2 AM.
name.clone() when duplication is honest. Verify with cargo check then cargo test --locked. Choose borrow for reads, Clone only when the duplicate must outlive the call.v.len(); v.push(x). In loops collect indices first, mutate after. Restructure so shared and exclusive phases never interleave.items.iter_mut() { *x += 1; }, or index without holding slices: for i in 0..v.len() { v[i] += 1; }. For maps use entry API or values_mut. Verify the loop compiles then run cargo test with --test-threads=1 if shared files are involved.| File | Command / Code | Purpose |
|---|---|---|
| src | struct Report { | Ownership in Three Rules |
| src | use std::mem::size_of; | Clone vs Copy |
| src | use std::cell::Cell; | Drop and RAII |
| src | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { | Shared Borrows and NLL |
| src | struct Pair { | Mutable Borrows |
| src | fn head(v: &[i32]) -> &[i32] { | Slices |
| src | struct Config { | Ownership Through Function Calls |
| src | fn build_greeting(name: &str) -> String { | Return-Value Moves |
| src | use std::collections::HashMap; | cannot-borrow-as-mutable |
| src | struct Server { | Builder Pattern Exercise |
Key takeaways
Common mistakes to avoid
7 patternsCloning to silence the borrow checker instead of restructuring
Holding a shared borrow across a mutation point in loops
iter() while pushing, with engineers adding full-collection clones that double latency.Borrowing the whole struct when disjoint fields would split
Returning references to function-local data
Using RefCell as a default container instead of last resort
Binding lock guards to _ and dropping them instantly
Slicing strings by raw byte indices on user input
Interview Questions on This Topic
What are the three ownership rules and how does a move differ from a copy?
Frequently Asked Questions
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
That's Core. Mark it forged?
27 min read · try the examples if you haven't