Rust Interview Prep
Top 30 Rust interview questions and answers covering ownership, traits, concurrency, async, and system design.
Core Language
1. What are the three rules of ownership in Rust?
Ownership is the foundation of Rust’s memory safety guarantees. Every other rule in the language — borrowing, lifetimes, Drop — exists to enforce these three invariants without a garbage collector.
- Each value has exactly one owner.
- There can only be one owner at a time — assigning a non-Copy value moves ownership.
- When the owner goes out of scope, the value is dropped (memory freed).
These rules are enforced at compile time and eliminate dangling pointers, double-frees, and use-after-free bugs without a garbage collector.
2. What is the difference between String and &str?
This is one of the most common beginner stumbling blocks. The key insight is that String owns its data and manages the heap allocation, while &str is just a view into some existing UTF-8 data — it does not own anything.
String | &str | |
|---|---|---|
| Ownership | Owned, heap-allocated | Borrowed reference to UTF-8 data |
| Mutability | Can grow/shrink | Read-only slice |
| Size | Dynamic | Fixed (pointer + length) |
| Typical use | Owning string data | Function parameters, string literals |
fn greet(name: &str) -> String { // takes &str (no allocation), returns owned String
format!("Hello, {}!", name)
}
3. What is the difference between &T and &mut T?
The borrow rules are what give Rust its data-race-free guarantee. The same rule that prevents data races in concurrent code also prevents iterator invalidation and use-after-realloc bugs in single-threaded code.
&T— shared reference: read-only, any number can exist simultaneously.&mut T— exclusive reference: read-write, only one can exist at a time, and no shared references may coexist.
This rule prevents data races at compile time — the same rule that prevents races in concurrent code also prevents iterator invalidation, use-after-realloc, and other memory bugs.
4. What is move semantics?
Move semantics let Rust transfer ownership of heap resources without copying them — a String moves its heap pointer to the new binding rather than copying all the characters. The original binding is invalidated so there is exactly one owner at all times.
let s = String::from("hello");
let t = s; // s is moved to t — no heap copy, just pointer transfer
// println!("{}", s); // compile error: s was moved
Move semantics are free at runtime (no allocation). The compiler just treats the original binding as invalid.
5. What types implement Copy?
Copy types are entirely stack-allocated and trivially duplicable — the compiler can bitwise-copy them without any risk because they have no heap resources to double-free.
Types that are entirely stack-allocated and trivially duplicable:
- All integer and float types (
i32,f64, etc.) bool,char- Tuples and arrays of
Copytypes - Shared references
&T
Types like String, Vec<T>, Box<T> do not implement Copy because they own heap data.
6. What is a lifetime in Rust?
Lifetimes answer the question “how long is this reference valid?” Without them, the compiler could not verify that a reference does not outlive the data it points to. They generate zero machine code — they are purely a compile-time check.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'a says “the returned reference lives at least as long as the shorter of x and y.”
Lifetimes generate zero machine code — they are purely a compile-time check.
7. Explain lifetime elision.
Lifetime elision is the compiler’s ability to infer lifetime annotations in the common cases, so you do not have to write them explicitly. Roughly 90% of real code never needs explicit lifetimes because one of these three rules applies.
The compiler applies three rules to infer lifetimes automatically:
- Each reference parameter gets its own lifetime.
- If there is exactly one input lifetime, it applies to all outputs.
- If one parameter is
&selfor&mut self, its lifetime applies to all outputs.
These rules handle ~90% of cases, so explicit annotations are rarely needed.
8. What is the difference between impl Trait and dyn Trait?
Understanding the dispatch difference matters for both correctness and performance. impl Trait is the right default — reach for dyn Trait only when the concrete type genuinely varies at runtime or when you need a heterogeneous collection.
impl Trait— static dispatch: the compiler generates a specific implementation for each concrete type. Faster (no indirection), but returns one concrete type.dyn Trait— dynamic dispatch: a fat pointer with a vtable resolved at runtime. Allows heterogeneous collections but has a small runtime cost.
fn static_dispatch(x: impl Display) { println!("{}", x); }
fn dynamic_dispatch(x: &dyn Display) { println!("{}", x); }
// dyn Trait enables storing different types in the same collection
let items: Vec<Box<dyn Display>> = vec![Box::new(1), Box::new("hi")];
9. What is the Send and Sync trait?
Send and Sync are how Rust encodes thread safety in the type system. Rather than runtime checks or developer discipline, the compiler verifies that you only cross thread boundaries with types that are safe to do so.
Send: a type that can be transferred to another thread (ownership moved across threads).Sync: a type that can be shared by reference across threads (&T: Send).
Most types are automatically Send + Sync. Exceptions:
Rc<T>— notSend(useArc<T>)RefCell<T>— notSync(useMutex<T>)- Raw pointers — neither
These marker traits are how Rust provides compile-time data race prevention.
10. What is the difference between Rc<T> and Arc<T>?
The difference is the cost of sharing. Rc uses plain integer operations on the reference count (fast, but only safe on one thread). Arc uses atomic CPU instructions (slightly slower, but safe across threads).
Both are reference-counted smart pointers for shared ownership:
Rc<T> | Arc<T> | |
|---|---|---|
| Thread-safe | No (!Send) | Yes (atomic operations) |
| Overhead | Lower | Slightly higher |
| Use case | Single-threaded sharing | Multi-threaded sharing |
Error Handling & Traits
11. How does the ? operator work?
? is the mechanism that makes error propagation ergonomic. Without it, every fallible call would require a match statement. With it, you write code that reads like the happy path, and errors flow up automatically.
// This:
let content = fs::read_to_string(path)?;
// Expands to roughly:
let content = match fs::read_to_string(path) {
Ok(v) => v,
Err(e) => return Err(e.into()), // .into() converts via From trait
};
The .into() conversion uses From — so if you implement From<IoError> for MyError, ? converts automatically.
12. What is the difference between unwrap, expect, and ??
Choosing the right one communicates intent. unwrap in library code is a red flag. ? in a function that returns Result is the idiomatic choice. expect in tests is fine — the message tells you which assertion failed.
| Method | On Err/None | Use when |
|---|---|---|
unwrap() | Panics with generic message | Tests, prototyping |
expect("msg") | Panics with your message | Tests, invariants that must hold |
? | Returns Err to caller | Production code |
13. What makes a trait object-safe?
Object safety is the constraint that makes dyn Trait possible. For a vtable to work, every method entry must be a fixed-size function pointer, which requires knowing the return type’s size and not having type parameters that would require infinitely many vtable entries.
A trait is object-safe (usable as dyn Trait) when:
- No methods return
Self - No methods have generic type parameters
- The trait has no associated constants that aren’t object-safe
Clone is not object-safe because it returns Self. The workaround is a separate DynClone trait or Box<dyn Trait> with an explicit clone_box method.
14. What is the From / Into relationship?
From and Into are conversion traits that let types express how they can be constructed from other types. The free blanket impl (impl<T, U: From<T>> Into<T> for U) means you only ever implement From and get Into automatically.
Implementing From<T> for U automatically provides Into<U> for T via a blanket impl. The convention is to implement From and use Into in function bounds:
impl From<&str> for MyType { fn from(s: &str) -> Self { /* ... */ } }
// Automatically gives: let x: MyType = "hello".into();
This is also how ? converts between error types.
15. What is interior mutability?
Interior mutability solves the problem of needing to mutate state in a context where only a shared reference is available — for example, implementing a cache inside a struct, or storing state in a callback. The standard tools trade compile-time safety for runtime flexibility at different costs.
Interior mutability allows mutation through a shared reference (&T), bypassing the usual borrow rules:
Cell<T>—Copytypes only, no runtime checkRefCell<T>— any type, runtime borrow check (panics on violation)Mutex<T>— thread-safe, blocks on contentionRwLock<T>— thread-safe, multiple readers OR one writer
Use when you need mutation in a context where only &self is available (e.g., caching, callback registration).
Concurrency & Async
16. How does Rust prevent data races at compile time?
Rust eliminates data races not through a runtime check but through the type system. Each mechanism builds on the previous: ownership limits who can mutate, borrows control concurrent access, and Send/Sync extend those guarantees across thread boundaries.
Through the combination of:
- Ownership — only one owner can mutate at a time.
- Borrow rules —
&mut Tis exclusive; no&Tcan coexist with it. Send/Sync— types that are unsafe to share across threads are!Sendor!Sync, so the compiler rejects code that tries to.
A value of type Rc<RefCell<T>> cannot be sent across threads because Rc is !Send. The type system enforces this.
17. When would you use a Mutex vs a channel?
This is a design question as much as a technical one. Channels model data flow — a value is produced, transferred, and consumed. Mutexes model shared state — a value lives in one place and multiple actors take turns accessing it. Channels are generally easier to reason about and test.
- Mutex: multiple threads need shared mutable access to the same state (e.g., a shared cache or counter).
- Channel: threads communicate by passing ownership of messages. Channels express dataflow more clearly and avoid shared state entirely.
The Rust philosophy prefers message passing (“share memory by communicating”) but both are safe.
18. What does async fn return?
The important insight is that calling an async fn is lazy — nothing runs until you .await it or hand it to a runtime. This is different from languages where async fn immediately starts running a background task.
An async fn returns an impl Future<Output = T>. The function body does not execute when called — it returns a future that must be driven to completion by a runtime (e.g., tokio).
async fn fetch() -> String { "hello".to_string() }
let f = fetch(); // f: impl Future<Output = String> — body has NOT run yet
let s = fetch().await; // body runs now; s is the String
19. What is Pin and why does async code need it?
Pin solves a subtle self-reference problem that arises specifically from Rust’s async state machines. The compiler generates state machines from async fn bodies, and those state machines can hold references to their own fields — which breaks if the struct is moved.
Pin<P> guarantees that the value pointed to by P will not be moved in memory. Async functions generate state machines that can hold self-referential data (a reference to a variable within the same struct). If such a struct were moved, the reference would become invalid. Pin prevents that move.
In practice, you rarely interact with Pin directly — the compiler and tokio::pin! handle it.
20. What is the difference between tokio::spawn and tokio::join!?
Both run futures concurrently, but at different costs and with different semantics. join! is lighter — it runs futures cooperatively in the same task. spawn creates true independent tasks that can run on different threads.
tokio::spawn— spawns an independent task on the thread pool. Returns aJoinHandle. The task runs concurrently with the spawner and can be awaited later.tokio::join!— runs multiple futures concurrently in the same task. All futures are polled in a loop; when one makes progress, control returns. More lightweight than spawning separate tasks.
Memory & Performance
21. What is the difference between Box<T>, Rc<T>, and Arc<T>?
Each solves a different problem. Box is the simplest — single ownership, heap allocation. Rc and Arc enable shared ownership where multiple parts of the code need to keep a value alive, differing only in thread-safety.
| Type | Ownership | Thread-safe | Use case |
|---|---|---|---|
Box<T> | Unique (single owner) | Yes | Heap allocation, dyn Trait, recursive types |
Rc<T> | Shared (reference count) | No | Single-threaded shared ownership |
Arc<T> | Shared (atomic ref count) | Yes | Multi-threaded shared ownership |
22. What is monomorphization?
Monomorphization is why Rust generics are truly zero-cost. Rather than passing types at runtime (like Java’s erased generics) or boxing values (like dynamic dispatch), the compiler generates dedicated machine code for each type combination used.
When a generic function or struct is used with a concrete type, the compiler generates a specialized copy of the code for that type. This means generics have zero runtime overhead — they compile to the same machine code as if you had written the function for that specific type.
Trade-off: longer compile times and potentially larger binaries for many type instantiations.
23. When would you use unsafe?
unsafe does not turn off the borrow checker — it unlocks five specific capabilities that require manual proof of correctness. Keep unsafe blocks small, document the invariants, and wrap them in a safe API.
unsafe is appropriate when:
- Calling C/FFI functions
- Implementing low-level data structures (custom allocators, lock-free queues)
- Writing OS/embedded code that needs raw pointer manipulation
- Marking a type as
Send/Syncbecause you’ve manually verified the safety invariants
Always document the safety invariants that the caller must uphold, and keep unsafe blocks as small as possible.
24. What is the Drop trait and when is it called?
Drop is Rust’s deterministic destructor — unlike garbage-collected languages, you know exactly when a value’s resources are released. This is what makes RAII patterns work: Mutex locks release when the guard drops, files close when File drops, network connections close automatically.
Drop::drop is called automatically when a value goes out of scope — the Rust equivalent of a destructor. It releases resources (memory, file handles, locks, network connections) deterministically without a GC.
Values are dropped in reverse declaration order. You can force an early drop with std::mem::drop(value).
Advanced Topics
25. What is the newtype pattern and why use it?
The newtype pattern uses the type system to prevent logical errors that the compiler would otherwise miss. Wrapping f64 in Meters and Seconds makes it a compile error to confuse them — a bug that would otherwise only appear at runtime.
Wrap a type in a single-field tuple struct to create a distinct type with the same representation:
struct Meters(f64);
struct Seconds(f64);
// Cannot accidentally pass Seconds where Meters is expected — compile error, not runtime bug
Benefits:
- Type safety (prevents mixing
MetersandSeconds) - Add methods to external types (bypasses the orphan rule)
- Zero runtime cost (same memory layout)
26. What is the orphan rule?
The orphan rule prevents conflicting trait implementations in the ecosystem. If two crates both implemented Display for Vec<T>, the compiler would not know which to use. The rule eliminates that ambiguity by requiring at least one of the trait or the type to be local.
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<T> in your own crate because both Display and Vec are foreign (from std).
The newtype pattern is the standard workaround: wrap Vec<T> in your own struct and implement Display for that.
27. What are the three closure traits and how do they differ?
Every closure implements at least FnOnce. The more capabilities it gives up (consuming captures → mutating captures → only reading captures), the more implementations it gets. Fn is the most restrictive to implement and the most flexible to use as a bound.
| Trait | Captures | Can call | Example |
|---|---|---|---|
FnOnce | May consume captured values | Once | move || drop(owned_string) |
FnMut | Mutably borrows captured values | Multiple times | || count += 1 |
Fn | Immutably borrows captured values | Multiple times | || x + 1 |
Every closure implements FnOnce. Closures that don’t consume captures also implement FnMut. Closures that don’t mutate captures also implement Fn. Fn is the most restrictive and most flexible to use.
28. What is the typestate pattern?
The typestate pattern encodes a state machine’s valid transitions into the type system. Invalid transitions become compile errors rather than runtime panics. This is one of Rust’s most powerful design patterns for APIs that have mandatory ordering constraints.
Encode a state machine’s states as type parameters so that invalid transitions are compile errors:
struct Connection<State> { _state: std::marker::PhantomData<State> }
struct Disconnected;
struct Connected;
impl Connection<Disconnected> {
fn connect(self) -> Connection<Connected> { /* ... */ todo!() }
}
impl Connection<Connected> {
fn send(&self, data: &[u8]) { /* ... */ }
fn disconnect(self) -> Connection<Disconnected> { /* ... */ todo!() }
}
// connection.send() before connect() — compile error, not runtime error
29. How does serde work under the hood?
serde’s design is a model of separation of concerns. Data types know how to traverse their fields but know nothing about formats. Formats know how to encode values but know nothing about the data’s structure. The traits are the interface between them.
serde defines two traits: Serialize and Deserialize. The #[derive(Serialize, Deserialize)] macro generates implementations that walk the struct/enum fields and call methods on a Serializer or Deserializer.
The format (JSON, TOML, MessagePack…) implements the Serializer/Deserializer traits. This separation means your types know nothing about the format, and the format knows nothing about your types — they communicate through a common interface.
30. How would you approach porting a C library to Rust?
This is a systems design question testing whether you understand the safe/unsafe boundary and how to build a layered abstraction. The key insight is that unsafe should be confined to the thinnest possible wrapper layer, with a safe API above it.
- Write a
*-syscrate usingbindgento generate raw FFI bindings from the C headers. - Wrap the
*-syscrate in a safe Rust API that upholds invariants (no raw pointers in public API, RAII for resource management). - Use
unsafeblocks only in the wrapper layer, with documented safety invariants. - Test thoroughly with both unit tests and integration tests against the original C behavior.
- Gradually replace the C implementation module by module, keeping the safe API stable throughout.
Tools: bindgen (bindings generation), cbindgen (generating C headers from Rust), cargo-fuzz (fuzz testing the boundary).
Quick Reference: Commonly Confused Pairs
| Pair | Key distinction |
|---|---|
String vs &str | Owned vs borrowed |
Box vs Rc vs Arc | Unique vs shared (single-thread) vs shared (multi-thread) |
Cell vs RefCell | Copy-only vs any type; no check vs runtime check |
Mutex vs RwLock | Exclusive access vs multiple readers + one writer |
impl Trait vs dyn Trait | Static dispatch vs dynamic dispatch |
clone vs copy | Explicit heap duplication vs implicit stack copy |
FnOnce vs FnMut vs Fn | Consumes / mutates / reads environment |
move vs borrow in closures | Transfer ownership vs borrow from enclosing scope |
panic! vs Result | Unrecoverable bug vs expected failure |
unwrap vs ? | Panic on error vs propagate to caller |