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.
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 hereQ2: 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.
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 heapQ3: What are the rules for borrowing in Rust?
Rust's borrow checker enforces two rules about references at any given point in the code:
- You may have any number of immutable (
&T) references to a value, OR - 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.
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.
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.
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.
// 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.
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.
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.
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)); // FnQ10: 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.
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.
Sendcontrols which types may be moved across thread boundaries;Synccontrols which types may be shared across threads via&T.Mutex<T>andRwLock<T>are the onlySynctypes 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.
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).
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:
Arccan be cloned and sent to multiple threads. Once on another thread, code can call methods on the innerTthrough&T.- If
Twere notSync, multiple threads holdingArc<T>could simultaneously hold&Tand mutate shared state unsafely. - If
Twere notSend, ownership ofTcould 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.
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:
- Type safety:
MetresandSecondsare different types even though both holdf64. The compiler prevents accidentally passing one where the other is expected, catching unit-confusion bugs like the Mars Climate Orbiter. - Orphan rule workaround: you can implement foreign traits (e.g.
fmt::Display) on a local wrapper around a foreign type. - Abstraction: expose a limited, controlled API by not re-exporting the inner type's methods.
- Zero overhead: the wrapper has the same memory layout as the inner type; the abstraction is erased at compile time.
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:
- Dereference a raw pointer (
*const Tor*mut T): raw pointers may be null, dangling, or unaligned; dereferencing them is undefined behaviour if they are invalid. - Call an unsafe function or method: functions annotated
unsafe fnhave preconditions the caller must uphold manually. - Access or mutate a mutable static variable: global mutable state can cause data races; safe Rust prohibits direct access.
- Implement an unsafe trait: traits like
SendandSyncare unsafe to implement because the programmer vouches for thread-safety invariants the compiler cannot verify. - 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.
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."
// 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.