Rust Developer Interview Questions — 35 with Code and Answers
Rust interviews are different from most language interviews. The compiler enforces concepts that other languages leave implicit — ownership, borrowing, lifetimes — so interviewers can dig deep into *why* the code compiles or doesn't, not just whether you know the syntax. Expect conceptual questions, code that won't compile (and you need to explain why), and design tradeoffs.
This guide covers 35 questions across every topic that appears in real Rust interviews at systems programming and backend roles. Each question includes a code answer, what the interviewer is actually testing, and the mistakes that get candidates cut.
Ownership and Borrowing
1. Explain Rust's ownership model. Why does it exist?
What interviewers look for: Understanding that ownership is a compile-time mechanism to guarantee memory safety without garbage collection. They want to hear you talk about the trade-off: you pay in programmer effort at compile time, and you get zero-cost, predictable memory management at runtime.
Answer:
Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped and its memory is freed. Ownership can be *moved* to another binding, at which point the original binding is invalid.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 is moved into s2
// println!("{}", s1); // compile error: s1 is moved
println!("{}", s2);
}This eliminates double-free errors and use-after-free bugs at compile time. There is no runtime GC pause because the compiler knows exactly when memory should be freed.
Common pitfall: Candidates say "Rust has no garbage collector" but can't explain what replaces it. The answer is deterministic drop via scope-based ownership — not GC, not manual malloc/free.
2. What is the difference between moving and copying a value?
What interviewers look for: Knowledge of the Copy trait and which types implement it, plus understanding of stack vs heap allocation.
// Copy: stored entirely on the stack, trivially duplicated
let x: i32 = 5;
let y = x; // x is copied, not moved
println!("{} {}", x, y); // both valid
// Move: heap-allocated, non-trivial to copy
let s1 = String::from("hello");
let s2 = s1; // s1 is moved
// s1 is invalid here
// Explicit clone for heap types
let s3 = String::from("world");
let s4 = s3.clone(); // deep copy, both valid
println!("{} {}", s3, s4);Types that implement Copy: all integer types, f32/f64, bool, char, tuples of Copy types, arrays of Copy types. Types that own heap memory (like String, Vec) do not implement Copy by default.
Common pitfall: Assuming .clone() is always cheap. For a Vec, clone recursively copies the entire structure.
3. Explain the borrowing rules. What are the two kinds of references?
What interviewers look for: Clear articulation of the rules: any number of shared references OR exactly one mutable reference — never both at the same time.
fn main() {
let mut s = String::from("hello");
// Multiple shared (immutable) references — fine
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);
// r1 and r2 are no longer used after this point
// Mutable reference — fine because r1/r2 are out of scope
let r3 = &mut s;
r3.push_str(", world");
println!("{}", r3);
}The rule that prevents simultaneous mutable and immutable references eliminates data races at compile time in concurrent code and prevents iterator invalidation in sequential code.
Common pitfall: Thinking borrows are tied to lexical scopes. Since Rust 2018, borrow lifetimes end at the last point of use (Non-Lexical Lifetimes), so this code works even though r1 and r2 appear before r3 in the same block.
4. What is a dangling reference, and how does Rust prevent it?
// This does not compile:
fn dangle() -> &String {
let s = String::from("hello");
&s // s is dropped at end of function, reference would dangle
} // error[E0106]: missing lifetime specifier
// Correct approach: return the String itself
fn no_dangle() -> String {
let s = String::from("hello");
s // ownership is moved out, no drop occurs
}The lifetime checker sees that the reference &s would outlive the data it points to and rejects the code. This is a class of bug that causes real crashes in C/C++ programs and is simply impossible in safe Rust.
5. What is interior mutability? When would you use `Cell` or `RefCell`?
What interviewers look for: Understanding that this is an escape hatch for cases where the borrow rules are too conservative, and knowing the runtime cost.
use std::cell::RefCell;
struct Node {
value: i32,
children: RefCell<Vec<Node>>,
}
impl Node {
fn add_child(&self, child: Node) {
// &self is shared, but we can mutate children at runtime
self.children.borrow_mut().push(child);
}
}Cell is for Copy types (no runtime cost, just copies). RefCell moves the borrow check to runtime — borrow() and borrow_mut() will panic if the invariants are violated. Use it when you have a logically single-threaded graph or when implementing certain recursive structures. For multi-threaded code, use Mutex instead.
Lifetimes and Lifetime Annotations
6. What is a lifetime annotation? Write a function that requires one.
What interviewers look for: That you understand lifetime annotations don't *change* how long references live — they describe relationships between lifetimes that the compiler cannot infer on its own.
// Without annotation: compiler can't know if output lifetime
// relates to x or y
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xy");
result = longest(s1.as_str(), s2.as_str());
println!("{}", result); // fine: result used within s2's lifetime
}
}'a says: the output reference lives at least as long as the shorter of x and y. The compiler enforces this at every call site.
7. What is the `'static` lifetime? Is it always safe to use?
// String literals have 'static lifetime — they're in the binary
let s: &'static str = "I live forever";
// Trait objects sometimes require 'static
fn spawn_thread(f: impl Fn() + Send + 'static) {
std::thread::spawn(f);
}
// This won't compile — local reference can't be 'static
fn bad_static<'a>(s: &'a str) -> &'static str {
s // error: lifetime 'a is not 'static
}'static does not mean "lives forever in all contexts" — it means "this reference is valid for the entire program duration." Using it as a bound on thread closures is correct (the thread may outlive the caller). Indiscriminately using 'static to silence lifetime errors usually means cloning data when you should be redesigning ownership instead.
8. What are lifetime elision rules?
The compiler applies three rules before requiring explicit annotations:
- 1Each reference parameter gets its own lifetime parameter.
- 2If there is exactly one input lifetime parameter, it is assigned to all output lifetime parameters.
- 3If one of the input parameters is
&selfor&mut self, the lifetime ofselfis assigned to all output lifetime parameters.
// These two signatures are equivalent:
fn first_word(s: &str) -> &str { /* ... */ }
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
// Three parameters — compiler can't infer, you must annotate:
fn three_refs<'a, 'b>(x: &'a str, y: &'b str, z: &str) -> &'a str {
x
}9. What is a lifetime bound on a struct? When do you need one?
// Any struct holding a reference must declare its lifetime
struct Important<'a> {
excerpt: &'a str,
}
impl<'a> Important<'a> {
fn announce(&self, announcement: &str) -> &str {
println!("Attention: {}", announcement);
self.excerpt // elision rule 3 applies: returns 'a
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("no period");
let i = Important { excerpt: first_sentence };
println!("{}", i.excerpt);
}The struct Important cannot outlive the reference it holds. The compiler enforces this at every use site.
Trait System and Generics
10. What is a trait? How does it differ from an interface in Java or Go?
What interviewers look for: Understanding of static dispatch (monomorphization) vs dynamic dispatch (dyn Trait), and the zero-cost abstraction principle.
trait Drawable {
fn draw(&self);
fn bounding_box(&self) -> (f64, f64, f64, f64) {
(0.0, 0.0, 100.0, 100.0) // default implementation
}
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Drawable for Circle {
fn draw(&self) { println!("Drawing circle r={}", self.radius); }
}
impl Drawable for Square {
fn draw(&self) { println!("Drawing square s={}", self.side); }
}
// Static dispatch: compiler generates separate code for each T
fn render_static<T: Drawable>(shape: &T) { shape.draw(); }
// Dynamic dispatch: single function, vtable lookup at runtime
fn render_dynamic(shape: &dyn Drawable) { shape.draw(); }With static dispatch, generics are monomorphized at compile time — no vtable, no indirection, fully inlineable. With dyn Trait, you get runtime polymorphism at the cost of a vtable lookup. Java interfaces are always dynamic dispatch. Go interfaces are always dynamic dispatch. Rust gives you the choice.
11. What are trait bounds? Show `where` clause syntax.
use std::fmt::{Display, Debug};
// Inline bounds
fn print_info<T: Display + Debug>(value: T) {
println!("Display: {}", value);
println!("Debug: {:?}", value);
}
// Where clause — cleaner for complex bounds
fn complex_operation<T, U>(t: T, u: U) -> String
where
T: Display + Clone,
U: Debug + Clone + PartialOrd,
{
format!("{} {:?}", t.clone(), u.clone())
}Prefer where clauses when bounds are long or when the same type appears multiple times. They keep the function signature readable.
12. What is the orphan rule?
You can only implement a trait for a type if either the trait or the type is defined in your crate. You cannot implement Display for Vec in your own crate because both are from std.
// Fine: your trait, external type
trait MyTrait { fn hello(&self); }
impl MyTrait for Vec<i32> { fn hello(&self) { println!("hello"); } }
// Fine: external trait, your type
struct MyStruct;
impl std::fmt::Display for MyStruct {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "MyStruct")
}
}
// Not allowed: both external
// impl std::fmt::Display for Vec<i32> {} // error E0117This rule prevents two crates from providing conflicting implementations of the same trait for the same type.
13. What is a blanket implementation?
// From the standard library — implements ToString for any type
// that implements Display
impl<T: Display> ToString for T {
fn to_string(&self) -> String {
format!("{}", self)
}
}A blanket implementation applies a trait to any type satisfying certain bounds. They enable powerful abstraction but can cause "multiple applicable implementations" errors if you're not careful.
14. Explain `impl Trait` in return position. When can't you use it?
// Return position impl Trait: caller gets some concrete type
// that implements the trait, but doesn't know which one
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
// Problem: different branches return different concrete types
fn make_shape(circle: bool) -> impl Drawable {
if circle {
Circle { radius: 1.0 }
// Square { side: 1.0 } // error: mismatched types
} else {
Circle { radius: 2.0 } // must be the same concrete type
}
}
// When you need different types: use Box<dyn Trait>
fn make_any_shape(circle: bool) -> Box<dyn Drawable> {
if circle {
Box::new(Circle { radius: 1.0 })
} else {
Box::new(Square { side: 1.0 })
}
}impl Trait in return position is a compile-time abstraction. All code paths must return the same concrete type. If you need different types at runtime, use Box.
Enums and Pattern Matching
15. How are Rust enums different from enums in C or Java?
Rust enums are algebraic data types — each variant can hold different data.
#[derive(Debug)]
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
fn process(msg: Message) {
match msg {
Message::Quit => println!("Quit"),
Message::Move { x, y } => println!("Move to ({}, {})", x, y),
Message::Write(text) => println!("Write: {}", text),
Message::ChangeColor(r, g, b) => println!("Color: {},{},{}", r, g, b),
}
}C enums are just named integers. Java enums can have methods but only fixed instances. Rust enums are closer to Haskell/ML sum types — they're the correct tool for modeling state machines, AST nodes, protocol messages, and any domain with distinct structural variants.
16. What is exhaustive pattern matching? How do you handle the default case?
The match expression in Rust must be exhaustive — every possible value must be handled. The compiler enforces this.
enum Direction { North, South, East, West }
fn describe(d: Direction) -> &'static str {
match d {
Direction::North => "cold",
Direction::South => "warm",
// Wildcard _ handles remaining cases
_ => "sideways",
}
}
// With if let for single-variant matching
fn maybe_move(msg: &Message) {
if let Message::Move { x, y } = msg {
println!("Moving to {}, {}", x, y);
}
}
// With while let for iterating
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("{}", top);
}Common pitfall: Adding _ too eagerly. If you add a new enum variant later, _ will silently swallow it. Prefer explicit matching unless you have a good reason for a catch-all.
17. Explain `Option<T>`. How does it replace null?
fn find_user(id: u32) -> Option<String> {
if id == 1 { Some(String::from("Alice")) } else { None }
}
fn main() {
// Must handle both cases explicitly
match find_user(1) {
Some(name) => println!("Found: {}", name),
None => println!("Not found"),
}
// Combinator style — often cleaner
let greeting = find_user(1)
.map(|name| format!("Hello, {}!", name))
.unwrap_or_else(|| String::from("Hello, stranger!"));
println!("{}", greeting);
// unwrap() panics on None — only use in tests or when logically impossible
let name = find_user(1).unwrap();
}Option forces you to handle the absent case at compile time. There is no null pointer dereference in safe Rust.
Error Handling
18. What is `Result<T, E>`? How do you propagate errors?
use std::fs::File;
use std::io::{self, Read};
// Manual propagation
fn read_file_manual(path: &str) -> Result<String, io::Error> {
let mut f = match File::open(path) {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut contents = String::new();
match f.read_to_string(&mut contents) {
Ok(_) => Ok(contents),
Err(e) => Err(e),
}
}
// The ? operator desugars to the manual match above
fn read_file(path: &str) -> Result<String, io::Error> {
let mut contents = String::new();
File::open(path)?.read_to_string(&mut contents)?;
Ok(contents)
}The ? operator: if the Result is Ok, unwrap and continue. If it's Err, return the error from the current function (applying From conversion if needed). It can only be used in functions that return Result or Option.
19. How do you handle multiple error types?
use std::num::ParseIntError;
use std::fmt;
// Option 1: Box<dyn Error> — simple, loses type information
fn parse_and_double(s: &str) -> Result<i32, Box<dyn std::error::Error>> {
let n: i32 = s.trim().parse()?;
Ok(n * 2)
}
// Option 2: Custom error enum — more verbose, fully typed
#[derive(Debug)]
enum AppError {
Parse(ParseIntError),
TooBig,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Parse(e) => write!(f, "parse error: {}", e),
AppError::TooBig => write!(f, "number too big"),
}
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn typed_parse(s: &str) -> Result<i32, AppError> {
let n: i32 = s.trim().parse()?; // ? uses From<ParseIntError>
if n > 1000 { return Err(AppError::TooBig); }
Ok(n)
}For production code, the thiserror crate generates Display and From implementations from derive macros. anyhow provides an ergonomic Box with context chaining — good for application code, less good for library code where callers need to match on error variants.
20. When should you use `unwrap()` vs `expect()` vs `?`?
unwrap(): panics with a generic message. Use only in tests or when theNone/Errcase is logically impossible and you want to document that.expect("message"): panics with a custom message. Better thanunwrap()in scripts or when you want to explain the invariant.?: propagates the error to the caller. Use in production code..unwrap_or(),.unwrap_or_else(),.unwrap_or_default(): provide fallback values. Use when you have a sensible default.
// Good: impossible None, clear invariant
let home = std::env::var("HOME").expect("HOME must be set on Unix");
// Good: propagate to caller
fn process(path: &str) -> Result<(), AppError> {
let content = std::fs::read_to_string(path)?;
// ...
Ok(())
}
// Bad in production:
let config = load_config().unwrap(); // silent panic if config is missingAsync/Await and Tokio
21. How does async/await work in Rust?
What interviewers look for: Understanding that async fn returns a Future, that Futures are lazy (nothing happens until polled), and that an executor is needed.
use tokio;
async fn fetch_data(id: u32) -> String {
// In real code: reqwest, sqlx, etc.
format!("data for {}", id)
}
async fn process() {
// await suspends the current task, yields to executor
let result = fetch_data(42).await;
println!("{}", result);
}
#[tokio::main]
async fn main() {
process().await;
}An async fn is syntactic sugar that transforms the function body into a state machine implementing the Future trait. The #[tokio::main] macro sets up Tokio's multi-threaded executor which drives the futures to completion.
22. What is `tokio::spawn`? What trait bounds does the closure need?
use tokio;
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
// This runs on the Tokio thread pool
println!("running in background");
42u32
});
// JoinHandle is itself a Future
let result = handle.await.unwrap();
println!("got: {}", result);
}The closure passed to tokio::spawn must be Send + 'static because Tokio may move the task between threads. This is the most common compile error when working with async Rust — you try to hold a Rc or a non-Send type across an .await point.
// This fails:
async fn broken() {
let rc = std::rc::Rc::new(1);
tokio::spawn(async move {
println!("{}", rc); // Rc is not Send
});
}23. What is the difference between `tokio::join!` and sequential awaits?
use tokio;
async fn fetch(id: u32) -> u32 { id * 2 }
#[tokio::main]
async fn main() {
// Sequential: total time = time(fetch(1)) + time(fetch(2))
let a = fetch(1).await;
let b = fetch(2).await;
// Concurrent: total time = max(time(fetch(1)), time(fetch(2)))
let (a, b) = tokio::join!(fetch(1), fetch(2));
println!("{} {}", a, b);
}join! polls all futures concurrently on the same task. For independent I/O operations (two database queries, two HTTP requests), always use join! or tokio::spawn — not sequential awaits.
24. What is `async fn` in a trait? What problem does it cause?
Before Rust 1.75, async fn in traits was not stable. The workaround was returning Pin.
use std::future::Future;
use std::pin::Pin;
// Pre-1.75 workaround
trait Fetcher {
fn fetch(&self, url: &str)
-> Pin<Box<dyn Future<Output = String> + Send + '_>>;
}
// Rust 1.75+: async fn in traits works natively
// (but has limitations with dyn dispatch)
trait FetcherNew {
async fn fetch(&self, url: &str) -> String;
}The async-trait crate (proc macro) generates the Pin boilerplate automatically and is still the pragmatic choice for object-safe async traits.
Smart Pointers
25. When do you use `Box<T>`?
// 1. Recursive types need Box to break the infinite-size cycle
#[derive(Debug)]
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
// 2. Large values you want on the heap
let big_array = Box::new([0u8; 1_000_000]);
// 3. Trait objects
fn make_drawable() -> Box<dyn Drawable> {
Box::new(Circle { radius: 1.0 })
}Box is the simplest smart pointer — single owner, heap allocation, no runtime overhead beyond the allocation itself. Drop is deterministic: when Box goes out of scope, it frees the heap memory.
26. What is `Rc<T>` and when do you need it?
use std::rc::Rc;
fn main() {
let a = Rc::new(5);
let b = Rc::clone(&a); // increments reference count, not a deep clone
let c = Rc::clone(&a);
println!("count: {}", Rc::strong_count(&a)); // 3
drop(b);
println!("count: {}", Rc::strong_count(&a)); // 2
} // a and c dropped here, memory freedRc is reference counted — multiple owners, single thread only. Use it for graph-like structures, shared configuration, or any time two parts of the same-thread code need to share data without a clear single owner. For multi-threaded sharing, use Arc (atomically reference counted).
27. What is the `Rc<RefCell<T>>` pattern?
use std::rc::Rc;
use std::cell::RefCell;
// Multiple owners that can mutate — graph nodes, for example
type SharedNode = Rc<RefCell<Vec<i32>>>;
fn main() {
let shared: SharedNode = Rc::new(RefCell::new(vec![1, 2, 3]));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
a.borrow_mut().push(4);
b.borrow_mut().push(5);
println!("{:?}", shared.borrow()); // [1, 2, 3, 4, 5]
}Rc gives you shared ownership with interior mutability, moving the borrow check to runtime. The multi-threaded equivalent is Arc. Use Rc for single-threaded scenarios like UI trees or interpreter environments.
28. When does `Rc` cause a memory leak, and how do you fix it?
use std::rc::{Rc, Weak};
use std::cell::RefCell;
// Rc cycles prevent reference count from reaching zero
struct Node {
value: i32,
// Use Weak to break cycles
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}If two Rc values reference each other, neither reference count ever reaches zero and the memory leaks. Use Weak (a non-owning reference that doesn't increment the strong count) for back-references: parent pointers in trees, observer patterns, any back-edge in a graph.
Closures and Iterators
29. Explain the three closure traits: `Fn`, `FnMut`, `FnOnce`.
fn apply_once<F: FnOnce() -> String>(f: F) -> String {
f() // can only call once — f may have moved out of values
}
fn apply_mut<F: FnMut() -> i32>(mut f: F) -> i32 {
f() + f() // can call multiple times, may mutate captured state
}
fn apply<F: Fn() -> i32>(f: F) -> i32 {
f() + f() // can call multiple times, read-only capture
}
fn main() {
let s = String::from("hello");
// FnOnce: moves s
let once = move || s;
println!("{}", apply_once(once));
let mut count = 0;
// FnMut: mutates count
let mut counter = || { count += 1; count };
println!("{}", apply_mut(&mut counter));
let x = 10;
// Fn: immutably borrows x
let adder = || x + 1;
println!("{}", apply(adder));
}FnOnce ⊇ FnMut ⊇ Fn. A bound of Fn is the most restrictive (caller can call as many times as it wants). A bound of FnOnce is the most permissive (caller only needs to call it once). Use the least restrictive bound that works.
30. How do Rust iterators achieve zero-cost abstraction?
fn main() {
let v = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// This chain compiles to a single loop with no intermediate allocations
let sum: i32 = v.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.sum();
println!("{}", sum); // 220
// Custom iterator
struct Counter { count: u32 }
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<u32> {
self.count += 1;
if self.count <= 5 { Some(self.count) } else { None }
}
}
let sum: u32 = Counter { count: 0 }
.zip(Counter { count: 0 }.skip(1))
.map(|(a, b)| a * b)
.filter(|x| x % 3 == 0)
.sum();
println!("{}", sum);
}Iterator adaptors are lazy structs that implement Iterator. The compiler inlines and fuses the chain into a single loop — no heap allocation, no runtime overhead. LLVM can vectorize the result. This is what "zero-cost abstraction" means: you write high-level combinator code and get assembly equivalent to hand-written loops.
31. What is `collect()` and why does it sometimes need a type annotation?
fn main() {
// collect() can produce many different types
let doubled: Vec<i32> = (1..=5).map(|x| x * 2).collect();
// HashMap needs turbofish or type annotation
use std::collections::HashMap;
let map: HashMap<i32, i32> = (1..=5).map(|x| (x, x * x)).collect();
// Collect into Result — all-or-nothing
let strings = vec!["1", "2", "three", "4"];
let numbers: Result<Vec<i32>, _> = strings.iter()
.map(|s| s.parse::<i32>())
.collect();
println!("{:?}", numbers); // Err(ParseIntError)
}collect() is generic over any type implementing FromIterator. The compiler needs to know the target type, either from a type annotation on the binding or from turbofish syntax (.collect::).
Unsafe Rust
32. What can you do in `unsafe` blocks that you can't do in safe Rust?
fn main() {
// 1. Dereference raw pointers
let x = 5;
let raw = &x as *const i32;
unsafe {
println!("{}", *raw);
}
// 2. Call unsafe functions
unsafe fn dangerous() { println!("unsafe"); }
unsafe { dangerous(); }
// 3. Access or modify mutable static variables
static mut COUNTER: u32 = 0;
unsafe {
COUNTER += 1;
println!("{}", COUNTER);
}
// 4. Implement unsafe traits
// unsafe trait Foo {}
// unsafe impl Foo for Bar {}
// 5. Access fields of unions
}unsafe does not turn off the borrow checker — it widens what you're allowed to do within a sound API you're responsible for. The five unsafe superpowers are: raw pointer dereference, calling unsafe functions, accessing mutable statics, implementing unsafe traits, and accessing union fields.
33. What is `unsafe` soundness vs safety? What is undefined behavior in Rust?
What interviewers look for: Understanding that unsafe is a contract between the programmer and the compiler — you assert that certain invariants hold that the compiler cannot verify.
Undefined behavior in Rust includes: data races, dereferencing null or dangling raw pointers, breaking pointer aliasing rules (two &mut references to the same memory), producing invalid values for a type (e.g., a bool that is neither 0 nor 1), and calling a C function incorrectly.
// This is unsound — it creates a reference to dropped memory
fn unsound() -> &'static str {
let s = String::from("hello");
// transmuting a local reference to 'static is UB
unsafe { std::mem::transmute(s.as_str()) }
// s is dropped here, reference is dangling
}The rule: a safe function must never cause UB no matter what valid inputs it receives. An unsafe function may require the caller to uphold additional invariants documented in the function's safety contract.
Testing and Documentation
34. How do you write unit tests, integration tests, and documentation tests in Rust?
// src/lib.rs
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// assert_eq!(mylib::add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, 1), 0);
}
#[test]
#[should_panic(expected = "attempt to add with overflow")]
fn test_overflow() {
add(i32::MAX, 1);
}
#[test]
fn test_result() -> Result<(), String> {
if add(2, 2) == 4 { Ok(()) } else { Err(String::from("math broken")) }
}
}Integration tests go in tests/ at the crate root — each file is a separate crate that can only access your public API. Doc tests (the code in /// comments) are compiled and run by cargo test, ensuring documentation examples stay correct.
35. How do you test async code with Tokio?
// Cargo.toml: tokio = { features = ["full", "test-util"] }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_async_function() {
let result = fetch_data(42).await;
assert_eq!(result, "data for 42");
}
// Control time in tests
#[tokio::test]
async fn test_with_time_control() {
tokio::time::pause();
let start = tokio::time::Instant::now();
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
// Time advanced instantly
assert!(start.elapsed() >= std::time::Duration::from_secs(3600));
}
}#[tokio::test] sets up an async test runtime. For tests involving time-sensitive logic, tokio::time::pause() lets you advance time programmatically without actually waiting.
Bonus: Advanced Questions That Separate Senior Candidates
36. What is `Pin<T>` and why is it needed for async?
use std::pin::Pin;
// Futures that reference their own fields cannot be moved after creation
// Pin prevents movement
async fn self_referential() {
let s = String::from("hello");
let reference = &s;
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
println!("{}", reference);
}
// Pin<Box<dyn Future>> is required when storing futures in structs
struct MyFuture {
inner: Pin<Box<dyn std::future::Future<Output = ()>>>,
}When a future is awaited, it may be stored on the heap and polled repeatedly. If the future contains a self-referential structure (a reference to one of its own fields, which the compiler generates internally for some async state machines), moving it would invalidate those references. Pin is a guarantee that the pointee will not be moved.
37. Explain the `Send` and `Sync` marker traits.
use std::sync::{Arc, Mutex};
// Send: safe to transfer ownership to another thread
// Sync: safe to share a reference across threads (&T is Send)
// Rc<T> is neither Send nor Sync — use Arc<T>
// RefCell<T> is Send but not Sync — use Mutex<T>
// Arc<Mutex<T>> is both Send and Sync
fn spawn_with_shared_state() {
let counter = Arc::new(Mutex::new(0));
let counter2 = Arc::clone(&counter);
let handle = std::thread::spawn(move || {
let mut c = counter2.lock().unwrap();
*c += 1;
});
handle.join().unwrap();
println!("{}", counter.lock().unwrap());
}These are automatically derived by the compiler based on the types a struct contains. You can implement them manually with unsafe impl Send for T {} when you know the invariants hold but the compiler can't prove it (e.g., wrapping a C FFI type that is thread-safe).
What Interviewers Are Actually Testing
In ownership/borrow questions: They want to see that you can read compiler errors and fix them, not just that you've memorized the rules. The best candidates say "this won't compile because..." before running the code.
In lifetime questions: Many candidates memorize the syntax but can't explain what a lifetime annotation actually says. Make sure you can articulate: "this annotation says that the output reference cannot outlive the shorter of these two input references."
In async questions: The most common failure is not knowing that async is lazy. Candidates often say "this starts executing here" pointing at an async call without .await. It doesn't — nothing happens until the future is polled.
In error handling: Senior interviewers specifically watch whether you reach for unwrap() habitually. Using ? fluently and knowing when Box vs a custom error enum is appropriate signals production experience.
In unsafe questions: The right answer is almost never "use unsafe." If you're asked to implement something that naively seems to require unsafe, usually there's a safe API in std that does it. Show that you look for safe alternatives first.
Preparation Checklist
Before your Rust interview, make sure you can do these things without looking them up:
- Write a function with explicit lifetime annotations and explain what each annotation means
- Fix a "cannot borrow as mutable because it is already borrowed as immutable" error
- Write a custom error type with
impl From<>for?operator compatibility - Implement a custom iterator with
Iteratortrait - Write an async function, spawn it with Tokio, and collect results with
join! - Explain when to use
Arcvs> Rc> - Read a compiler error about lifetimes and identify what the fix is
The Rust compiler's error messages are famously helpful. In interviews, talk through what the compiler would say and why — it shows you actually write Rust regularly, not just read about it.