RustInterview Questions

Rust Interview Questions

Twenty questions commonly asked in Rust engineering interviews, from core language mechanics to advanced concurrency and performance topics. Each answer aims to explain the why behind the feature, not just the syntax.

Q1: What is ownership in Rust and what problem does it solve?

Ownership is Rust's mechanism for managing memory without a garbage collector and without manual malloc/free. Every value has exactly one owner — a variable — and when that variable goes out of scope the value is automatically dropped and its memory freed. Ownership can be transferred (moved) to a new owner, but there can never be two owners simultaneously.

This solves two classes of bugs simultaneously: memory leaks (forgetting to free) and use-after-free / double-free (freeing too early or twice). In languages with GC, reachability analysis prevents collection too early but adds runtime overhead. In C/C++, manual management is error-prone. Rust's approach is verified at compile time with zero runtime cost.

RUST
fn main() {
    let s1 = String::from("hello"); // s1 owns the heap data
    let s2 = s1;                    // ownership moves to s2
    // println!("{}", s1);          // compile error: s1 was moved
    println!("{}", s2);             // OK
}   // s2 goes out of scope → String is dropped here
Q2: What is the difference between stack and heap allocation in Rust?

The stack is a LIFO region of memory where fixed-size values are stored. All allocations and de-allocations are a single pointer increment/decrement — they are extremely fast. Primitives (i32, bool, char), fixed-size arrays, and structs with known sizes go on the stack by default.

The heap is a general-purpose region managed by the allocator. Allocations are slower (they require finding a free block) and the size can be determined at runtime. String, Vec, Box, and HashMap all put their data on the heap.

Rust makes this distinction explicit: you get stack allocation by default and must opt into heap allocation by wrapping a value in a smart pointer like Box. This clarity helps performance-sensitive code avoid unexpected allocations.

RUST
let stack_int: i32 = 42;              // on the stack
let heap_string = String::from("hi"); // pointer on stack, data on heap
let boxed: Box<i32> = Box::new(42);   // i32 explicitly placed on heap
Q3: What are the rules for borrowing in Rust?

Rust's borrow checker enforces two rules about references at any given point in the code:

  1. You may have any number of immutable (&T) references to a value, OR
  2. You may have exactly one mutable (&mut T) reference — but then no immutable references may exist simultaneously.

Additionally, all references must be valid for their entire lifetime — no reference may outlive the data it points to (no dangling pointers).

These rules map directly onto the real-world requirements of safe concurrency: if nobody can write while others read, there can be no data races. The borrow checker enforces this at compile time, not at runtime.

RUST
let mut s = String::from("hello");

let r1 = &s;     // OK — immutable borrow
let r2 = &s;     // OK — another immutable borrow
println!("{} {}", r1, r2); // r1, r2 used last time here

let r3 = &mut s; // OK — no active immutable borrows remain
r3.push_str(", world");
println!("{}", r3);
Q4: What is the difference between String and &str?

String is an owned, heap-allocated, growable UTF-8 string. It has full control over its data and can be appended to, truncated, or otherwise modified. When a String is dropped, its heap buffer is freed.

&str is a borrowed string slice — a fat pointer (pointer + length) into some UTF-8 data that lives elsewhere (a String, a string literal in the binary, or any other UTF-8 buffer). It is immutable and never owns the data.

The practical rule: prefer &str in function parameters (callers can pass both &String and string literals thanks to deref coercion), and use String when you need to own and/or build the string.

RUST
fn greet(name: &str) {          // accepts &str or &String
    println!("Hello, {}!", name);
}

fn main() {
    let owned  = String::from("Alice");
    let literal: &str = "Bob";

    greet(&owned);    // &String coerces to &str
    greet(literal);   // &str directly
    greet("Carol");   // string literal (&'static str)
}
Q5: What is the difference between Clone and Copy?

Copy is a marker trait for types whose bytes can be duplicated trivially — assigning or passing a Copy type silently bitcopies the value. All primitives and fixed-size aggregates of primitives implement Copy. A type cannot be Copy if it owns heap memory, because that would create two owners of the same buffer.

Clone is an explicit deep-copy trait. You call .clone() intentionally and the implementation may do expensive work (allocating, recursively cloning fields). Every Copy type also implements Clone, but the reverse is not true — String and Vec are Clone but not Copy.

The key difference is intention: Copy is silent and cheap; Clone is visible and potentially expensive.

RUST
let x: i32 = 5;
let y = x;          // Copy — x is still valid
println!("{} {}", x, y);

let s1 = String::from("hi");
// let s2 = s1;     // this would MOVE, not copy
let s2 = s1.clone(); // explicit deep copy
println!("{} {}", s1, s2);
Q6: What is a lifetime annotation and when do you need one explicitly?

A lifetime annotation (e.g. 'a) is a label that tells the compiler how the lifetimes of references in a function signature relate to one another. The compiler uses them to verify that no reference outlives the data it points to.

You need to write them explicitly when the compiler's lifetime elision rules cannot infer the relationship. The most common cases are:

  • A function takes multiple reference parameters and returns a reference, and the compiler cannot determine which input the return value borrows from.
  • A struct holds a reference field — the struct must not outlive the data.

If a function takes one reference parameter and returns a reference, elision handles it automatically.

RUST
// Explicit lifetime required — compiler cannot guess which input
// the returned reference relates to.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

// Struct holding a reference — must declare lifetime
struct Important<'a> {
    content: &'a str,
}
Q7: What is the difference between Rc<T> and Arc<T>?

Both Rc<T> and Arc<T> provide shared ownership of a heap-allocated value using reference counting: the data is dropped only when the last clone is dropped.

The difference is thread safety. Rc<T> updates its reference count with plain integer arithmetic — fast but not thread-safe. The Rust compiler marks Rc as !Send and !Sync, so you cannot move or share it across thread boundaries.

Arc<T> (Atomic RC) uses atomic CPU instructions to update the count, which is safe from multiple threads simultaneously. This makes Arc slightly slower than Rc but enables use in concurrent code. The rule of thumb: default to Rc inside a single thread, switch to Arc when you need to share across threads.

RUST
use std::sync::Arc;
use std::thread;

let data = Arc::new(vec![1, 2, 3]);
let data2 = Arc::clone(&data);

let handle = thread::spawn(move || {
    println!("{:?}", data2);  // Arc is Send — this compiles
});
handle.join().unwrap();
Q8: What is interior mutability? Give an example with RefCell.

Interior mutability is a pattern that allows mutation of data through a shared (&T) reference, which normally forbids mutation. Rust's ownership rules are usually enforced at compile time, but interior mutability shifts those checks to runtime.

RefCell<T> is the primary single-threaded interior mutability primitive. You call .borrow() to get a Ref<T> (shared borrow) or .borrow_mut() to get a RefMut<T> (exclusive borrow). If you violate the borrow rules (e.g. taking two mutable borrows), RefCell panics at runtime instead of failing at compile time.

Common use case: a graph or tree node that multiple owners (Rc) need to mutate.

RUST
use std::cell::RefCell;
use std::rc::Rc;

let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone  = Rc::clone(&shared);

// Mutate through the first clone
shared.borrow_mut().push(4);

// Read through the second clone
println!("{:?}", clone.borrow()); // [1, 2, 3, 4]
Q9: What are the three Fn traits (Fn, FnMut, FnOnce) and when is each used?

Every closure automatically implements one or more of:

  • FnOnce — the closure can be called at most once. It may consume (move out of) its captured variables, which is why it can only run once. All closures implement this.
  • FnMut — the closure can be called multiple times and may mutate its captured variables. Closures that do not consume captures implement this.
  • Fn — the closure can be called multiple times and only reads (or does not use) its captured variables. The most restrictive; implemented by the most closure types.

The hierarchy is: every Fn is also FnMut and FnOnce; every FnMut is also FnOnce. When accepting a closure as a parameter, use the weakest bound that satisfies your needs to maximise the range of acceptable closures.

RUST
fn call_once<F: FnOnce() -> String>(f: F) -> String { f() }
fn call_mut<F: FnMut() -> i32>(mut f: F) { println!("{}", f()); println!("{}", f()); }
fn call_shared<F: Fn(i32) -> i32>(f: F) -> i32 { f(1) + f(2) }

let name = String::from("Alice");
println!("{}", call_once(|| format!("Hello, {}!", name))); // FnOnce

let mut n = 0;
call_mut(|| { n += 1; n }); // FnMut

let factor = 3;
println!("{}", call_shared(|x| x * factor)); // Fn
Q10: What is the difference between impl Trait and dyn Trait?

Both allow functions to work with values that implement a trait, but they do so through different mechanisms.

impl Trait (static dispatch / monomorphization): the compiler knows the concrete type at compile time and generates a specialized copy of the code for each type. Zero runtime overhead, but the binary may grow (code bloat). The concrete type cannot vary at runtime — both branches of an if must return the same type.

dyn Trait (dynamic dispatch / virtual dispatch): the concrete type is not known until runtime. The value is a fat pointer to the data plus a vtable (function pointer table). There is a small runtime cost (vtable lookup + possible cache miss) but you can hold values of different concrete types in the same collection or return different types from the same function.

RUST
trait Animal { fn speak(&self) -> &str; }
struct Dog; impl Animal for Dog { fn speak(&self) -> &str { "woof" } }
struct Cat; impl Animal for Cat { fn speak(&self) -> &str { "meow" } }

// Static — concrete type known at compile time
fn make_sound_static(a: &impl Animal) { println!("{}", a.speak()); }

// Dynamic — type unknown at compile time; works for a heterogeneous list
fn make_sounds_dynamic(animals: &[Box<dyn Animal>]) {
    for a in animals { println!("{}", a.speak()); }
}

fn main() {
    let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
    make_sounds_dynamic(&zoo);
}
Q11: How does Rust prevent data races at compile time?

Rust prevents data races through a combination of its ownership model, the Send and Sync marker traits, and the borrow checker.

A data race requires three conditions: two or more threads access the same memory concurrently, at least one access is a write, and the accesses are not synchronized. Rust eliminates the second and third conditions at the type level:

  • The borrow checker prevents two mutable references from existing simultaneously even within a single thread.
  • Send controls which types may be moved across thread boundaries; Sync controls which types may be shared across threads via &T.
  • Mutex<T> and RwLock<T> are the only Sync types that expose &mut T — and they require acquiring a lock first, providing the necessary synchronization.

The result: if your multi-threaded Rust program compiles, data races are impossible (barring unsafe code with incorrect invariants).

Q12: What is monomorphization and what are its trade-offs compared to dynamic dispatch?

Monomorphization is the compile-time process of replacing each use of a generic function or type with a concrete, specialized version for each type it is used with. For example, fn max<T: PartialOrd>(a: T, b: T) -> T called with i32 generates a concrete max_i32 function internally.

Advantages: zero runtime overhead (no vtable), better inlining and optimization, type errors caught at the call site.

Disadvantages: longer compile times and potentially larger binaries (code bloat), because a new copy of the function is emitted for every distinct type.

Dynamic dispatch (dyn Trait) has the opposite trade-offs: one copy of the code exists for all types (smaller binary), but each call goes through a vtable (slightly slower, prevents inlining). For hot code paths, prefer generics; for heterogeneous collections or plugin-style code, dyn Trait may be better.

Q13: What is the orphan rule and why does it exist?

The orphan rule states that you can implement a trait for a type only if at least one of the following is true: the trait is defined in your crate, or the type is defined in your crate. You cannot implement a foreign trait for a foreign type.

It exists to guarantee coherence — the guarantee that there is at most one implementation of a trait for any given type in a program. Without it, two crates could each provide impl Display for Vec<i32>, and the compiler would have no way to choose between them when both crates are depended on. The rule prevents these conflicts from arising.

The newtype pattern is the standard workaround: wrap the foreign type in a local tuple struct and implement the trait on the wrapper.

RUST
use std::fmt;

struct Wrapper(Vec<String>);         // local type wrapping foreign Vec

impl fmt::Display for Wrapper {      // Display is foreign, but Wrapper is local
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec!["a".to_string(), "b".to_string()]);
    println!("{}", w);
}
Q14: When should you use panic! vs returning a Result?

Use panic! for bugs — situations that should never happen if the code is correct: violated invariants, incorrect API usage, assertion failures in tests. A panic means "the program reached an impossible state; crashing is safer than continuing." Examples: unwrap() in a test, index out of bounds, a logic assertion.

Use Result for expected, recoverable failures: file not found, network timeout, invalid user input, a parse failure. These are conditions you anticipate and want the caller to handle gracefully. Propagate them with ? and handle them at the appropriate layer.

A useful heuristic: if the error can be caused by data or conditions outside your code's control, return Result. If it indicates a programming mistake inside your code, panic.

Q15: How does the ? operator work and what trait does it use?

The ? operator is shorthand for: "if this Result (or Option) is Err (or None), convert the error and return early from the current function; otherwise, unwrap the Ok (or Some) value and continue."

Under the hood it desugars roughly to:

match expr {
    Ok(val)  => val,
    Err(e)   => return Err(From::from(e)),
}

The From::from(e) conversion is what allows ? to work across different error types — as long as the returned error type implements From<IncomingError>. This is why Box<dyn Error> and crates like anyhow work so well: they implement From for virtually every error type.

The underlying trait is std::ops::Try (stabilised as part of the Rust ecosystem; the operator itself uses it internally).

RUST
use std::fs;
use std::io;

fn read_username(path: &str) -> Result<String, io::Error> {
    // ? returns early with Err if read_to_string fails
    let text = fs::read_to_string(path)?;
    Ok(text.trim().to_string())
}
Q16: What is the Send marker trait and why does Arc require it?

Send is a marker trait (no methods) that signals a type is safe to transfer ownership to another thread. The compiler automatically implements Send for most types. Exceptions include Rc<T> (non-atomic reference count) and raw pointers.

Arc<T> requires T: Send + Sync for its own Send implementation to be satisfied. Here is why:

  • Arc can be cloned and sent to multiple threads. Once on another thread, code can call methods on the inner T through &T.
  • If T were not Sync, multiple threads holding Arc<T> could simultaneously hold &T and mutate shared state unsafely.
  • If T were not Send, ownership of T could end up on a thread that isn't safe to own it.

The compiler therefore refuses to compile Arc::clone (which creates a second owning thread reference) if T does not satisfy both bounds.

Q17: What is a trait object and when is it appropriate?

A trait object is a fat pointer — a pair of (data pointer, vtable pointer) — that refers to some value implementing a trait, without the concrete type being known at compile time. You write them as &dyn Trait or Box<dyn Trait>.

Trait objects are appropriate when:

  • You need a heterogeneous collection — e.g. Vec<Box<dyn Draw>> storing circles, squares, and triangles all at once.
  • The concrete type is determined at runtime (e.g. loaded from a config or built via a factory).
  • You want to avoid monomorphization code bloat in large codebases.
  • You are building a plugin architecture where concrete types are not known at compile time.

They are not appropriate for hot loops where the vtable indirection cost would be measurable; use generics there.

RUST
trait Draw { fn draw(&self); }
struct Circle;  impl Draw for Circle  { fn draw(&self) { println!("circle"); } }
struct Square;  impl Draw for Square  { fn draw(&self) { println!("square"); } }

fn render(shapes: &[Box<dyn Draw>]) {
    for s in shapes { s.draw(); }
}

fn main() {
    let shapes: Vec<Box<dyn Draw>> = vec![Box::new(Circle), Box::new(Square)];
    render(&shapes);
}
Q18: What is the newtype pattern and what problems does it solve?

The newtype pattern wraps an existing type in a single-field tuple struct to give it a distinct identity:

struct Metres(f64);
struct Seconds(f64);

It solves several problems:

  1. Type safety: Metres and Seconds are different types even though both hold f64. The compiler prevents accidentally passing one where the other is expected, catching unit-confusion bugs like the Mars Climate Orbiter.
  2. Orphan rule workaround: you can implement foreign traits (e.g. fmt::Display) on a local wrapper around a foreign type.
  3. Abstraction: expose a limited, controlled API by not re-exporting the inner type's methods.
  4. Zero overhead: the wrapper has the same memory layout as the inner type; the abstraction is erased at compile time.

RUST
struct Metres(f64);
struct Seconds(f64);

fn speed(distance: Metres, time: Seconds) -> f64 {
    distance.0 / time.0
}

fn main() {
    let d = Metres(100.0);
    let t = Seconds(9.58);
    // speed(t, d); // compile error — arguments swapped!
    println!("{:.2} m/s", speed(d, t));
}
Q19: What are the five things you can do in unsafe Rust that you cannot do in safe Rust?

Rust's unsafe keyword unlocks exactly five additional capabilities:

  1. Dereference a raw pointer (*const T or *mut T): raw pointers may be null, dangling, or unaligned; dereferencing them is undefined behaviour if they are invalid.
  2. Call an unsafe function or method: functions annotated unsafe fn have preconditions the caller must uphold manually.
  3. Access or mutate a mutable static variable: global mutable state can cause data races; safe Rust prohibits direct access.
  4. Implement an unsafe trait: traits like Send and Sync are unsafe to implement because the programmer vouches for thread-safety invariants the compiler cannot verify.
  5. Access fields of a union: reading a union field requires knowing which variant was last written, which the compiler cannot track.

Everything else in unsafe blocks — arithmetic, control flow, function calls to safe functions — remains governed by the usual rules.

RUST
let mut x: i32 = 42;
let r: *mut i32 = &mut x;   // create raw pointer (safe)

unsafe {
    *r += 1;                  // dereference raw pointer (unsafe)
    println!("{}", *r);       // 43
}
Q20: What does "zero-cost abstractions" mean in Rust? Give an example.

"Zero-cost abstractions" is the principle, borrowed from C++, that high-level language features should compile to the same machine code you would write by hand in a low-level language — they add no runtime overhead.

In Rust, generics are a prime example. When you write a generic function, the compiler produces a separate, fully optimized, concrete copy for each type it is called with (monomorphization). There is no boxing, no virtual dispatch, no type tag at runtime. The abstraction costs nothing at runtime; you pay only in compile time.

Iterators are another: a chain of .filter().map().collect() compiles to the same loop a skilled C programmer would write. The compiler inlines each closure and adaptor, eliminating all intermediate allocations and function call overhead.

The quote from Bjarne Stroustrup (later adopted by Rust): "What you don't use, you don't pay for. What you do use, you couldn't hand-code any better."

RUST
// High-level: iterator chain with closures
fn sum_even_squares(v: &[i32]) -> i32 {
    v.iter()
     .filter(|&&x| x % 2 == 0)
     .map(|&x| x * x)
     .sum()
}

// The compiler produces code equivalent to this hand-written loop:
fn sum_even_squares_manual(v: &[i32]) -> i32 {
    let mut total = 0;
    for &x in v {
        if x % 2 == 0 { total += x * x; }
    }
    total
}
// Both compile to identical (or nearly identical) machine code.
Success
These twenty questions cover the topics most commonly tested in Rust engineering interviews. For deeper practice, combine each answer with hands-on coding — write small programs that exercise each concept and experiment with the compile errors the borrow checker gives you.