Top 50 Rust Interview Questions for Mid-Level Developers
Rust has rapidly become one of the most loved programming languages, prized for its blend of performance, safety, and modern tooling. For mid-level developers—typically those with one to three years of Rust experience—interviews go beyond basic syntax and dive into ownership semantics, trait design, concurrency primitives, and idiomatic patterns. This tutorial presents the top 50 Rust interview questions you are likely to encounter, complete with practical code examples and concise explanations. Use it both as a study guide and as a reference for what hiring managers care about most.
Why This Matters
Mid-level Rust developers are expected to write code that is not only correct but also idiomatic. Interviewers want to see that you understand why the borrow checker enforces its rules, how to design traits that compose well, when to reach for Arc versus Rc, and how to model errors without resorting to panics. Mastering these questions demonstrates the maturity needed to contribute to production Rust codebases.
Section 1: Ownership and Memory (Questions 1–10)
Q1: What is ownership in Rust?
Ownership is Rust's core memory management model. Every value has a single owner, and when that owner goes out of scope, the value is dropped. This eliminates the need for garbage collection while preventing use-after-free and double-free bugs.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1's ownership moves to s2
// println!("{}", s1); // ERROR: value borrowed after move
println!("{}", s2);
}
Q2: What is the difference between a move and a copy?
Types that implement the Copy trait (such as integers, floats, and bools) are duplicated on assignment rather than moved. All other types are moved by default.
fn main() {
let x = 5; // i32 is Copy
let y = x; // x is still valid
println!("{} {}", x, y);
let s1 = String::from("hi");
let s2 = s1; // String is not Copy, s1 is moved
// println!("{}", s1); // ERROR
}
Q3: How does Rust decide if a type is Copy?
A type is Copy if all of its fields are Copy and it does not implement Drop. You can derive Copy and Clone for simple structs.
#[derive(Copy, Clone, Debug)]
struct Point { x: i32, y: i32 }
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // copied, not moved
println!("{:?} {:?}", p1, p2);
}
Q4: What is a borrow and what are the borrowing rules?
Borrowing lets you reference a value without taking ownership. The rules are: you may have either one mutable reference or any number of immutable references, and references must always point to valid data.
fn main() {
let mut s = String::from("rust");
let r1 = &s;
let r2 = &s; // multiple immutable borrows OK
println!("{} {}", r1, r2);
let r3 = &mut s; // mutable borrow after immutable scope ends
r3.push_str("acean");
println!("{}", r3);
}
Q5: What is the difference between &str and String?
String is an owned, growable heap-allocated string. &str is a borrowed slice into string data, which may live on the heap, stack, or in the binary itself.
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
fn main() {
let owned = String::from("Ada");
let borrowed: &str = "Grace";
println!("{}", greet(&owned));
println!("{}", greet(borrowed));
}
Q6: Explain the Drop trait.
Drop lets you define cleanup logic that runs automatically when a value goes out of scope. It is Rust's equivalent of a destructor.
struct Resource { name: String }
impl Drop for Resource {
fn drop(&mut self) {
println!("Dropping {}", self.name);
}
}
fn main() {
let _r = Resource { name: String::from("conn") };
println!("end of main");
} // "Dropping conn" prints here
Q7: When would you use Box<T>?
Box<T> allocates a value on the heap. It is used for recursive types, large values you want to move cheaply, and trait objects.
enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
// Without Box, the size of List would be infinite.
}
Q8: What is the difference between Rc and Arc?
Rc provides single-threaded reference counting; Arc provides atomic reference counting safe for sharing across threads. Use Arc whenever the value may cross thread boundaries.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3]);
let handles: Vec<_> = (0..3).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || println!("t{} sees {:?}", i, data))
}).collect();
for h in handles { h.join().unwrap(); }
}
Q9: What is interior mutability?
Interior mutability lets you mutate a value through an immutable reference, checked at runtime rather than compile time. RefCell<T> is the single-threaded form; Mutex<T> and RwLock<T> are thread-safe forms.
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(5);
*cell.borrow_mut() += 10;
println!("{}", cell.borrow()); // 15
}
Q10: What is Cell<T> and how does it differ from RefCell<T>?
Cell<T> provides interior mutability for Copy types by copying values in and out. RefCell<T> works with non-Copy types via borrow/borrow_mut, enforcing borrowing rules at runtime.
use std::cell::Cell;
fn main() {
let c = Cell::new(10);
let r = &c;
r.set(20); // mutate through immutable reference
println!("{}", c.get());
}
Section 2: Lifetimes and References (Questions 11–20)
Q11: What is a lifetime in Rust?
A lifetime is a compile-time annotation describing how long a reference is valid. The compiler uses lifetimes to ensure references never outlive their referents.
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 s2 = String::from("short");
println!("{}", longest(&s1, &s2));
}
Q12: What is lifetime elision?
The compiler infers lifetimes in common patterns so you do not have to write them. Three rules apply: each input reference gets its own lifetime; if there is one input lifetime, it is assigned to all outputs; and if &self or &mut self exists, its lifetime is assigned to all outputs.
// These two signatures are equivalent:
fn first_word(s: &str) -> &str { s }
fn first_word_explicit<'a>(s: &'a str) -> &'a str { s }
Q13: What is the 'static lifetime?
'static means the reference is valid for the entire program. All string literals have this lifetime because they are stored in the binary.
fn static_str() -> &'static str {
"I live forever"
}
Q14: How do you store references inside a struct?
Structs that hold references must declare lifetime parameters so the compiler can verify the references outlive the struct.
struct Parser<'a> {
input: &'a str,
pos: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self { Parser { input, pos: 0 } }
fn peek(&self) -> Option<char> { self.input[self.pos..].chars().next() }
}
Q15: What is a dangling reference and how does Rust prevent it?
A dangling reference points to memory that has been freed. Rust's borrow checker rejects code that would create one.
// fn dangle() -> &String {
// let s = String::from("oops");
// &s // ERROR: s is dropped at end of function
// }
fn no_dangle() -> String {
let s = String::from("ok");
s // ownership moves out, no reference returned
}
Q16: What does '_ mean in a type signature?
The '_ placeholder tells the compiler to infer the lifetime. It is often used in function return types or impl blocks where the lifetime is obvious from context.
impl<'a> Parser<'a> {
fn rest(&self) -> &'_ str {
&self.input[self.pos..]
}
}
Q17: Can a function return a reference to a locally created value?
No. Local values are dropped when the function returns, so any reference to them would dangle. Return an owned value instead, or accept the data as an input parameter.
Q18: What is the relationship between lifetimes and trait objects?
Trait objects have a default lifetime of 'static when stored, but you can parameterize them. Box<dyn Trait> is shorthand for Box<dyn Trait + 'static>.
trait Logger {
fn log(&self, msg: &str);
}
struct Console;
impl Logger for Console {
fn log(&self, msg: &str) { println!("{}", msg); }
}
fn make_logger() -> Box<dyn Logger> {
Box::new(Console)
}
Q19: What is variance in Rust?
Variance describes how subtyping relationships between lifetimes propagate through generic types. &'a T is covariant in both 'a and T; &mut 'a T is covariant in 'a but invariant in T; fn(T) is contravariant in T. Understanding variance helps explain why some lifetime assignments are rejected.
Q20: How do you express "any lifetime" in a generic bound?
Use the for<'a> higher-ranked trait bound (HRTB) to require that a function or type works for all possible lifetimes.
fn apply<F>(f: F)
where
F: for<'a> Fn(&'a str) -> &'a str,
{
let s = String::from("data");
println!("{}", f(&s));
}
Section 3: Types, Traits, and Generics (Questions 21–30)
Q21: What is a trait and how is it different from an interface?
A trait defines shared behavior. Unlike interfaces in some languages, traits can have default methods, associated types, and generic parameters, and they can be implemented for any type—even external ones via the newtype pattern.
trait Summable {
fn sum(&self) -> i64;
fn describe(&self) -> String { String::from("a summable thing") }
}
impl Summable for Vec<i32> {
fn sum(&self) -> i64 {
self.iter().map(|&x| x as i64).sum()
}
}
Q22: What is the difference between generic dispatch and trait objects?
Generics use static dispatch—the compiler monomorphizes a separate version for each concrete type. Trait objects use dynamic dispatch via a vtable at runtime. Static dispatch is faster; dynamic dispatch is more flexible.
// Static dispatch
fn print_static<T: std::fmt::Display>(x: T) { println!("{}", x); }
// Dynamic dispatch
fn print_dyn(x: &dyn std::fmt::Display) { println!("{}", x); }
Q23: What are associated types and when do you use them?
Associated types are types tied to a trait implementation. They are used when each implementor should have exactly one corresponding type, simplifying signatures compared to generic parameters.
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
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 }
}
}
Q24: What is the difference between impl Trait in argument and return position?
In argument position, impl Trait is sugar for an anonymous generic bound. In return position, it specifies an opaque type that the caller cannot name but the compiler knows.
fn make_iter() -> impl Iterator<Item = i32> {
vec![1, 2, 3].into_iter()
}
Q25: What are trait bounds and how do you combine them?
Trait bounds constrain generic types. Use the + syntax to require multiple bounds, or where clauses for readability.
fn process<T>(x: T)
where
T: std::fmt::Debug + Clone + PartialOrd,
{
let y = x.clone();
println!("{:?} vs {:?}", x, y);
}
Q26: What is the newtype pattern?
The newtype pattern wraps an existing type in a new struct to give it distinct behavior or to implement external traits. It costs nothing at runtime.
struct Meters(f64);
struct Feet(f64);
impl Meters {
fn to_feet(&self) -> Feet { Feet(self.0 * 3.28084) }
}
fn main() {
let m = Meters(1.0);
let f = m.to_feet();
println!("{} m = {} ft", m.0, f.0);
}
Q27: What is the orphan rule?
The orphan rule states you may implement a trait for a type only if either the trait or the type is local to your crate. This prevents conflicting implementations across crates and is the reason the newtype pattern is so common.
Q28: What are default generic type parameters?
You can specify a default type for a generic parameter using <T = SomeType>. This lets callers omit the parameter in common cases.
trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
Q29: How do you implement a trait for references?
You can implement traits for &T or &mut T directly. The standard library does this for many traits so that generics work seamlessly with references.
trait Printable {
fn print(&self);
}
impl<T: Printable> Printable for &T {
fn print(&self) { (**self).print(); }
}
Q30: What is the difference between From and Into?
Implementing From<T> for U automatically gives you Into<U> for T. From is the recommended trait to implement because it is reflexive and easier to chain.
struct Celsius(f64);
struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self { Fahrenheit(c.0 * 9.0 / 5.0 + 32.0) }
}
fn main() {
let f: Fahrenheit = Celsius(0.0).into();
println!("{} F", f.0);
}
Section 4: Error Handling (Questions 31–36)
Q31: What is the difference between Option and Result?
Option<T> represents the presence or absence of a value. Result<T, E> represents success or a typed error. Use Option when there is no error information to convey; use Result when something can fail.
fn parse_int(s: &str) -> Result<i32, std::num::ParseIntError> {
s.parse::<i32>()
}
fn main() {
match parse_int("42") {
Ok(n) => println!("got {}", n),
Err(e) => println!("error: {}", e),
}
}
Q32: What does the ? operator do?
The ? operator returns early with an error if the Result is Err, or unwraps the Ok value. It can also convert error types via From.
use std::fs;
use std::io;
fn read_config(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?;
Ok(contents)
}
Q33: How do you define custom error types?
Use an enum to represent distinct error cases, and implement std::fmt::Display and std::error::Error. The thiserror crate automates this.
use std::fmt;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
NotFound(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "io: {}", e),
AppError::Parse(e) => write!(f, "parse: {}", e),
AppError::NotFound(s) => write!(f, "not found: {}", s),
}
}
}
impl std::error::Error for AppError {}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
Q34: When should you panic versus return a Result?
Return a Result for expected, recoverable failures (bad input, missing files). Panic for invariant violations or programmer errors that should never occur in correct code (index out of bounds in trusted logic, unreachable branches).
Q35: What is unwrap_or_else and why prefer it over unwrap?
unwrap_or_else lets you provide a fallback computed lazily via a closure, avoiding panics and unnecessary computation.
fn main() {
let input = "abc";
let n: i32 = input.parse().unwrap_or_else(|_| {
println!("falling back");
0
});
println!("{}", n);
}
Q36: How do you convert multiple error types into one?
Implement From for each underlying error type into your unified error enum. Then the ? operator converts automatically.
fn load_count(path: &str) -> Result<i32, AppError> {
let s = std::fs::read_to_string(path)?; // io::Error -> AppError
let n: i32 = s.trim().parse()?; // ParseIntError -> AppError
Ok(n)
}
Section 5: Concurrency (Questions 37–44)
Q37: What does Send mean?
Send marks types whose ownership can be safely transferred across threads. Most types are Send; notable exceptions include Rc<T> and RefCell<T>.
Q38: What does Sync mean?
Sync marks types that are safe to share between threads via shared references. A type T is Sync if &T is Send. Mutex<T> is Sync when T: Send.
Q39: How do you share mutable state across threads?
Combine Arc<T> for shared ownership with Mutex<T> or RwLock<T> for synchronized mutation.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
*counter.lock().unwrap() += 1;
})
}).collect();
for h in handles { h.join().unwrap(); }
println!("count = {}", *counter.lock().unwrap());
}
Q40: What is the difference between Mutex and RwLock?
Mutex allows only one accessor at a time, whether reading or writing. RwLock allows multiple concurrent readers or one exclusive writer. Use RwLock when reads vastly outnumber writes.
Q41: What are channels in Rust?
Channels provide message passing between threads. The standard library offers std::sync::mpsc for multi-producer, single-consumer channels. The crossbeam and tokio crates offer richer options.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send("hello from thread").unwrap();
});
println!("got: {}", rx.recv().unwrap());
}
Q42: What is JoinHandle and why is it important?
JoinHandle is returned by thread::spawn and lets you wait for a thread to finish via join(). Calling join also propagates panics from the child thread, which is critical for correctness.
Q43: How do async and threads differ in Rust?
Threads are preemptively scheduled by the OS and have higher overhead. Async tasks are cooperatively scheduled by an executor (such as Tokio) on a thread pool, allowing millions of concurrent tasks with low memory cost.
async fn fetch(id: u32) -> String {
format!("data-{}", id)
}
#[tokio::main]
async fn main() {
let results = futures::future::join_all((0..3).map(fetch)).await;
println!("{:?}", results);
}
Q44: What is Pin<T> and why does it exist?
Pin ensures a value will not be moved in memory, which is required for self-referential async state machines. Most developers interact with Pin indirectly through .await, but library authors must understand it to write safe poll-based code.
Section 6: Modules, Macros, and Ecosystem (Questions 45–50)
Q45: How do modules work in Rust?
Modules organize code into namespaces. You declare them with mod and control visibility with pub. The 2018 edition allows files to be discovered via mod.rs or a file named after the module.
// src/math/mod.rs
pub fn add(a: i32, b: i32) -> i32 { a + b }
// src/main.rs
mod math;
fn main() { println!("{}", math::add(2, 3)); }
Q46: What is the difference between use and pub use?
use brings items into scope. pub use re-exports them so callers of your crate can access them through a cleaner path. This is the foundation of a well-designed public API.
Q47: What are declarative macros and how do you write one?
Declarative macros use pattern matching to generate code at compile time. They are defined with macro_rules!.
macro_rules! vec_of_strings {
( $( $x:expr ),* ) => {
{
let mut v = Vec::new();
$(
v.push(String::from($x));
)*
v
}
};
}
fn main() {
let names = vec_of_strings!("Ada", "Grace", "Linus");
println!("{:?}", names);
}
Q48: What are procedural macros and what are the three kinds?
Procedural macros are Rust functions that operate on token streams. The three kinds are: derive macros (add trait implementations), attribute macros (annotate items), and function-like macros (invoked like macro_rules! but with full code-generation power). The serde #[derive(Serialize)] is a common example.
Q49: What is Cargo and what are workspaces?
Cargo is Rust's build system and package manager. Workspaces let multiple crates share a single Cargo.lock and target directory, which is essential for multi-crate repositories.
// Cargo.toml (workspace root)
[workspace]
members = ["core", "cli", "server"]
resolver = "2"
Q50: What are feature flags and how do you use them?
Feature flags enable conditional compilation of code paths. They are declared in Cargo.toml under [features] and checked with #[cfg(feature = "...")].
// Cargo.toml
[features]
json = ["serde_json"]
// src/lib.rs
#[cfg(feature = "json")]
pub fn parse_json(s: &str) -> serde_json::Value {
serde_json::from_str(s).unwrap()
}
Best Practices for Mid-Level Rust Developers
- Prefer borrowing over cloning. Pass
&Tor&strwhen you do not need ownership. Clone only at the boundaries where ownership is genuinely required. - Design small, composable traits. Avoid god-traits with many methods. Combine focused traits with bounds in
whereclauses for clarity. - Use the newtype pattern liberally. It prevents mixing up primitive types and lets you implement traits for external types safely.
- Model errors explicitly. Define a single error enum per crate, implement
Fromfor sub-errors, and considerthiserrorfor boilerplate reduction. - Avoid
unwrapandexpectin production paths. Reserve them for tests or provably-invariant cases. Otherwise, propagate errors with?. - Choose the right concurrency primitive. Use
Arc<Mutex<T>>for shared mutation, channels for message passing, and async runtimes for I/O-bound workloads. - Run clippy and format with rustfmt. Treat warnings as errors in CI with
RUSTFLAGS="-D warnings". - Write tests at multiple levels. Unit tests in
#[cfg(test)]modules, integration tests intests/, and doc tests via///comments. - Document public APIs. Use doc comments with examples that compile as doctests. This is both documentation and a regression test.
- Understand the cost of abstractions. Know when monomorphization increases binary size, when dynamic dispatch costs a vtable lookup, and when
Boxheap allocation is justified.
Conclusion
Mid-level Rust interviews test your ability to reason about ownership, lifetimes, traits, and concurrency—not just to write code that compiles, but to write code that is idiomatic and maintainable. The fifty questions above cover the conceptual territory most hiring teams explore, from the borrow checker's rules to async runtime semantics and Cargo workspace organization. Pair this knowledge with hands-on practice: build a small CLI, contribute to an open-source crate, or refactor a project to use a custom error type. The combination of conceptual fluency and practical experience is what distinguishes a strong mid-level Rust developer and prepares you to grow into senior roles.