Rust Interview Questions

Reviewed by Mark Dickie · Last updated

Rust is a systems programming language that guarantees memory safety and data-race freedom at compile time, without a garbage collector. Its ownership and borrowing model — enforced by the borrow checker — is what most interviews probe, alongside traits, the Result/Option error model, and fearless concurrency. These questions cover the concepts that separate someone who has merely fought the borrow checker from someone who understands why it rejects their code.

At a glance

Questions15
Difficulty2–5 of 5
FormatsMultiple choice, Multiple answer, True / false, Code output, Find the bug, Short answer, Flashcard, Ordering, Fill in the blank

What you'll review

  1. move semantics
  2. question operator
  3. rc arc
  4. trait objects
  5. send sync
  6. iterators
  7. pattern matching
  8. interior mutability
  9. vec
  10. borrow checker
  11. panic unwrap
  12. lifetimes
  13. box
  14. drop raii
  15. structs enums

Practice questions

Rust/ownership/move-semantics

You write let a = String::from("hi"); let b = a; println!("{}", a);. What does the compiler do?

Options

  • Compile error: a was moved into b, so using a afterward is a use-after-move
  • It compiles and prints hi; let b = a copies the string
  • It compiles but prints garbage because a now points to freed memory
  • Runtime panic: a has been dropped
Show answer

It is a compile error. String owns a heap buffer and is not Copy, so let b = a moves ownership to b and invalidates a; reading a afterward is a use-after-move that the borrow checker rejects before runtime ("borrow of moved value"). Copy types like i32 would duplicate instead. To keep both, use a.clone() or borrow with &a.

Why:

String owns a heap allocation and does not implement Copy, so let b = a is a move: ownership transfers to b and a is invalidated at compile time. Using a afterward is a use-after-move, and the borrow checker rejects it before the program ever runs — there is no runtime garbage or panic, just a compile error (borrow of moved value: a). Contrast a Copy type like i32, where let b = a duplicates the bits and both stay usable. To keep both String handles valid you must a.clone() (a deep copy) or borrow with &a. This compile-time move tracking is what lets Rust free each allocation exactly once without a garbage collector.

Rust/error-handling/question-operator

Inside fn load() -> Result<Config, io::Error>, you call let s = fs::read_to_string(path)?;. What does the ? operator do when read_to_string returns Err(e)?

Options

  • It returns early from load, propagating Err(e) (after From conversion) to the caller
  • It panics, unwinding the stack with the error message
  • It logs the error and continues with a default empty string
  • It retries read_to_string until it succeeds
Show answer

On Err(e), ? returns early from the enclosing function, propagating the error to the caller after converting it via the From trait into the function's declared error type. On Ok(v) it unwraps to v and continues. Unlike unwrap, ? never panics — it propagates. The From conversion is what lets one ? bridge many underlying error types into a single return error.

Why:

On Err(e), ? returns from the enclosing function immediately with that error, after converting it via the From trait into the function's declared error type — here both sides are io::Error, so no conversion is needed. On Ok(v) it unwraps to v and execution continues. ? is not unwrap: it never panics (that's option b's mistake); it propagates. The From conversion is the key feature — it lets you write ? across many underlying error types as long as your function's error type implements From for each, which is why Box<dyn Error> or a custom error enum with #[from] derives are common return types. ? only works in functions that return Result, Option, or another Try type.

Rust/memory-model/rc-arc

You have shared, reference-counted data that several threads must read. Why must you use Arc<T> rather than Rc<T> here?

Options

  • Rc uses non-atomic reference counting and is not Send/Sync, so it can't cross threads; Arc updates its count atomically
  • Rc can only hold one value, while Arc can hold many
  • Arc is faster than Rc in all cases, so it's always preferred
  • Rc allocates on the stack and Arc on the heap
Show answer

Rc<T> updates its reference count with non-atomic operations and is deliberately neither Send nor Sync, so the compiler forbids it across threads. Arc<T> uses atomic counting and is Send + Sync (when T is), so it compiles in threaded code. The atomics cost more, which is why single-threaded code keeps Rc. Arc only synchronizes the count — mutating the inner value still needs a Mutex.

Why:

Rc<T> updates its strong/weak counts with plain non-atomic integer operations, so concurrent clones/drops from multiple threads would race and corrupt the count — Rust prevents this statically by making Rc neither Send nor Sync, so the compiler rejects moving or sharing it across threads. Arc<T> (Atomically Reference Counted) uses atomic operations for the counts and is Send + Sync when T: Send + Sync, so it compiles in threaded code. The tradeoff is that atomics cost more than plain increments, which is exactly why Rc still exists — use it for single-threaded sharing and pay nothing for synchronization you don't need. Note Arc only makes the count thread-safe; mutating the inner T still needs a Mutex/RwLock.

Rust/types-traits/trait-objects

What is the key runtime difference between fn draw<T: Shape>(s: &T) (generic) and fn draw(s: &dyn Shape) (trait object)?

Options

  • The generic is monomorphized to static dispatch (no vtable); the trait object uses a vtable for dynamic dispatch at runtime
  • They are identical after compilation; dyn is only a readability hint
  • The trait object is faster because it avoids generating multiple copies of the function
  • The generic version uses a vtable; the trait object is inlined
Show answer

A generic fn draw<T: Shape> is monomorphized — the compiler emits a concrete copy per type and dispatches statically, so calls can be inlined with no runtime indirection. &dyn Shape is a trait object: a fat pointer carrying a vtable, and methods are resolved through that vtable at runtime (dynamic dispatch). Static dispatch is faster but can bloat the binary; dynamic dispatch keeps one copy and allows heterogeneous collections.

Why:

A generic fn draw<T: Shape> is monomorphized: the compiler stamps out a separate, concrete copy of draw for each T it's called with, and each call dispatches statically — the exact method is known at compile time, so it can be inlined, with zero runtime indirection. &dyn Shape is a trait object: a fat pointer (data pointer + vtable pointer) where the method is looked up through the vtable at runtime (dynamic dispatch). Static dispatch is typically faster per-call and inlinable but can bloat binary size with many monomorphized copies; dynamic dispatch keeps one copy and lets you store heterogeneous types (e.g. Vec<Box<dyn Shape>>) at the cost of an indirect call and lost inlining. Choosing between them is a classic Rust performance-vs-flexibility tradeoff.

Rust/concurrency/send-sync

Send means a type can be moved to another thread; Sync means &T can be shared across threads. Which of these types can safely be sent or shared across threads as written? Select all that apply.

Options

  • Arc<i32> — sent to another thread
  • Rc<i32> — sent to another thread
  • Mutex<Vec<u8>> — shared by reference across threads
  • RefCell<i32> — shared by reference across threads
  • i32 — sent to another thread
Show answer

Arc<i32> (Send + Sync), plain i32 (Send), and Mutex<Vec<u8>> (Sync, so shareable by reference) are all safe across threads. Rc<i32> is deliberately not Send because its count is non-atomic, and RefCell<i32> is not Sync because its borrow flags aren't atomic — sharing either across threads is a compile error. Send/Sync are auto-derived marker traits the compiler uses to rule out data races.

Why:

Arc<i32> is Send + Sync because its count is atomic, so it can move to another thread (a). Plain i32 is Send (and Sync), so it crosses freely (e). Mutex<Vec<u8>> is Sync when its contents are Send, so &Mutex<...> can be shared across threads — that's the whole point of a mutex (c). Rc<i32> is explicitly not Send: its non-atomic count would race, so it cannot be sent to another thread (b is wrong). RefCell<i32> is Send but not Sync: its runtime borrow flags aren't atomic, so a shared &RefCell across threads could double-mutably-borrow undetected — Rust forbids it (d is wrong). These two marker traits are auto-derived structurally, and the borrow checker uses them to make data races a compile error rather than a runtime bug.

Rust/collections-iterators/iterators

Rust iterator adapters like .map() and .filter() are lazy: calling v.iter().map(f).filter(g) does no work until a consuming operation (such as .collect(), .sum(), or a for loop) drives the chain.

Show answer

True. Iterator adapters like map and filter are lazy — each returns a new iterator wrapping the previous one and touches no element until a consumer (collect, sum, for, etc.) drives the chain. A chain with no consumer does nothing, and the compiler warns about it. Laziness is what makes adapter chains zero-cost: the compiler fuses them into one tight loop with no intermediate allocations.

Why:

True. Adapters such as map, filter, take, and enumerate are lazy — each just returns a new iterator struct that wraps the previous one; no element is touched until a consumer pulls values through. Consumers include for loops and methods like collect, sum, count, fold, and for_each. A direct consequence: a chain of adapters with no consumer is a no-op (the compiler even warns iterators are lazy and do nothing unless consumed). Laziness is also what makes adapter chains a 'zero-cost abstraction' — the compiler fuses the whole chain into a single tight loop with no intermediate collections, so iter().map().filter().sum() allocates nothing in between and is as fast as a hand-written loop.

Rust/collections-iterators/pattern-matching

What does this Rust program print?

fn main() {
    let x = 5;
    let x = x + 1;
    {
        let x = x * 2;
        println!("{}", x);
    }
    println!("{}", x);
}

Options

  • 12 6
  • 12 12
  • 6 6
  • Compile error: cannot reassign immutable x
Show answer
12
6
Why:

This is shadowing, not mutation. Each let x = ... introduces a brand-new immutable binding that happens to reuse the name x, hiding the previous one — no mut is needed and there is no compile error (option d). In main's scope, x becomes 5, then 6. The inner block shadows again with x * 2 = 12 and prints 12; that inner binding is scoped to the block, so when it ends the outer x (still 6) is visible again and the second println! prints 6. Output: 12 then 6. Shadowing differs from mut reassignment in two ways that matter: it can change the variable's type (let s = s.len();), and the original value isn't altered — useful for staged transformations of an input.

Rust/memory-model/interior-mutability

What is the result of running this Rust program?

use std::cell::RefCell;

fn main() {
    let cell = RefCell::new(5);
    let a = cell.borrow();
    let mut b = cell.borrow_mut();
    *b += 1;
    println!("{}", *a);
}

Options

  • It compiles, then panics at runtime: "already borrowed: BorrowMutError"
  • Compile error: cannot borrow cell as mutable while borrowed as immutable
  • It prints 5
  • It prints 6
Show answer
It compiles, then panics at runtime: "already borrowed: BorrowMutError"
Why:

RefCell<T> moves Rust's borrowing rules from compile time to runtime: it tracks an internal borrow flag and lets you take &/&mut through borrow()/borrow_mut(). Here the immutable borrow a is still alive (it's used later by println!) when borrow_mut() is called, so the dynamic check sees an outstanding shared borrow and panics with already borrowed: BorrowMutError rather than allowing aliasing. This compiles (so option b is wrong — the static borrow checker can't see the conflict, that's the whole reason RefCell exists), but blows up at runtime. The lesson: RefCell buys interior mutability at the price of converting borrow violations from compile errors into panics, so you must keep borrow scopes short (often by ending one in its own { } block) to avoid overlap.

Rust/collections-iterators/vec

What does this Rust program print?

fn main() {
    let mut v: Vec<i32> = Vec::with_capacity(10);
    v.push(1);
    v.push(2);
    println!("{} {}", v.len(), v.capacity());
}

Options

  • 2 10
  • 2 2
  • 10 10
  • 2 16
Show answer
2 10
Why:

Vec::with_capacity(10) allocates room for 10 elements up front but creates an empty vector, so len() is 0 initially and only counts elements actually pushed — here 2. capacity() reports the allocated slots, which stays 10 because two pushes fit comfortably within the reserved space; no reallocation happens. So it prints 2 10. The distinction is real-world important: len is how many elements exist, capacity is how many fit before the next growth. Reserving capacity with with_capacity (or reserve) when you know the size avoids repeated reallocate-and-copy cycles as a Vec grows (Rust's growth strategy typically doubles capacity), which is a common, cheap performance win in hot loops.

Rust/ownership/borrow-checker

This function fails to compile. What is the root cause the borrow checker is reporting?

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{}", first);
}

Options

  • first is an immutable borrow of v that is still used after v.push(4); push needs a &mut borrow, and Rust forbids a mutable borrow while a shared borrow is live (push could reallocate and dangle first)
  • v must be declared with let not let mut, because push mutates it
  • &v[0] is invalid syntax; you must write v.get(0)
  • println! cannot print a reference; you must dereference with *first
Show answer

first is an immutable borrow of v that is still used after v.push(4); push needs a &mut borrow, and Rust forbids a mutable borrow while a shared borrow is live (push could reallocate and dangle first)

Why:

let first = &v[0] takes a shared (immutable) borrow of v, and that borrow is still live because first is used in the final println!. v.push(4) requires a mutable borrow of v, but Rust's aliasing rule forbids a &mut while any & is outstanding. This isn't pedantry: push may reallocate the Vec's backing buffer when capacity is exceeded, which would leave first pointing at freed memory — a classic iterator-invalidation / dangling-pointer bug that the borrow checker turns into a compile error instead. Option b is backwards (mut is required for push); the real fix is to use first before mutating, or copy the value out (let first = v[0];, since i32 is Copy) so no borrow outlives the mutation.

Rust/error-handling/panic-unwrap

When is .unwrap() (or .expect()) on a Result/Option acceptable, and what should you use instead in code that must not crash?

Show answer

unwrap() returns the inner value on Ok/Some but panics on Err/None, aborting the current thread. It's acceptable in throwaway prototypes, examples, tests, and cases where the value is provably present (an invariant you can prove can never fail), where a panic signals a genuine bug. expect("msg") is preferable to unwrap() even then because it documents the invariant in the panic message. In production code paths that must not crash, propagate the error instead: use the ? operator to bubble it up, match/if let to handle both arms, or combinators like map, and_then, unwrap_or, unwrap_or_else, and ok_or to supply a fallback or convert between Result and Option. The principle is that recoverable errors should be returned as values, and panics reserved for unrecoverable, programmer-error situations.

Why:

unwrap/expect panic on the error case, so they're fine in tests, prototypes, and spots where the success case is a guaranteed invariant — and expect is the better of the two because its message documents why the call can't fail. Production code that must stay up should instead propagate the error with ?, branch on it with match/if let, or fall back with combinators like unwrap_or_else. A strong answer ties this to Rust's split between recoverable errors (returned as Result) and unrecoverable bugs (panics). The real-world cost of careless unwrap is a service that crashes on the first malformed input.

Rust/ownership/lifetimes

What do lifetime annotations like <'a> actually do, and why does fn longest<'a>(x: &'a str, y: &'a str) -> &'a str need one while many functions don't?

Show answer

Lifetimes are compile-time-only annotations that describe how the lifetimes of references relate to one another; they don't change how long any value lives, they just let the borrow checker verify that no returned reference outlives the data it points to (preventing dangling references). They generate no runtime cost. Most functions don't need explicit lifetimes because of lifetime elision: the compiler applies default rules (e.g. each input reference gets its own lifetime, and with a single input reference its lifetime is assigned to the output). longest returns a reference that could come from either x or y, so the compiler can't infer which input the output borrows from — the 'a annotation tells it that the returned reference is valid for the shorter of the two inputs' lifetimes, so the output may not outlive either argument. Without it the function is ambiguous and won't compile.

Why:

Lifetime annotations are purely a compile-time device: they express relationships between reference lifetimes so the borrow checker can guarantee a returned reference never outlives its source, with zero runtime cost. Functions usually omit them thanks to elision — default rules that infer the obvious cases. longest needs an explicit 'a because its output could borrow from either argument, so the compiler can't elide which input it ties to; 'a says the result is valid only as long as both inputs are. A good answer names dangling-reference prevention and elision; this is a hallmark senior-level Rust topic because the syntax intimidates but the underlying idea (don't return a borrow that can dangle) is simple.

Rust/memory-model/box

What is Box<T> and what are the main reasons to reach for it?

Show answer

Box<T> is the simplest smart pointer: it owns a single heap allocation holding a value of type T, while the Box itself is a pointer-sized handle on the stack. When the Box goes out of scope it drops the value and frees the heap memory automatically (RAII). The three classic reasons to use it: (1) to give a recursive type a known, finite size — e.g. enum List { Cons(i32, Box<List>), Nil }, where boxing breaks the otherwise-infinite size; (2) to store a large value on the heap and move it cheaply by transferring just the pointer instead of copying the whole value; and (3) to hold a trait object whose size isn't known at compile time, such as Box<dyn Error> or Vec<Box<dyn Shape>>, enabling dynamic dispatch over heterogeneous types. Box<T> has single ownership and zero runtime overhead beyond the heap allocation itself.

Why:

Box<T> is owned, single-owner heap allocation with automatic cleanup on drop. The interview-critical use cases are: enabling recursive types (which would otherwise have infinite size), cheaply moving large values by pointer, and holding unsized trait objects for dynamic dispatch. A common follow-up is contrasting it with Rc/Arc (shared ownership) — Box is the one-owner case with no reference counting. Knowing the recursive-type use (Box<List>) is the detail that separates a memorized answer from real understanding.

Rust/memory-model/drop-raii

Three local values are created in one scope: let a = Guard("a"); let b = Guard("b"); let c = Guard("c"); where Guard prints its name in its Drop impl. Order the four events from when the scope ends, top to bottom, in the sequence they actually occur.

Put these in order

  • Execution reaches the closing } of the scope
  • c is dropped (prints "c") — last declared, first dropped
  • b is dropped (prints "b")
  • a is dropped (prints "a") — first declared, last dropped
Show answer

Rust drops locals in reverse declaration order — last-in, first-out — when the scope ends. So after execution reaches the closing brace, the order is: c drops first (it was declared last), then b, then a (declared first, dropped last). This LIFO unwinding keeps earlier values valid in case later ones borrow from them. Note that struct fields instead drop in declaration order, top to bottom.

Why:

At the end of a scope, Rust drops local variables in reverse order of declaration — last-in, first-out — so c drops first, then b, then a. This LIFO order exists because a later value may borrow from or depend on an earlier one, so unwinding in reverse keeps those dependencies valid while each Drop runs. (Fields within a single struct, by contrast, drop in declaration order, top to bottom.) Knowing the order matters for RAII guards: if a MutexGuard and a value that uses it are in the same scope, their declaration order decides which lock is released first, and getting it wrong can deadlock or release a resource too early.

Rust/types-traits/structs-enums

To let the compiler auto-generate trait implementations for a struct, you annotate it with #[_____(Clone, Debug)]. With that in place, Debug enables printing the value using the _____ format specifier inside println!.

Show answer

To let the compiler auto-generate trait implementations for a struct, you annotate it with #[**derive**(Clone, Debug)]. With that in place, Debug enables printing the value using the **{:?}** format specifier inside println!.

Why:

#[derive(...)] is the attribute that asks the compiler to auto-implement common traits structurally — Clone, Debug, PartialEq, Hash, Default, and others — saving you the boilerplate of writing each impl by hand. Deriving Debug lets you print a value with the {:?} specifier (or {:#?} for pretty, multi-line output) in println!/format!, which is the standard way to dump a struct for debugging. Note {} (Display) is not auto-derivable — you must implement Display yourself, because the human-facing representation is a deliberate design choice, whereas Debug's programmer-facing one can be generated mechanically.

Related interview questions

Job market

See rust salaries and hiring demand from live job postings.

Practice this for real

CodePrep turns your target job description into an adaptive quiz from a bank of tagged questions, scores your answers, and resurfaces the topics you miss.

New topics and job-market signal, in your inbox

Occasional updates — new question topics, launch news, and what the developer job market is hiring for. Confirm your email to join, and unsubscribe anytime.