Free Handbook · Every example compiled & verified

Interview Questions

Sixty Rust interview questions — 25 junior, 25 mid-level, 10 senior — with model answers, what each is really testing, a coding round and a take-home checklist.

0 / 140 lessons🔥 0 day streak
ShareXLinkedIn

Module 15 · what you'll be able to do

  • Answer the 25 junior questions on ownership, borrowing, enums, error handling and collections that every Rust screen draws from
  • Explain lifetimes, traits and dyn, closures, smart pointers, Send and Sync, and async at mid-level depth
  • Reason through senior questions on unsafe and FFI, performance, API design, crate architecture and migrating a C++ or Go service
  • Run a live coding round in Rust with a repeatable script, and hand in a take-home that reviewers approve
01

How to use this module

Rust interviews follow a recognisable shape: a screen on ownership, borrowing and error handling (the ideas every Rust program depends on), a technical round on traits, lifetimes, iterators, smart pointers and concurrency, and — for experienced roles — a design conversation about async services, performance, unsafe code and API design. The questions below are grouped the same way.

  • Say the answer out loud before opening it. Recognising an answer when you read it is not the same as producing it under pressure.
  • Read the "what they are really testing" line. It tells you what the interviewer will follow up on, which is where most candidates lose points.
  • Back every claim with code you have compiled. The modules this draws on — Ownership & Borrowing, Error Handling, Closures & Iterators, Smart Pointers & Concurrency — have examples you can paste into your editor.
Quote the compiler
Rust interviewers love candidates who can name the error they would get: "that is E0502, a mutable borrow while a shared one is alive". It proves you have written real Rust, and it turns a theory question into a story about code you debugged. Module 11 covers the 15 most common ones.
02

Junior: ownership and borrowing — 6 questions

Asked in nearly every first-round Rust screen. Ownership is the idea the rest of the language is built on, so a vague answer here ends the interview early.

JuniorWhat are the three ownership rules?

(1) Every value has exactly one owner, a variable or a field. (2) There is only one owner at a time: assigning or passing a non-Copy value moves ownership, and the old name can no longer be used. (3) When the owner goes out of scope, the value is dropped: its destructor runs and its heap memory is freed. Those three rules replace both a garbage collector and manual free: the compiler knows at every point who is responsible for freeing each value.

What they are really testing: Whether they can state the rules precisely and connect them to the payoff — memory safety without a garbage collector.

JuniorWhat is the difference between a move, a copy and a clone?

A move transfers ownership: let b = a; with a String copies the three-word header (pointer, length, capacity) and makes a unusable, so the heap buffer still has one owner. A copy happens instead for types that implement Copy — integers, bool, char, shared references, tuples and arrays of Copy types — and both names stay valid. A clone is an explicit, possibly expensive deep copy: a.clone() allocates a new buffer. A type can be Copy only if it owns no heap data and has no Drop impl.

What they are really testing: That "move" is a cheap bitwise copy plus invalidation, and that clone is always visible in the code. Candidates who think a move copies the heap data are corrected here.

JuniorState the borrowing rules. Why does Rust allow many &T or one &mut T, but not both?

At any moment a value can have either any number of shared references (&T) or exactly one mutable reference (&mut T), and every reference must be valid for as long as it is used. The rule prevents data races at compile time, and also single-threaded bugs such as iterator invalidation: while you hold &v[0], v.push(x) could reallocate the vector and leave the reference dangling, so the compiler rejects it.

let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);              // error[E0502]: cannot borrow `v` as mutable
println!("{}", first);

What they are really testing: Whether they can give a concrete bug the rule prevents, not just recite it. The push-while-borrowed example is the one they hope to hear.

JuniorWhat is the difference between String and &str? Which should a function take?

String is an owned, growable, heap-allocated UTF-8 buffer. &str is a borrowed view of UTF-8 bytes that live somewhere else — inside a String, or in the program binary for a literal. A function that only reads text should take &str: it then accepts literals, &String (through deref coercion) and slices of either, with no allocation. Take String only when the function needs to keep or modify the text it is given.

What they are really testing: API taste. fn f(s: &String) or fn f(s: String) for a read-only parameter is the tell of someone who has not written much Rust.

JuniorWhat is a slice?

A slice is a reference to a contiguous run of elements owned by someone else: &[T] for any sequence, &str for text. It is a fat pointer — a pointer plus a length — so it knows its own bounds and indexing past the end panics instead of reading stray memory. &v[1..3] borrows part of a vector without copying, and a function taking &[T] works with arrays, vectors and other slices alike.

What they are really testing: That a slice borrows rather than copies, and why functions should accept slices.

JuniorHow does Rust prevent dangling references?

The borrow checker gives every reference a lifetime and rejects any program where a reference could outlive the value it points to. Returning &local from a function is a compile error (E0515 or E0106) because local is dropped when the function returns. The fix is to return an owned value (String instead of &str) or to borrow from a parameter so the result is tied to the caller’s data.

What they are really testing: Understanding that the check is static — there is no runtime cost and no garbage collector keeping the value alive.

03

Junior: structs, enums and pattern matching — 5 questions

Rust models data with structs and enums and takes it apart with match. These questions check that you use the type system to make bad states impossible instead of checking for them at run time.

JuniorRust has no null. What does it use instead, and why is that better?

Option<T>, an enum with two variants: Some(value) and None. Because Option<String> and String are different types, you cannot use a possibly-missing value as if it were present — the compiler makes you handle None with match, if let, ?, unwrap_or and friends. The null-pointer bug becomes a type error. It costs nothing at runtime for references and boxes: Option<&T> is the same size as &T, with None stored as the null bit pattern.

What they are really testing: That "absence" is tracked by the type system, and bonus points for the niche optimisation.

JuniorWhat does "match is exhaustive" mean, and why does it matter?

The compiler checks that every possible value of the matched type is covered by some arm; if not, it is a compile error (E0004) listing the missing patterns. The big win comes later: when someone adds a variant to an enum, every match that forgot it stops compiling, so the change cannot ship half-done. A catch-all _ => arm switches that protection off, so avoid it on your own enums unless "everything else" is really what you mean.

What they are really testing: Whether they see exhaustiveness as a refactoring tool, and know the cost of a lazy _ arm.

JuniorHow are Rust enums different from enums in C or Java?

Each variant can carry its own data, with different types per variant: enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 } }. That makes them sum types (tagged unions): a value is exactly one variant, and match destructures it and binds the fields. Option and Result are ordinary library enums built this way. Enums can also have impl blocks and implement traits.

What they are really testing: Whether they reach for enums to model states and messages, which is idiomatic Rust design.

JuniorWhen do you use if let, let else and while let instead of match?

if let Some(x) = opt { … } when you care about one pattern and ignore the rest. let Some(x) = opt else { return; }; when the unmatched case must leave the function (return, break, continue or panic) — it keeps the happy path unindented. while let Some(top) = stack.pop() { … } loops until the pattern stops matching. Use a full match when two or more cases need handling, so exhaustiveness checking still works for you.

What they are really testing: Fluency with the everyday pattern forms, and knowing that let-else must diverge.

JuniorWhat does #[derive(Debug, Clone, PartialEq)] do?

It asks the compiler to generate standard trait implementations from the type’s fields: Debug for {:?} printing, Clone for .clone(), PartialEq for ==. Every field must itself implement the trait. Derive when the field-by-field behaviour is right; write the impl by hand when it is not — for example equality that ignores a cache field, or Display, which can never be derived because there is no single right user-facing format.

What they are really testing: Knowing derive is code generation with a field-by-field rule, and when to write the impl yourself.

04

Junior: error handling — 5 questions

Rust has no exceptions, so every interviewer checks how you handle failure. Good answers show you know when to return a Result, when a panic is right, and how ? keeps the code flat.

JuniorWhen should code panic! and when should it return a Result?

Return Result for failures that are an expected part of the domain — a missing file, bad user input, a network timeout — so the caller decides what to do. panic! is for bugs: a broken invariant, an index the code itself guarantees is valid, a state that should be impossible. A library should almost never panic on input it does not control, because a panic takes the decision away from the application.

What they are really testing: The recoverable-versus-bug distinction, which drives every error-handling decision in Rust.

JuniorIs unwrap() always bad? When is expect() fine?

No. unwrap and expect are fine in tests, examples, prototypes, and in production where the value cannot be absent by construction — then expect("reason") documents why, and the message appears in the panic if the reasoning was wrong. Examples: "127.0.0.1".parse::<IpAddr>().expect("valid literal"), or a regex compiled from a constant. What is bad is unwrap on data from outside — input, files, the network — where failure is an ordinary event.

What they are really testing: Nuance. "Never use unwrap" is as wrong as using it everywhere; they want the reason attached to each use.

JuniorWhat does the ? operator do?

On a Result, expr? evaluates to the Ok value, or returns early from the function with the error — converted through From::from into the function’s own error type. On an Option it unwraps Some or returns None. It only works inside a function whose return type can carry that failure; using it in a function returning () is error E0277.

fn read_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    let port: u16 = s.trim().parse()?;
    Ok(port)
}

What they are really testing: That ? is early return plus From conversion, not magic — the From part is what makes custom error types compose.

JuniorHow do you convert between Option and Result?

opt.ok_or(err) or opt.ok_or_else(|| make_err()) turns None into Err; use the _else form when building the error allocates, so it only happens on the failure path. res.ok() throws the error away and gives an Option. res.err() keeps only the error. These let one function mix lookups (Option) and fallible operations (Result) and still use ? throughout.

What they are really testing: Practical fluency with the combinators that keep error handling flat.

JuniorCan main return a Result? What happens when it returns Err?

Yes: fn main() -> Result<(), Box<dyn std::error::Error>> lets you use ? directly in main. When it returns Err(e), the runtime prints Error: followed by the error’s Debug representation to stderr and exits with a non-zero status code (1). For user-facing tools, catch the error yourself and print it with Display for a friendlier message.

What they are really testing: Small but telling detail: it is Debug, not Display, which is why real CLIs often handle the error themselves.

05

Junior: language basics and tooling — 5 questions

Quick questions that show whether you have actually written and built Rust, or only read about it.

JuniorWhat is the difference between let, let mut and shadowing?

Variables are immutable by default: let x = 5; cannot be reassigned. let mut x allows reassignment and mutation, and the mut is a signal to readers that this value changes. Shadowing — let x = x.trim(); — declares a brand-new variable with the same name, which may have a different type; the old one is simply hidden. It is idiomatic for transforming a value step by step (let n: u32 = n.parse()?;) without inventing names like n_str.

What they are really testing: That shadowing creates a new binding and can change the type, while mut cannot.

JuniorWhat happens on integer overflow in Rust?

In a debug build, arithmetic overflow panics ("attempt to add with overflow"). In a release build the checks are off by default and the value wraps around in two’s complement. Neither is undefined behaviour, unlike signed overflow in C. When overflow is a real possibility, say what you want explicitly: checked_add (returns Option), wrapping_add, saturating_add or overflowing_add. A frequent special case is usize underflow: v.len() - 1 on an empty vector.

What they are really testing: That debug and release differ, and that the explicit methods exist. The usize underflow follow-up catches many candidates.

JuniorWhat do cargo check, cargo build, cargo run and cargo test do?

check type-checks and borrow-checks without generating machine code — the fastest feedback loop while editing. build compiles to target/debug (add --release for optimised code in target/release). run builds then runs the binary. test compiles the crate in test mode and runs every #[test] function plus the code examples in doc comments. Day to day you add cargo fmt and cargo clippy.

What they are really testing: Whether they have used the tooling daily, and know that benchmarks and timing belong on --release.

JuniorRust is "expression-oriented". What does that mean, and what does a trailing semicolon change?

Almost everything produces a value: if, match, blocks and loops (loop with break value). The last expression of a block, without a semicolon, is the block’s value — which is why functions often end with a bare expression instead of return. Adding a semicolon turns it into a statement whose value is (), and a function declared -> i32 ending in x + 1; fails with "mismatched types: expected i32, found ()".

What they are really testing: Reading the most common beginner compile error correctly.

JuniorWhat is a trait?

A trait is a named set of methods a type can implement — Rust’s version of an interface. impl Display for Point gives Point the behaviour println!("{}") needs. Traits can supply default method bodies, and they are used two ways: as bounds on generics (fn show<T: Display>(x: T), resolved at compile time) and as trait objects (Box<dyn Display>, resolved at run time). Operators, iteration, printing, cloning and thread safety are all traits in Rust.

What they are really testing: The basic mental model, and whether they know traits drive the whole standard library.

06

Junior: collections and strings — 4 questions

Choosing the right collection and handling UTF-8 text are daily work, and both have Rust-specific rules that trip up people coming from other languages.

JuniorWhat is the difference between an array, a Vec and a slice?

An array [T; N] has a length fixed at compile time and lives inline (usually on the stack). A Vec<T> owns a growable buffer on the heap: pointer, length and capacity. A slice &[T] borrows a run of elements from either. Use arrays for small fixed sets, Vec when the size is known only at run time, and slices as function parameters.

What they are really testing: Choosing the right one and knowing what lives where.

JuniorWhy does s[0] not compile for a String?

Strings are UTF-8, where a character takes one to four bytes, so "the character at index 0" cannot be found in O(1) and a byte index could land in the middle of a character. Rust refuses to guess (error E0277: the type str cannot be indexed by {integer}). Say what you mean instead: s.chars().next() for the first character, s.as_bytes()[0] for the first byte, or a range slice &s[0..3], which panics if the range does not fall on character boundaries.

What they are really testing: That they understand UTF-8, not just the workaround.

JuniorHow do you count occurrences with a HashMap?

With the entry API, which looks the key up once:

let mut counts: HashMap<&str, usize> = HashMap::new();
for w in text.split_whitespace() {
    *counts.entry(w).or_insert(0) += 1;
}
entry returns a handle to the slot, or_insert(0) fills it if empty and returns &mut usize, and *… += 1 increments through that reference. The alternative — get then insert — does two lookups and fights the borrow checker.

What they are really testing: The single most common map idiom in Rust code.

JuniorWhy does printing a HashMap give a different order on each run?

The default hasher (SipHash) is seeded randomly per process to resist hash-flooding attacks, and a hash map stores entries by hash, not by insertion or key order. Never rely on its iteration order. For sorted output, collect into a Vec and sort it, or use a BTreeMap, which keeps keys ordered at O(log n) per operation.

What they are really testing: That they will not write a test or a report that depends on hash order.

07

Mid-level: lifetimes — 4 questions

Lifetimes are the topic that separates people who have fought the borrow checker from people who have only read about it. The theory lives in Module 08.

Mid-levelWhat does <'a> in fn longest<'a>(x: &'a str, y: &'a str) -> &'a str mean?

It does not change how long anything lives. It is a contract: the returned reference is valid for at most the shorter of the two input borrows. The compiler needs it because the body could return either argument, and without the annotation the caller could not tell which input the result borrows from. At the call site, the borrow checker then refuses any use of the result after either input has been dropped.

What they are really testing: The key insight that lifetimes describe relationships between borrows, they do not extend them.

Mid-levelWhat are the lifetime elision rules?

Three rules that let you omit annotations in the common cases. (1) Each reference parameter gets its own lifetime. (2) If there is exactly one input lifetime, it is assigned to every output reference. (3) If one parameter is &self or &mut self, its lifetime is assigned to the outputs. If the rules leave an output lifetime undetermined — two reference parameters and no self — you get E0106 and must write it yourself.

What they are really testing: Whether they know why most code needs no annotations, and can predict when it will.

Mid-levelWhen would a struct hold a reference, and what does it cost?

When it is a short-lived view over data someone else owns — a parser over an input &str, an iterator over a slice. It needs a lifetime parameter, struct Parser<'a> { input: &'a str }, and the struct can never outlive that data, which spreads the lifetime through every type that contains it. For long-lived data — anything stored in a cache, sent to another thread or returned from a service — own the data (String, Vec, Arc<str>) instead. Zero-copy parsing is the main place borrowed structs pay off.

What they are really testing: Judgement: knowing borrowed structs are a performance tool with an ergonomic price.

Mid-levelWhat is the difference between &'static str and a T: 'static bound?

&'static str is a reference valid for the whole program — string literals, or leaked memory. T: 'static is weaker and more common: it says the type contains no non-static borrows, so a value of it may be kept for as long as its owner likes. Every owned type such as String or Vec<u8> satisfies it. That is why thread::spawn requires F: 'static: the thread can run arbitrarily long, so the closure may own data but not borrow from the caller’s stack.

What they are really testing: A classic confusion; the thread::spawn example shows they understand the practical meaning.

08

Mid-level: traits, generics and dyn — 5 questions

Traits are how Rust does polymorphism, and the choice between generics and trait objects shows up in every non-trivial codebase.

Mid-levelGenerics or trait objects: fn f<T: Shape>(s: &T) versus fn f(s: &dyn Shape)?

Generics use static dispatch: the compiler generates a copy of f per concrete type (monomorphisation), so calls can be inlined — fastest, at the cost of larger binaries and longer compiles. dyn Shape uses dynamic dispatch: one copy of f, calls through a vtable pointer stored beside the data pointer. Use dyn when you need different types in one collection (Vec<Box<dyn Shape>>), plugin-style extension, or to cut compile time; use generics in hot paths and when the concrete type is known.

What they are really testing: Understanding monomorphisation versus vtables, and being able to name a situation for each.

Mid-levelWhy can some traits not be used as dyn Trait?

A trait object must be callable through a vtable without knowing the concrete type, so the trait has to be dyn compatible (formerly called "object safe"). Broadly, its methods must not be generic over types, must not return Self by value, and the trait must not require Self: Sized. Clone is the famous example: clone() returns Self, whose size is unknown behind dyn. Work-arounds: mark the offending method where Self: Sized so it is excluded from the vtable, or add a fn box_clone(&self) -> Box<dyn Trait> method.

What they are really testing: Whether they have hit E0038 in practice and know the escape hatches.

Mid-levelWhen do you use an associated type instead of a generic parameter on a trait?

Use an associated type when each implementing type has exactly one natural choice: Iterator has type Item because a given iterator yields one kind of thing, and callers write I: Iterator<Item = u32>. Use a generic parameter when one type may implement the trait many times with different arguments: impl From<u8> for Big and impl From<u16> for Big can coexist. Associated types also make signatures shorter, because users need not name the type in every bound.

What they are really testing: Trait-design judgement beyond syntax.

Mid-levelWhat is the orphan rule, and how do you work around it?

You may implement a trait for a type only if the trait or the type is defined in your crate. It stops two crates from providing conflicting impls of, say, Display for Vec<u8>, which would make it impossible to combine them. The workaround is the newtype pattern: wrap the foreign type, struct Hex(Vec<u8>);, and implement the trait for the wrapper. The newtype costs nothing at runtime.

What they are really testing: Knowing the coherence reason, not just the rule, and the standard fix.

Mid-levelWhat are From, Into, AsRef and Default for?

From<T> defines an infallible conversion; implementing it gives you Into for free, and it is what ? uses to convert error types. TryFrom is the fallible version. AsRef<T> is a cheap reference conversion, so fn open<P: AsRef<Path>>(p: P) accepts &str, String and PathBuf. Default gives a type a zero value and enables ..Default::default() in struct literals. Implementing these standard traits is what makes a type feel native to other Rust code.

What they are really testing: Familiarity with the conversion traits that idiomatic APIs are built from.

09

Mid-level: closures and iterators — 4 questions

Idiomatic Rust is iterator-heavy, and closures carry ownership rules of their own. Expect a follow-up asking you to rewrite a loop as a chain, or the other way round.

Mid-levelExplain Fn, FnMut and FnOnce.

They describe what a closure does with what it captured. Fn only reads captures, so it can be called many times, even concurrently. FnMut mutates captures, so it needs exclusive access for each call. FnOnce moves a capture out, so it can be called only once. Every Fn is also FnMut, and every FnMut is also FnOnce. When writing a function that takes a closure, ask for the least you need: FnOnce if you call it once (thread::spawn, unwrap_or_else), FnMut for repeated calls on one thread, Fn only if you must share it.

What they are really testing: Whether they choose bounds from the caller’s point of view, which is the API-design half of the question.

Mid-levelWhat does move do on a closure, and when is it required?

It makes the closure take ownership of every variable it captures, instead of borrowing them. It is required when the closure may outlive the current stack frame: passing it to thread::spawn or tokio::spawn, returning it from a function, or storing it in a struct. For Copy captures it copies. A common pattern is to clone an Arc just before a move closure, so the closure owns its own handle.

What they are really testing: Connecting move to lifetimes and threads rather than treating it as a fix-the-error keyword.

Mid-levelIterators in Rust are lazy. What does that mean in practice?

Adapters like map, filter and zip only build a new iterator value; nothing runs until a consumer — collect, sum, for, count — pulls items through. Consequences: a chain with no consumer does nothing (the compiler warns that the adapter is unused: "iterators are lazy and do nothing unless consumed"); the chain processes one item at a time end to end with no intermediate vectors; and take(3) after an expensive map means only three items are ever mapped. After optimisation, a chain usually compiles to the same loop you would write by hand.

What they are really testing: Understanding the pull model and its performance consequences.

Mid-levelWhat is the difference between iter(), iter_mut() and into_iter()?

iter() yields &T and leaves the collection untouched. iter_mut() yields &mut T so you can change elements in place. into_iter() on an owned collection consumes it and yields T by value, so the collection cannot be used afterwards. A for loop calls into_iter() on whatever it is given, which is why for x in &v borrows and for x in v moves v.

What they are really testing: Precise ownership reasoning in the most common loop in the language.

10

Mid-level: smart pointers and memory — 4 questions

When single ownership is not enough, these types decide who owns what and when mutation is allowed.

Mid-levelWhen do you use Box, Rc and Arc?

Box<T>: a single owner of heap data — recursive types (a tree node holding Option<Box<Node>>), large values you want to move cheaply, and trait objects (Box<dyn Error>). Rc<T>: several owners on one thread, with a non-atomic reference count, so it is cheap but not Send. Arc<T>: several owners across threads, with an atomic count. All three give shared (read-only) access; add RefCell or Mutex inside to mutate.

What they are really testing: Choosing by ownership shape and thread boundary, not by habit.

Mid-levelWhat is interior mutability? How does RefCell fail?

Interior mutability lets you mutate data through a shared reference, with the borrowing rules checked somewhere other than compile time. Cell does it for Copy values by swapping whole values; RefCell tracks borrows at run time — borrow() and borrow_mut() hand out guards, and breaking the one-writer rule panics (current Rust prints "RefCell already borrowed"). try_borrow_mut returns a Result instead. Mutex and RwLock are the thread-safe equivalents. The usual shape is Rc<RefCell<T>> on one thread and Arc<Mutex<T>> across threads.

What they are really testing: That RefCell moves the check to run time — and the failure mode is a panic, so keep borrows short.

Mid-levelCan you leak memory in safe Rust?

Yes. Leaks are memory-safe, so safe Rust does not prevent them. The classic accidental case is an Rc cycle: two nodes holding Rcs to each other keep both counts above zero forever. The fix is Weak for back-pointers (child to parent), which does not keep the value alive and is upgraded with upgrade() returning Option. Deliberate leaks exist too: Box::leak and std::mem::forget. In services, the more common "leak" is an unbounded collection, such as a cache map that is never pruned.

What they are really testing: Knowing the precise guarantee — memory safety, not leak freedom — and the Weak fix.

Mid-levelWhat is Cow and when is it useful?

Cow<'a, B> ("clone on write") holds either a borrowed &'a B or an owned value. It lets a function return borrowed data in the common case and allocate only when it has to change something:

fn escape(s: &str) -> Cow<'_, str> {
    if s.contains('&') { Cow::Owned(s.replace('&', "&amp;")) }
    else { Cow::Borrowed(s) }
}
Callers use it like a &str. It is common in parsers, escaping and normalisation code where most inputs pass through unchanged.

What they are really testing: Awareness of allocation-avoidance tools used in real libraries.

11

Mid-level: concurrency, Send and Sync — 4 questions

"Fearless concurrency" is one of the main reasons companies adopt Rust, so interviewers check that you know exactly what the compiler guarantees — and what it does not.

Mid-levelWhat do Send and Sync mean?

Send: a value of this type can be moved to another thread. Sync: a &T can be shared between threads (formally, T: Sync exactly when &T: Send). Both are auto traits — the compiler implements them when every field has them. Rc is neither, because its count is not atomic; RefCell is Send but not Sync; Arc<Mutex<T>> is both when T: Send. thread::spawn requires its closure to be Send, which is how data races become compile errors.

What they are really testing: Understanding that thread safety is encoded in the type system, with concrete examples of types that fail.

Mid-levelHow do you share a counter across threads?

Wrap it in Arc<Mutex<u64>>, clone the Arc into each thread’s move closure, lock, update, and let the guard drop to unlock. For a plain counter, Arc<AtomicU64> with fetch_add(1, Ordering::Relaxed) is lighter and never blocks. With scoped threads (std::thread::scope) you can even borrow a local AtomicU64 or Mutex directly with no Arc, because the scope guarantees every thread has finished before it returns.

What they are really testing: Knowing the standard answer, the lighter atomic, and the newer scoped-thread option.

Mid-levelChannels or shared state — how do you choose?

Channels (std::sync::mpsc, or crossbeam and tokio channels) move ownership of messages between threads, so there is no shared data to lock — good for pipelines and worker pools, and easier to reason about. Shared state behind a Mutex or RwLock fits when many threads need to read or update one structure, such as a cache. Prefer bounded channels so a slow consumer applies backpressure instead of growing memory without limit.

What they are really testing: Design reasoning and awareness of backpressure.

Mid-levelCan Rust code deadlock? What is mutex poisoning?

Yes. The type system prevents data races, not deadlocks: two threads locking two mutexes in opposite orders can still wait forever, and locking the same std::sync::Mutex twice on one thread may deadlock or panic. Prevent it with a consistent lock order, short critical sections and no locks held while calling unknown code. Poisoning: if a thread panics while holding a std mutex, the mutex is marked poisoned and later lock() calls return Err, warning that the data may be half-updated. into_inner() on that error recovers the guard if you decide the data is still fine.

What they are really testing: An honest account of what Rust does and does not guarantee.

12

Mid-level: async and error design — 4 questions

Most Rust backend jobs mean async code on tokio (Module 12 builds one), and every real codebase needs a deliberate error strategy.

Mid-levelWhat does an async fn return, and what does .await do?

Calling an async fn runs none of its body; it returns a value that implements Future — a state machine the compiler generated from the function. Futures are lazy: nothing happens until something polls them. .await polls the inner future; if it is not ready, the current task yields to the executor, which runs other tasks and polls again when the I/O is ready. Rust ships the syntax and the Future trait but no runtime, which is why services add tokio.

What they are really testing: The poll-based, lazy model — the source of most async surprises.

Mid-levelWhy is calling blocking code inside an async handler a problem, and what do you do instead?

A tokio runtime runs many tasks on a few worker threads. A task only gives the thread back at an .await, so std::thread::sleep, a blocking file read, a synchronous database driver or a heavy CPU loop stalls every other task scheduled on that thread, and latency spikes across the service. Use the async versions (tokio::time::sleep, tokio::fs), move blocking work to tokio::task::spawn_blocking, and move long CPU work to a dedicated pool such as rayon.

What they are really testing: Production awareness: this is the most common async performance bug.

Mid-levelWhy does tokio::spawn complain that a future "cannot be sent between threads safely"?

tokio::spawn may move a task between worker threads, so the future must be Send and 'static. A future is Send only if everything it holds across an .await is Send. The usual culprits are an Rc, a RefCell borrow, or a std::sync::MutexGuard alive across an await. Fix it by dropping the guard before awaiting (put the locked section in its own block), using tokio::sync::Mutex when the lock really must be held across an await, or swapping Rc for Arc.

What they are really testing: Whether they have debugged real async code — this error is nearly universal.

Mid-levelHow do you design error types for a library versus an application?

A library exposes a specific error enum so callers can match on the cases: implement Display and std::error::Error, keep the underlying cause available through source(), and add From impls so ? converts lower-level errors. The thiserror crate derives all of that. An application mostly needs to report errors with context, so anyhow::Result (or Box<dyn Error>) with .context("loading config") is simpler. Mark a public enum #[non_exhaustive] so adding a variant is not a breaking change.

What they are really testing: Whether they separate "caller must handle it" from "operator must read it", and know the ecosystem split.

13

Senior: unsafe, performance, design and migration — 10 questions

Senior Rust rounds are conversations, not quizzes. There is rarely one right answer; the interviewer is listening for trade-offs, measurement and experience with production systems.

SeniorWhat does unsafe actually allow, and what does "sound" mean?

unsafe unlocks five things: dereferencing raw pointers, calling unsafe functions (including FFI), accessing or modifying a mutable static, implementing an unsafe trait (such as Send or Sync), and accessing union fields. It does not turn off the borrow checker for references. An abstraction is sound if no safe code, however it calls your API, can cause undefined behaviour. So an unsafe block is a promise: document the invariant in a // SAFETY: comment, keep the block small, wrap it in a safe API that enforces the invariant, and test it under Miri, which detects many kinds of undefined behaviour.

What they are really testing: Discipline around unsafe: encapsulation, documented invariants and tooling, not bravado.

SeniorHow would you call a C library from Rust, and expose Rust to C?

Declare the functions in an extern "C" block (by hand, or generated with bindgen), link the library through a build script, and wrap every call in a safe Rust function that converts types: CString and CStr for NUL-terminated strings, #[repr(C)] structs for layout, raw pointers checked for null, and an ownership rule for who frees what — memory allocated by C is freed by C. To go the other way, export #[no_mangle] pub extern "C" fn functions and generate a header with cbindgen. Never let a panic unwind across the boundary: catch it with std::panic::catch_unwind and return an error code.

What they are really testing: Real FFI experience: ownership across the boundary, string handling and panic safety.

SeniorRust claims "zero-cost abstractions". What does that mean, and where does it not hold?

It means an abstraction costs nothing extra compared with the hand-written code it replaces, and you pay nothing for features you do not use. Iterator chains compile to plain loops, generics are monomorphised into specialised code, Option<&T> is a nullable pointer, and newtypes vanish. It does not hold everywhere: monomorphisation makes binaries larger and compiles slower; dyn Trait costs an indirect call and blocks inlining; bounds checks remain unless the optimiser can prove them away; and Rc, Arc and RefCell have real runtime costs. I would verify with a benchmark or by reading the assembly rather than assume.

What they are really testing: A nuanced answer that knows the costs, backed by measurement.

SeniorA Rust service is slower than expected. How do you investigate?

First confirm it is a release build with a sensible profile (lto and codegen-units = 1 for the final binary). Measure before changing anything: a flamegraph (cargo flamegraph or perf) for CPU, tokio-console for async tasks that block or starve, and a heap profiler for allocation. The usual findings are needless allocation (clone(), to_string(), collect() into a Vec that is iterated once), lock contention on a hot Mutex, blocking calls on the async runtime, and unbuffered I/O. Fix one thing, re-measure with criterion benchmarks, and keep the benchmark in the repository so the regression cannot come back.

What they are really testing: A measurement-first method and knowledge of Rust-specific hotspots.

SeniorWhat makes a good public Rust library API?

Accept the most general borrowed input (&str, &[T], impl AsRef<Path>, impl IntoIterator) and return owned, concrete types. Make invalid states unrepresentable with enums and newtypes, use a builder when a constructor would have many optional arguments, and return a specific error enum. Implement the common traits — Debug, Clone, PartialEq, Default, Send and Sync where possible — because users cannot add them later (orphan rule). Protect semver: keep fields private, mark enums and structs #[non_exhaustive], put optional dependencies behind features, and check changes with cargo-semver-checks. Document with runnable doc examples, which cargo test keeps honest.

What they are really testing: Library-author thinking: ergonomics, evolvability and the Rust API guidelines.

SeniorHow do you structure a large Rust codebase?

A Cargo workspace with several crates: a core domain crate with no I/O, adapter crates for the database, HTTP and messaging, and thin binaries that wire them together. Separate crates compile in parallel and cache independently, which matters because the crate is Rust’s unit of compilation. Share dependency versions with [workspace.dependencies], keep features additive (a feature must never remove API), and commit Cargo.lock for applications. Watch compile times: heavy generic code and procedural macros are the usual cost, so keep generic code at the edges and consider dyn in internal boundaries where it does not matter for speed.

What they are really testing: Compile-time awareness and boundary design — the problems that appear only at scale.

SeniorYour team wants to rewrite a C++ service in Rust. How do you approach it?

Not as a big-bang rewrite. First find the reason — memory-safety bugs, crashes, security findings — and pick the component where Rust pays off most: usually the parser or network-facing code that handles untrusted input. Migrate incrementally behind the existing interface: call Rust from C++ through a C ABI, or use the cxx crate for a safer bridge, with the same tests running against both implementations. Keep the old version until the new one matches in production metrics. Plan for team ramp-up; the borrow checker changes how people design ownership, and C++ patterns such as graphs of mutual pointers need rethinking (indices, arenas, or Rc and Weak).

What they are really testing: Risk management, incremental migration and knowing where Rust’s value actually lies.

SeniorGo or Rust for a new backend service — how would you decide?

For a typical CRUD API, Go is often the more pragmatic choice: fast compiles, a simple concurrency model, a garbage collector nobody has to think about, and easy hiring. Rust earns its place when you need predictable latency without GC pauses, low memory per instance, heavy CPU work (parsing, compression, encoding), correctness guarantees that are hard to get otherwise, or when it is a proxy, database or embedded system. I would also weigh the team’s experience, since Rust’s learning curve is a real cost in the first months. Moving a hot path from Go to Rust behind a gRPC boundary is a common middle ground.

What they are really testing: Honest trade-offs rather than language advocacy.

SeniorWhat is cancellation safety in async Rust, and why does it matter?

An async task can be cancelled at any .await: when a future is dropped — by tokio::select! choosing another branch, a timeout firing, or a client disconnecting — it simply stops at its last await point and never resumes. Code is cancellation safe if stopping at any await leaves no half-done state. Reading into a local buffer and then dropping it can lose data; a sequence of "debit, .await, credit" can stop in the middle. Design so each await point is a safe place to stop: use transactions, keep state in the struct rather than in locals across awaits, check the documentation (tokio marks which methods are cancellation safe), and use a shutdown signal for graceful termination instead of dropping tasks.

What they are really testing: Deep async understanding that only comes from production incidents.

SeniorMany tasks update a shared in-memory map and a single Arc<Mutex<HashMap>> has become a bottleneck. What are your options?

First measure contention and hold times; often the fix is holding the lock for less time (clone the value out, do the work, then lock again briefly). Then, by increasing effort: an RwLock if reads dominate; sharding — N maps each behind its own lock, chosen by key hash (the dashmap crate does this); an actor — one task owns the map and others send it messages over a channel, which removes locking and gives natural backpressure; or copy-on-write snapshots behind an Arc that readers load without locking (arc-swap) when writes are rare. The choice depends on the read/write ratio and on whether readers need the very latest value.

What they are really testing: A graduated, measured set of options rather than one favourite pattern.

The senior-answer shape
State the trade-off, give the default you would pick and why, name what would change your mind, and say how you would measure it. "It depends" is only a good answer when it is followed by what it depends on.
14

The coding round, walked through

Rust coding rounds use the same problems as any other language, but the interviewer also watches how you handle ownership: whether you borrow or clone, whether you return an Option or a sentinel, whether the borrow checker surprises you. Use the same script every time.

  1. 1
    Clarify (2 min)

    Restate the problem. Ask about input size, empty input, duplicates, and what to return when there is no answer. Write the signature first: fn top_k(words: &[&str], k: usize) -> Vec<String>. The types are the contract.

  2. 2
    Example (1 min)

    Work one small case by hand. It becomes your first line in main.

  3. 3
    Brute force out loud (2 min)

    "Count everything, sort it all, take the first k: O(n log n)." Say it and its cost before improving it.

  4. 4
    Pick the pattern (1 min)

    HashMap with entry for counting? Two pointers? A BinaryHeap? Name it, and why.

  5. 5
    Code (15 min)

    Talk while you type. Borrow inputs, return owned outputs, handle the edge cases you listed. If the borrow checker objects, say what it caught before you change anything.

  6. 6
    Test (5 min)

    Run your example, then the edges. Finding your own bug scores higher than never having one.

  7. 7
    Complexity and improvements (2 min)

    State time and space, then the better version if there is one.

Here is a typical 30-minute problem solved that way: given a list of words, return the k most frequent; break ties alphabetically. The first version is the brute force with a two-key sort; the second is the answer to "what would you improve?" — a heap that never holds more than k items.

rustmain.rs
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};

// Contract: words may be empty or repeat; k may be 0 or larger than the word count.
// Returns the k most frequent words, ties broken alphabetically.
fn top_k(words: &[&str], k: usize) -> Vec<String> {
    let mut counts: HashMap<&str, usize> = HashMap::new();
    for &w in words {
        *counts.entry(w).or_insert(0) += 1;
    }
    let mut ranked: Vec<(&str, usize)> = counts.into_iter().collect();
    // count descending, then word ascending: O(n log n)
    ranked.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
    ranked.into_iter().take(k).map(|(w, _)| w.to_string()).collect()
}

// Improvement for huge n, small k: keep only k candidates. O(n log k).
fn top_k_heap(words: &[&str], k: usize) -> Vec<String> {
    let mut counts: HashMap<&str, usize> = HashMap::new();
    for &w in words {
        *counts.entry(w).or_insert(0) += 1;
    }
    // Min-heap on (count, Reverse(word)): the weakest candidate sits on top.
    let mut heap = BinaryHeap::new();
    for (w, c) in counts {
        heap.push(Reverse((c, Reverse(w))));
        if heap.len() > k {
            heap.pop();                          // evict the weakest
        }
    }
    let mut out: Vec<String> = Vec::with_capacity(heap.len());
    while let Some(Reverse((_, Reverse(w)))) = heap.pop() {
        out.push(w.to_string());
    }
    out.reverse();                               // strongest first
    out
}

fn main() {
    let words = ["b", "a", "b", "c", "a", "b"];
    println!("{:?}", top_k(&words, 2));
    println!("{:?}", top_k(&[], 3));
    println!("{:?}", top_k(&["x"], 0));
    println!("{:?}", top_k(&["z", "y", "z", "y"], 1)); // tie -> alphabetical
    println!("{:?}", top_k_heap(&words, 2));
    println!("{:?}", top_k_heap(&["z", "y", "z", "y"], 1));
}
Outputcompiled & run with real Rust
["b", "a"]
[]
[]
["y"]
["b", "a"]
["y"]

BinaryHeap is a max-heap, so wrapping the entry in Reverse makes it a min-heap: the weakest candidate sits on top and pop() evicts it. The inner Reverse(w) makes the alphabetically later word count as weaker on a tie.

Your turn

Add a test where every word appears once, e.g. ["d", "c", "b"] with k = 2. Predict the output before running it — both functions must print ["b", "c"].

What loses the round

  • Silence for ten minutes, then a wall of code
  • Adding .clone() everywhere until the borrow checker goes quiet, without saying why
  • Returning -1 or an empty string instead of an Option
  • len() - 1 on a possibly empty slice
  • Printing a HashMap directly and expecting a stable order

What wins it

  • Writing the function signature first and explaining the ownership choices in it
  • A worked example and edge cases before any code
  • Using entry, iterator adapters and ? naturally
  • Naming the complexity without being asked
  • "I use a BinaryHeap of size k here because I only need k items"
15

Take-home assignment checklist

Rust take-homes are usually "build a small async API", "write a CLI that processes this file" or "implement this data structure or protocol". Reviewers clone the repository, run cargo build, cargo test and cargo clippy, read the README and the tests, then the code. A warning-filled build is a bad first impression that is completely avoidable.

  • Builds cleanly on a fresh machine: cargo build and cargo test work with only rustup installed. Pin the toolchain in rust-toolchain.toml if you use anything recent, and commit Cargo.lock for a binary.
  • Zero warnings: cargo fmt --check and cargo clippy -- -D warnings both pass. Reviewers run them.
  • README: what it does, how to build, run and test it in three commands, example input and output, and the decisions you made — including what you deliberately left out.
  • Tests: unit tests next to the code in #[cfg(test)] modules for the tricky rules, integration tests in tests/ for the public behaviour, and at least one test for invalid input.
  • No stray unwrap() on input, files or network. Use expect with a reason where failure is impossible, and Result everywhere else.
  • Deliberate errors: a custom error enum (hand-written or with thiserror) in library code, anyhow with context in the binary. An HTTP API returns a 400 with a clear body for bad input, never a panic.
  • Ownership that reads well: borrowed parameters, no clones used to silence the borrow checker, no unsafe unless the task needs it — and if it does, a // SAFETY: comment on every block.
  • Structure: logic in lib.rs and modules, a thin main.rs, so the logic is testable without starting the program.
  • Optional but noticed: a GitHub Actions workflow running fmt, clippy and tests, and a Dockerfile for services.
  • Time-box to what they asked (usually 3–4 hours) and say so in the README. One finished extra is fine; five half-done ones are a red flag.
The sentence reviewers want to write
"Clean clippy, tests cover the edge cases, no unwraps on input, and the ownership reads naturally." Aim every decision at that sentence. The next module, Job Ready, turns the same standards into a portfolio.

Frequently asked questions

What Rust topics are asked most in interviews?
At junior level: ownership and moves, the borrowing rules, String versus &str, Option and Result, the ? operator and match. At mid level: lifetimes, generics versus dyn Trait, Fn/FnMut/FnOnce, Box/Rc/Arc and RefCell, Send and Sync, and async basics on tokio. Senior rounds add unsafe and FFI, performance investigation, API design and migrating services from C++ or Go.
Do Rust interviews expect async and tokio knowledge?
For backend roles, usually yes: most Rust web services run on tokio with axum or a similar framework. Expect questions on how futures are lazy, why blocking inside async code is harmful, and why a future must be Send to be spawned. Systems and embedded roles focus more on ownership, unsafe and performance.
Will I have to write Rust live in the coding round?
Often, yes, though some companies let you choose any language. If you pick Rust, know the standard collections, the entry API and iterator adapters well enough to write them without looking them up, and practise handling the borrow checker out loud.

Finish the Rust handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.