Rust interview questions on ownership, borrowing, and concurrency

Reviewed by Mark Dickie · Last updated

7 questions on this page you can answer and see marked on the spot. Go to the first one

Rust is a systems programming language that enforces memory safety at compile time through ownership, borrowing, and lifetimes. Interviewers mostly check whether you can explain those rules, predict when the borrow checker will reject code, and pick between Box, Rc, Arc, and RefCell for a given case. Expect follow-ups on Send and Sync, on error handling with Result and the ? operator, and on when to use generics versus trait objects. Knowing why the compiler refuses a program counts for more than memorised syntax.

Which Rust topics do interviewers test most often?

TopicWhat to be able to explain
Ownership and movesWhy let b = a; invalidates a for a String but leaves an i32 usable
Borrowing and lifetimesWhy &mut and & to the same value cannot coexist, and what 'a ties together in a function signature
Smart pointersWhen Box<T>, Rc<T>, Arc<T>, or RefCell<T> fits, and what each costs at runtime
Error handlingHow Result<T, E> and ? propagate errors, and when unwrap() is acceptable
ConcurrencyHow Send and Sync stop data races at compile time, and why Arc<Mutex<T>> is the usual shared-state pattern
Traits and genericsStatic dispatch through generics versus dyn Trait objects and vtables

How should you prepare for a Rust interview?

  1. Write a small program that fails the borrow checker on purpose, then restructure the borrows until it compiles.
  2. Build a singly linked list with Option<Box<Node>> to see ownership and moves in practice.
  3. Share a counter across threads with Arc<Mutex<i32>>, then remove the Mutex and read the Send error the compiler gives you.
  4. Replace a chain of unwrap() calls in a small CLI with ? and a custom error enum.
  5. Rewrite a for loop that pushes into a Vec as an iterator chain using map and collect.

Key facts

  • Tarmac has 104 Rust interview questions on this topic, 10 of them on this page, at difficulty 2–4 of 5.
  • Tarmac tracked 286 job postings asking for Rust in October 2026.
  • Roles asking for Rust advertise a median base salary of £75,000, across 48 job postings as of October 2026.
  • Tarmac last reviewed these Rust interview questions on 7 November 2026.

At a glance

Questions10 shown · 104 in the bank
Difficulty2–4 of 5
FormatsMultiple choice, Fill in the blank, True / false, Code output, Short answer, Flashcard, Ordering, Multiple answer, Find the bug

What you'll review

  1. move semantics
  2. structs enums
  3. question operator
  4. iterators
  5. pattern matching
  6. panic unwrap
  7. box
  8. drop raii
  9. send sync
  10. borrow checker

Practice questions

Try one before you open the answer. Pick an option and press Check; it's marked on the spot.

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

Options

Show answer

Reading a after let b = a 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.

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.

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

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 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.#

Options

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.

What does this Rust program print?#

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

Options

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. 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.

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.

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.

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

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.

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

Pick every one that applies.

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. Plain i32 is Send (and Sync), so it crosses freely. 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. Rc<i32> is explicitly not Send: its non-atomic count would race, so it cannot be sent to another thread, so sending it 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, so sharing it 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.

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

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. The claim that v must be declared with let not let mut 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.

Related interview questions

Job market

See rust salaries and hiring demand from live job postings.

The other 94 questions

This page shows 10 and marks what you pick. That's as far as a page can go. A free account opens the other 94 and keeps every answer. What you miss comes back until it's right: after a day, then at longer gaps.

Start with this topic

Free · the whole bank · 100 answers per 30 days, each marked with written feedback

Interview booked? Practise the job ad instead

What moved, monthly

One email a month when the bulletin comes out: what moved in the markets we track, and the new question topics we published. Confirm your address to join. Unsubscribe any time.