The 60 Rust questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Rust is a compiled systems programming language that reached 1.0 in 2015, built around one goal: memory safety without a garbage collector. It does that with ownership and a compile-time borrow checker that tracks who can read or write each value, so data races and use-after-free bugs fail to compile instead of crashing in production. It's statically typed, has no null and no exceptions (errors travel through Option and Result), and its zero-cost abstractions mean high-level code compiles down to the same machine code you'd write by hand. In interviews, Rust questions probe the ownership model (moves, borrows, lifetimes) and how you reason about it, not memorized syntax. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, the official Rust book from the Rust team is the canonical path; the first Rust round is increasingly a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Rust in 100 Seconds
Video: Rust in 100 Seconds (Fireship, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Rust certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Rust is a compiled systems programming language focused on memory safety, performance, and safe concurrency. It gives you the low-level control of C and C++ without a garbage collector, catching whole classes of memory bugs at compile time instead of runtime.
The problem it solves is the old trade-off between safety and speed. Managed languages are safe but pay a garbage-collection cost; C is fast but leaks and crashes. Rust's ownership system delivers both, which is why it shows up in operating systems, browsers, and embedded work.
Key point: A one-line definition plus the safety-without-GC angle beats a feature list. Interviewers open with this to hear how you frame Rust's point.
Watch a deeper explanation
Video: Learn Rust Programming - Complete Course 🦀 (freeCodeCamp.org, YouTube)
Ownership is Rust's core rule set for managing memory. Each value has exactly one owner, and when that owner goes out of scope the value is dropped and its memory freed automatically. No garbage collector, no manual free.
The three rules: each value has one owner, there can only be one owner at a time, and when the owner leaves scope the value is dropped. Everything else in Rust's memory story (moves, borrows, lifetimes) builds on these.
fn main() {
let s = String::from("hello"); // s owns the string
takes_ownership(s); // ownership moves into the function
// println!("{}", s); // error: s was moved
}
fn takes_ownership(text: String) {
println!("{}", text);
} // text dropped here, memory freedKey point: Every deeper Rust question traces back to ownership. The three rules cleanly and the follow-ups on moves and borrows have somewhere to land.
Watch a deeper explanation
Video: Understanding Ownership in Rust (Let's Get Rusty, YouTube)
For types that live on the heap like String or Vec, assignment moves ownership rather than copying. The original variable becomes invalid, so using it afterward is a compile error. This prevents two variables from thinking they own the same memory.
For simple stack types that implement Copy (integers, bools, chars), assignment copies instead, and the original stays usable. So the behavior depends on whether the type is Copy.
let a = String::from("hi");
let b = a; // a moved into b
// println!("{}", a); // error: value borrowed after move
let x = 5;
let y = x; // i32 is Copy, so x is still valid
println!("{} {}", x, y); // 5 5Key point: The 'why' is the answer: one owner at a time. Interviewers watch for whether you know move is the default and Copy is the exception.
Borrowing lets you access a value through a reference without taking ownership. You use & for an immutable borrow and &mut for a mutable one. The value's owner keeps ownership while the borrow is active.
The rules the borrow checker enforces: at any time you can have either one mutable reference or any number of immutable references, but not both, and references must never outlive the data they point to. This is how Rust prevents data races at compile time.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // immutable borrow
println!("{}", first);
v.push(4); // ok, first is no longer used
}Key point: Say the rule as 'one mutable XOR many immutable'. That framing is what interviewers are listening for.
&T is a shared, read-only reference: you can look at the value but not change it, and many can exist at once. &mut T is an exclusive reference: you can modify the value, but only one can exist at a time and no shared references may coexist with it.
This split is what stops data races. If only one path can write and nobody else can read during that write, concurrent corruption can't happen. The compiler checks it for you.
fn main() {
let mut count = 0;
add_one(&mut count); // exclusive borrow to mutate
println!("{}", count); // 1
}
fn add_one(n: &mut i32) {
*n += 1; // dereference to write
}String is an owned, growable, heap-allocated UTF-8 string; you can push to it and change it. &str is a string slice: a borrowed, immutable view into text that lives somewhere else, whether a String or a literal baked into the binary.
String literals in your code are &str. You take &str as a function parameter when you only need to read text, because it accepts both String (via deref) and literals, which makes the function more flexible.
fn greet(name: &str) { // accepts String and literals
println!("Hi {}", name);
}
fn main() {
let owned = String::from("Asha");
greet(&owned); // String derefs to &str
greet("Ben"); // literal is &str
}Key point: The rule of thumb the question needs: take &str for parameters, return String when you own new data. Volunteering that shows real usage.
Option<T> is an enum with two variants: Some(T) holds a value, None means there's nothing. Rust has no null, so any place a value might be absent is typed as Option, and the compiler forces you to handle the None case before you can use the inner value.
This kills the null-pointer bug at the source. You can't accidentally use a missing value because the type system won't let you reach the T without checking first.
fn first_char(s: &str) -> Option<char> {
s.chars().next()
}
match first_char("hi") {
Some(c) => println!("first: {}", c),
None => println!("empty"),
}Key point: Frame it as 'null-safety by type'. The follow-up is usually how you get the value out; have match, if let, and unwrap_or ready.
Result<T, E> is an enum with Ok(T) for success and Err(E) for failure. Functions that can fail return a Result, and the caller must decide what to do with the error. There are no exceptions in Rust, so errors are ordinary values you pass around.
This makes error paths visible in the type signature. You can see from a function's return type that it can fail, and the compiler makes sure you don't ignore that.
use std::num::ParseIntError;
fn parse(s: &str) -> Result<i32, ParseIntError> {
s.parse::<i32>()
}
match parse("42") {
Ok(n) => println!("got {}", n),
Err(e) => println!("failed: {}", e),
}The ? operator is shorthand for error propagation. On a Result, it unwraps Ok and gives you the inner value, or returns the Err early from the current function. On an Option it does the same with Some and None. It turns three lines of match into one character.
It only works inside functions that return a compatible Result or Option, and it converts the error type through the From trait, so a function can collect different error types into one.
use std::fs;
fn read_config(path: &str) -> Result<String, std::io::Error> {
let text = fs::read_to_string(path)?; // returns Err early if it fails
Ok(text.trim().to_string())
}Key point: Explain that ? both unwraps and early-returns, and that it converts errors via From. That second half is what separates users from understanders.
Watch a deeper explanation
Video: Error Handling in Rust (Let's Get Rusty, YouTube)
match compares a value against a series of patterns and runs the arm for the first that fits. It's an expression, so it returns a value, and it must be exhaustive: the compiler rejects it if you leave any possible case unhandled.
Exhaustiveness is the point. When you match on an enum and later add a variant, every match that didn't handle it stops compiling, so the compiler walks you to each spot that needs updating.
let n = 3;
let label = match n {
0 => "zero",
1 | 2 => "small",
3..=9 => "single digit",
_ => "large",
};
println!("{}", label); // single digitKey point: Mention exhaustiveness. Interviewers love hearing that adding an enum variant breaks the build until every match is fixed.
if let is a shorter match for when you only care about one pattern. It runs a block if the value matches that pattern and binds the inner data, with an optional else for everything else. It reads cleaner than a match with one real arm and a throwaway _.
Use it for Option and Result when you want to act on Some or Ok and don't need to handle every other case explicitly. There's also while let for looping until a pattern stops matching.
let maybe = Some(7);
if let Some(n) = maybe {
println!("got {}", n);
} else {
println!("nothing");
}No. Variables are immutable by default. Once you bind a value, you can't change it unless you declare the variable with mut. This makes immutability the easy path and mutation something you opt into deliberately.
Immutable by default reduces bugs and helps the compiler reason about your code, especially around concurrency. If a value never changes, sharing it across threads is trivially safe.
let x = 5;
// x = 6; // error: cannot assign twice to immutable variable
let mut y = 5;
y = 6; // fine, y is mutableShadowing lets you declare a new variable with the same name as an existing one using let again. The new binding shadows the old one, and it can even have a different type. This differs from mutation because you're creating a fresh variable, not changing the old value.
It's handy for transforming a value through stages while keeping one name, like parsing a string into a number without inventing text_num and num_value.
let spaces = " "; // &str
let spaces = spaces.len(); // now usize, shadowed
println!("{}", spaces); // 3Rust has scalar types: integers (i8 through i128 and usize, plus unsigned u8 through u128), floats (f32, f64), booleans (bool), and characters (char, a 4-byte Unicode scalar). It also has compound types: tuples, which group values of different types, and arrays, which hold a fixed number of one type.
Integer types are sized explicitly, which matters for systems work. usize is pointer-sized and used for indexing and lengths.
A struct groups related data under one name. Rust has three forms: named-field structs, tuple structs whose fields are positional, and unit structs with no fields. You attach behavior with impl blocks, keeping data and methods together.
Structs are how you model your domain. Combined with enums and traits, they give you the type-driven design Rust interviews expect you to reach for.
struct User {
name: String,
active: bool,
}
impl User {
fn greeting(&self) -> String {
format!("Hi {}", self.name)
}
}
let u = User { name: String::from("Asha"), active: true };
println!("{}", u.greeting());An enum lists a fixed set of variants a value can be. What makes Rust enums stronger than plain C-style enums is that each variant can carry data of its own, so an enum can model a value that's one of several shapes.
Option and Result are enums, which shows the pattern: enum plus match gives you exhaustive, data-carrying alternatives. You reach for enums whenever a value is exactly one of a known set of cases.
enum Shape {
Circle(f64), // radius
Rectangle(f64, f64), // width, height
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => 3.14159 * r * r,
Shape::Rectangle(w, h) => w * h,
}
}Key point: Stress that variants carry data. That's the feature that makes Rust enums a modeling tool, and the key point is it.
A Vec<T> is a growable, heap-allocated list. You can push and pop, and it resizes itself. An array [T; N] has a fixed length known at compile time and usually lives on the stack. Reach for Vec when the size varies, arrays when it's fixed and small.
Indexing a Vec out of bounds panics, while get returns an Option so you can handle the missing case without crashing.
let mut nums = vec![1, 2, 3];
nums.push(4);
println!("{}", nums.len()); // 4
println!("{:?}", nums.get(10)); // None, no panicFunctions use fn, with typed parameters and an optional return type after ->. Rust is expression-based: most things produce a value. A block's final expression, written without a semicolon, is its return value, so you often skip the return keyword.
The distinction bites beginners: adding a semicolon turns an expression into a statement that returns the unit type (), which then fails to match the declared return type.
fn square(n: i32) -> i32 {
n * n // no semicolon: this is the return value
}
let result = if true { 1 } else { 0 }; // if is an expressionRust has loop for an infinite loop you break out of, while for a condition-based loop, and for to iterate over a range or anything iterable. for is the common one because it can't run off the end of a collection.
loop is special: it's an expression, so you can break with a value and assign the result. Loop labels let break and continue target an outer loop from inside a nested one.
let mut count = 0;
let stopped_at = loop {
count += 1;
if count == 5 {
break count * 2; // break with a value
}
};
println!("{}", stopped_at); // 10A slice is a borrowed view into a contiguous run of a collection, without owning it. &str is a string slice; &[T] is a slice of a Vec or array. A slice holds a pointer and a length, so it knows exactly what range it covers.
Slices let a function work on part of a collection, or accept both arrays and Vecs, without copying data. That's why &[T] parameters are more flexible than &Vec<T>.
fn sum(values: &[i32]) -> i32 { // accepts arrays and Vecs
values.iter().sum()
}
let arr = [1, 2, 3, 4];
println!("{}", sum(&arr[1..3])); // 5, a slice of two elementsCargo is Rust's build system and package manager. It creates projects, compiles code, runs tests, manages dependencies (called crates) declared in Cargo.toml, and produces release builds. Most Rust work happens through cargo commands rather than calling the compiler directly.
Cargo.toml declares your dependencies and metadata; Cargo.lock pins exact versions so builds are reproducible. Knowing cargo build, run, test, and add covers day-to-day work.
The exclamation mark means println! is a macro, not a function. Macros run at compile time and can take a variable number of arguments and check the format string against them, which a normal function can't do the same way.
You'll see the same mark on vec!, format!, panic!, and assert!. Recognizing that a name ending in ! is a macro invocation is a small but real signal you've read real Rust.
Rust infers types where it can, so let x = 5 is fine without annotation. It infers from usage across a function, not just the assignment, which is why sometimes you must annotate to resolve ambiguity, like .collect::<Vec<_>>(). Function signatures always need explicit types.
Numeric types never convert implicitly. You cast with as for simple conversions, but as can truncate silently, so for safe, checked conversions use the From and TryFrom traits, which fail loudly on overflow instead of wrapping.
let big: i64 = 300;
let small = big as u8; // 44, silently truncated
let safe = u8::try_from(big); // Err, value doesn't fit
println!("{:?}", safe);Key point: as truncates while TryFrom checks.
const declares a compile-time constant that's always immutable, must have a type annotation, and can be declared in any scope including the module top level. let creates a variable binding that's immutable by default but can be made mutable with mut, and it's evaluated at runtime.
There's also static for values with a fixed memory location that live for the whole program. For most fixed values like a max limit, const is the right choice.
const MAX_RETRIES: u32 = 3; // compile-time, must be typed
fn main() {
let attempts = MAX_RETRIES;
println!("{}", attempts);
}For candidates with working experience: the type system, standard library judgment, and the questions that separate users from understanders.
A lifetime is a compile-time label describing how long a reference stays valid. Every reference has one; usually the compiler infers it. When it can't, you annotate references with names like 'a to tell it which references share a lifetime.
Lifetimes exist to guarantee no reference outlives the data it points to, which is how Rust rules out dangling pointers at compile time with zero runtime cost. They don't change how long values live; they let the compiler verify relationships it can't figure out alone.
// the returned reference lives as long as both inputs
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}Key point: The key line: lifetimes describe relationships, they don't extend anything. Candidates who say lifetimes 'keep values alive' reveal they haven't got it.
Watch a deeper explanation
Video: Rust Lifetimes Finally Explained! (Let's Get Rusty, YouTube)
A trait defines shared behavior as a set of method signatures, like an interface. Types implement a trait with impl Trait for Type, and any type that implements it can be used wherever that behavior is required. Traits can also provide default method bodies.
Traits drive generics and polymorphism in Rust. You bound a generic on a trait to say 'any type that can do this', and you use trait objects when you need runtime dispatch over different concrete types.
trait Summary {
fn summarize(&self) -> String {
String::from("(no summary)") // default method
}
}
struct Article { title: String }
impl Summary for Article {
fn summarize(&self) -> String {
format!("Article: {}", self.title)
}
}Key point: Mention default methods and trait bounds. Being able to say where traits meet generics is the intermediate bar.
Watch a deeper explanation
Video: Traits in Rust (Let's Get Rusty, YouTube)
Generics let you write functions and types that work over many types, with trait bounds constraining what those types can do. The compiler uses monomorphization: it generates a specialized copy of the code for each concrete type you actually use.
That makes generics zero-cost at runtime. There's no boxing or dynamic dispatch; the generated code is as fast as if you'd written it by hand for each type. The trade-off is larger binaries and longer compile times.
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut max = items[0];
for &item in items {
if item > max { max = item; }
}
max
}Key point: The words to reach for are monomorphization and zero-cost. Then note the binary-size trade-off; that balance is the intermediate signal.
Static dispatch uses generics: the compiler picks the exact method at compile time via monomorphization, so calls are direct and fast. Dynamic dispatch uses trait objects (dyn Trait), where the concrete type is known only at runtime and the method is found through a vtable pointer.
Use generics when the type is known at compile time and you want speed. Use trait objects when you need a collection of different concrete types behind one trait, like a Vec<Box<dyn Draw>>, accepting a small runtime lookup cost.
| Static (generics) | Dynamic (dyn Trait) | |
|---|---|---|
| Resolved | Compile time | Runtime, via vtable |
| Speed | Fastest, inlinable | Small indirection cost |
| Binary size | Larger (per type) | Smaller |
| Use for | Known types, hot paths | Mixed types in one collection |
A closure is an anonymous function that can capture variables from its surrounding scope. It captures by reference, by mutable reference, or by value depending on what the body needs, and you can force a by-value capture with move.
Closures implement one of three traits based on how they use captures: Fn (captures by reference, callable many times), FnMut (mutates captures), and FnOnce (consumes captures, callable once). Function parameters that take closures are bounded on these traits.
let factor = 3;
let multiply = |x: i32| x * factor; // captures factor by reference
println!("{}", multiply(5)); // 15
let data = vec![1, 2, 3];
let owns = move || println!("{:?}", data); // move captures by value
owns();Key point: The all three traits and what triggers each. 'When would a closure be FnOnce?' is the standard follow-up: when it moves a captured value out.
An iterator produces a sequence of values through the Iterator trait, whose one required method is next. Adapter methods like map, filter, and take return new iterators and do nothing on their own; they're lazy. Work happens only when a consuming method like collect, sum, or a for loop pulls values through.
Because chains are lazy and compile down through monomorphization, iterator pipelines are zero-cost: they run as fast as an equivalent hand-written loop, so idiomatic Rust favors them freely.
let total: i32 = (1..=10)
.filter(|n| n % 2 == 0) // lazy
.map(|n| n * n) // lazy
.sum(); // consumes, runs the chain
println!("{}", total); // 220Key point: Say 'adapters are lazy, consumers drive them' and 'zero-cost'. That combination is exactly what this question screens for.
Vec<T> for an ordered, growable list. HashMap<K, V> for key-value lookup with average O(1) access. HashSet<T> for unique membership. BTreeMap and BTreeSet when you need keys kept in sorted order. VecDeque for a double-ended queue with fast pushes and pops at both ends.
The judgment interviewers probe is HashMap versus BTreeMap: hash for raw speed and no ordering, BTree for sorted iteration and range queries.
| Collection | Best at | Ordering |
|---|---|---|
| Vec | Growable indexed list | Insertion order |
| HashMap | Fast key lookup | None |
| BTreeMap | Sorted keys, range queries | Sorted |
| VecDeque | Queue, push/pop both ends | Insertion order |
#[derive(...)] is an attribute that auto-generates trait implementations for a struct or enum, saving you from writing boilerplate. Common derives include Debug for {:?} printing, Clone and Copy for duplication, PartialEq for == comparisons, and Hash for use as a HashMap key.
It works because those traits have derivable logic that follows a mechanical rule over your fields. If your type needs custom behavior, you write the impl by hand instead.
#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }
let a = Point { x: 1, y: 2 };
let b = a.clone();
println!("{:?} == {:?}: {}", a, b, a == b);Define an enum with a variant per failure kind, then implement Display for human-readable messages and the std::error::Error trait so it fits the ecosystem. Implement From for underlying errors so the ? operator converts into your type automatically.
In practice teams use crates to cut the boilerplate: thiserror derives Display, Error, and From for library error types, and anyhow gives a catch-all error type for application code. Naming that split, thiserror for libraries and anyhow for apps, indicates production experience.
use std::fmt;
#[derive(Debug)]
enum ConfigError {
Missing(String),
Invalid(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::Missing(k) => write!(f, "missing key: {}", k),
ConfigError::Invalid(k) => write!(f, "invalid value: {}", k),
}
}
}
impl std::error::Error for ConfigError {}Return a Result for errors the caller can reasonably recover from or should decide about: a file that might not exist, input that might not parse, a network call that might fail. These are expected failures that belong in the type signature.
Panic for bugs and broken invariants: an index that should always be valid, a The code assumes can never happen, a failed assertion. In library code prefer Result and let the caller choose; reserve unwrap and expect for cases you've proven can't fail or for quick prototypes.
Key point: The line the question needs: Result for expected failures, panic for programmer errors. Then admit unwrap is fine only when failure is truly impossible.
Clone is an explicit, potentially expensive duplication you call with .clone(); it can allocate and copy heap data. Copy is an implicit, cheap, bitwise duplication that happens automatically on assignment, and it's only allowed for types made entirely of stack data with no heap ownership.
Every Copy type is also Clone, but not the reverse. String and Vec are Clone but not Copy, because duplicating them means copying heap memory, which Rust refuses to do silently.
#[derive(Copy, Clone)]
struct Coord { x: i32, y: i32 } // all stack data, Copy allowed
let a = Coord { x: 1, y: 2 };
let b = a; // copied, a still usable
println!("{}", a.x);
let s = String::from("hi");
let t = s.clone(); // explicit, heap copiedBox<T> is the simplest smart pointer: it puts a value on the heap and owns it, with the pointer itself on the stack. When the Box goes out of scope, the heap value is freed. It's a single-owner heap allocation with no runtime cost beyond the indirection.
You reach for Box in three cases: recursive types that need a known size (like a linked list or tree node), trait objects (Box<dyn Trait>) for dynamic dispatch, and moving a large value without copying it around.
enum List {
Cons(i32, Box<List>), // Box gives the recursive type a fixed size
Nil,
}
use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));Rc<T> is a reference-counted smart pointer for single-threaded shared ownership: several owners hold the same value and it's dropped when the last one goes away. RefCell<T> moves borrow checking to runtime, letting you mutate data through a shared reference and panicking if you violate the borrow rules.
Combined as Rc<RefCell<T>>, they give shared, mutable data in a single thread, the pattern behind graphs and trees where nodes need multiple owners that can still change. The trade-off is runtime borrow checks and no thread safety; for threads you'd use Arc<Mutex<T>> instead.
use std::rc::Rc;
use std::cell::RefCell;
let shared = Rc::new(RefCell::new(vec![1, 2]));
let clone = Rc::clone(&shared);
clone.borrow_mut().push(3); // mutate through a shared ref
println!("{:?}", shared.borrow()); // [1, 2, 3]Key point: The follow-up is 'what's the multithreaded equivalent?'. Answer Arc<Mutex<T>> and note the runtime cost each layer adds.
Beyond simple variants, patterns can destructure structs and tuples, bind ranges, use match guards (an if condition on an arm), bind a whole value while also matching its parts with @, and ignore fields with .. This lets one match arm express fairly specific conditions.
These features keep matching expressive without nested ifs. The compiler still checks exhaustiveness, so complex matches stay safe.
struct Point { x: i32, y: i32 }
let p = Point { x: 0, y: 7 };
match p {
Point { x: 0, y } => println!("on y-axis at {}", y),
Point { x, y } if x == y => println!("diagonal"),
Point { x, .. } => println!("x is {}", x),
}Code is organized into modules with mod, forming a tree rooted at the crate. Items are private to their module by default; pub exposes them, and pub(crate) limits visibility to the current crate. You bring paths into scope with use.
A crate is the compilation unit: a binary or a library. A package (a Cargo.toml) holds one or more crates. Knowing that private-by-default plus explicit pub is how Rust controls its public surface answers most module questions.
In argument position, fn f(x: impl Display) is shorthand for a generic bounded by that trait: it accepts any type implementing Display. In return position, -> impl Iterator<Item = i32> says the function returns some concrete type that implements the trait without naming it, which is how you return closures and iterator chains.
Return-position impl Trait is static dispatch: one concrete type is chosen at compile time. If you needed to return different concrete types from different branches, you'd use Box<dyn Trait> instead.
fn evens(limit: i32) -> impl Iterator<Item = i32> {
(0..limit).filter(|n| n % 2 == 0)
}
for n in evens(6) {
print!("{} ", n); // 0 2 4
}Unit tests live in the same file inside a #[cfg(test)] module, with each test function marked #[test] and using assert!, assert_eq!, or assert_ne!. Integration tests go in a separate tests/ directory and exercise your crate's public API. cargo test runs them all.
Tests marked #[should_panic] verify a function panics as expected, and functions returning Result let you use ? inside tests. Rust's built-in test harness means no external framework is needed for most work.
fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}thread::spawn starts an OS thread with a closure, and the returned handle's join method waits for it and collects its result. To move data into a thread you use a move closure, transferring ownership so there's no dangling reference.
To share data across threads you wrap it in Arc for shared ownership and a Mutex or RwLock for safe mutation. The compiler enforces this through the Send and Sync traits, so unsafe sharing simply won't compile.
use std::thread;
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
*c.lock().unwrap() += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 5Key point: Say Arc for ownership, Mutex for mutation, Send and Sync for the compiler's guarantee. That trio is the shape of every safe-sharing answer.
From<T> defines how to build your type from a T, and Into<U> is the reverse-direction call. They're linked: implement From for a type and the standard library gives you the matching Into for free, so you only ever write the From impl.
This is the idiomatic way to convert between types, and it's what the ? operator uses to turn one error type into another. Taking impl Into<String> as a parameter is a common pattern that lets callers pass either a &str or a String.
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)
}
}
let f: Fahrenheit = Celsius(100.0).into(); // Into comes free
println!("{}", f.0); // 212Key point: The linked answer: implement From, get Into for free. Connecting it to how ? converts errors is the detail that indicates real experience.
You implement the matching trait from std::ops. Add gives you +, Mul gives *, Index gives [], and so on. Each trait has an associated Output type and one method, so overloading is just implementing a trait with a fixed name.
This keeps operator behavior explicit and discoverable: there's no hidden operator magic, just trait impls you can find and read.
use std::ops::Add;
#[derive(Debug)]
struct V2 { x: i32, y: i32 }
impl Add for V2 {
type Output = V2;
fn add(self, other: V2) -> V2 {
V2 { x: self.x + other.x, y: self.y + other.y }
}
}
let sum = V2 { x: 1, y: 2 } + V2 { x: 3, y: 4 };
println!("{:?}", sum); // V2 { x: 4, y: 6 }advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
unsafe is a keyword that unlocks five extra abilities the borrow checker can't verify: dereferencing raw pointers, calling unsafe functions, accessing or modifying mutable statics, implementing unsafe traits, and accessing union fields. It does not turn off the borrow checker for everything else.
The point is a small, auditable boundary. You use unsafe to build a safe abstraction (like Vec's internals or FFI wrappers) and take on the job of upholding invariants the compiler normally guarantees. Good practice keeps unsafe blocks tiny and documents why each is sound.
let mut num = 5;
let r1 = &num as *const i32; // raw pointer, safe to create
let r2 = &mut num as *mut i32;
unsafe {
*r2 = 10; // dereferencing raw pointers needs unsafe
println!("{}", *r1); // 10
}Key point: Correct the common myth: unsafe does not disable the borrow checker. Naming the exact abilities it grants signals you've actually used it.
Send means a type's ownership can be transferred to another thread. Sync means a type is safe to share by reference across threads, which is equivalent to saying &T is Send. Both are auto traits: the compiler implements them automatically when a type's parts qualify.
They're the foundation of Rust's fearless concurrency. thread::spawn requires Send, and sharing across threads requires Sync, so the compiler rejects sending a non-thread-safe type like Rc across threads before it can cause a data race. Rc is not Send; Arc is.
Key point: The sharp answer defines Sync as '&T is Send' and gives the Rc-vs-Arc example. That precision is what senior the key signal is.
An async fn returns a Future, a value representing a computation that isn't finished. Futures are lazy: they do nothing until an executor polls them. await suspends the current async function at a point where a future isn't ready yet, letting the executor run other tasks, and resumes when it's ready.
Rust deliberately ships no built-in runtime, so you pick an executor like Tokio or async-std. The compiler turns each async function into a state machine, which is why Rust's async is zero-cost in principle but has a steeper mental model than green-threaded languages.
async fn fetch(id: u32) -> String {
format!("item {}", id)
}
async fn run() {
let a = fetch(1).await; // suspends until ready
let b = fetch(2).await;
println!("{} {}", a, b);
}Key point: Say futures are lazy and need an executor, and that async compiles to a state machine. 'Why doesn't my async function run?' (never awaited or polled) is a classic follow-up.
Zero-cost abstraction means high-level constructs (iterators, generics, closures, Option) compile down to code as efficient as a hand-written low-level version, so you pay no runtime penalty for the abstraction. Iterator chains, for instance, optimize into the same loop you'd write by hand.
Where it breaks down: trait objects add vtable indirection, Rc and RefCell add reference counts and runtime borrow checks, and async adds state-machine overhead. The honest senior take is that most abstractions are zero-cost, but the ones involving dynamic dispatch or runtime bookkeeping aren't, and you should know which is which.
The borrow checker analyzes the flow of references to enforce that borrows never violate the aliasing rules and never outlive their data. Non-Lexical Lifetimes (NLL), stabilized in 2018, changed borrows to end at their last use rather than at the end of the enclosing scope.
Before NLL, a borrow held until the closing brace, which rejected code that was actually fine. After NLL, a mutable borrow can start right after an immutable borrow is last used, so more correct programs compile. Knowing this history explains why old Rust examples add extra scopes that modern code doesn't need.
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{}", first); // last use of the immutable borrow
v.push(4); // allowed under NLL, borrow already endedPhantomData<T> is a zero-sized marker that tells the compiler your type acts as though it holds a T, even when it doesn't store one directly. It affects drop checking, variance, and the auto traits (Send and Sync) without adding any runtime size.
You need it with raw pointers or type parameters that don't appear in any field, common in FFI wrappers and unsafe abstractions, so the compiler applies the right ownership and thread-safety reasoning to your type.
use std::marker::PhantomData;
struct Handle<T> {
id: u32,
_marker: PhantomData<T>, // behaves as if it owns a T
}An associated type is a single output type the implementer fixes, written as type Item inside the trait. A generic parameter lets a type implement the trait many times with different type arguments. Iterator uses an associated type Item because a given iterator yields one element type.
Choose an associated type when there's exactly one natural output type per implementer, which keeps signatures clean. Choose a generic parameter when a type genuinely implements the trait for several argument types, like From<T> for many source types.
trait Container {
type Item; // associated type
fn get(&self, i: usize) -> Option<&Self::Item>;
}
impl Container for Vec<i32> {
type Item = i32;
fn get(&self, i: usize) -> Option<&i32> {
self.as_slice().get(i)
}
}Implementing Drop gives a type a destructor: the drop method runs automatically when the value goes out of scope, for cleanup like closing files or releasing locks. You can't call drop yourself; to free something early you pass it to std::mem::drop.
Order matters: values drop in reverse declaration order, and a struct's fields drop in declaration order after the struct's own drop runs. This deterministic, scope-based cleanup is Rust's RAII, and it's why you rarely need finally blocks.
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn main() {
let _a = Guard("a");
let _b = Guard("b");
} // prints "dropping b" then "dropping a"Interior mutability lets you mutate data through a shared &T reference, which the normal rules forbid. Cell and RefCell provide it for single-threaded code; Mutex and RwLock provide it across threads. The trick is moving the borrow enforcement from compile time to runtime.
It stays safe because the checks still happen, just later. RefCell tracks borrows at runtime and panics if you take a mutable borrow while another borrow is live. So you keep Rust's guarantee against aliased mutation; you trade a compile error for a runtime panic and a small cost.
Key point: The precise framing: interior mutability moves borrow enforcement from compile time to runtime. Note the RefCell panic; that's the cost the question needs acknowledged.
By default Rust uses its own repr(Rust) layout: the compiler is free to reorder fields and add padding for alignment and size, so you can't assume field order matches your source. This lets it pack types efficiently and even niche-optimize enums like Option<&T> to pointer size.
#[repr(C)] forces C-compatible layout in declaration order, which you need for FFI. #[repr(transparent)] guarantees a single-field wrapper has the same layout as its field. Knowing that default layout is unspecified is the point: relying on it is a bug.
Monomorphization generates a separate copy of generic code for every concrete type, which grows binary size and lengthens compile times, sometimes badly in heavily generic codebases. It can also hurt instruction-cache behavior when the same logic exists in many near-identical copies.
You manage it by pushing the generic surface thin: a small generic wrapper that immediately calls a single non-generic inner function, so only the wrapper is duplicated. Switching a hot generic to a trait object (dyn) also trades a little runtime dispatch for one shared copy.
// thin generic wrapper delegates to one non-generic body
fn log_all<T: AsRef<str>>(items: &[T]) {
fn inner(items: &[&str]) { // compiled once
for s in items { println!("{}", s); }
}
let refs: Vec<&str> = items.iter().map(|s| s.as_ref()).collect();
inner(&refs);
}You declare foreign functions in an extern "C" block and call them inside unsafe, since the compiler can't verify the foreign side. To expose Rust to C, mark a function extern "C" with #[no_mangle] so its symbol name is preserved. Types crossing the boundary must be #[repr(C)].
The hard parts are ownership and memory: you decide which side allocates and frees, convert between Rust String and C's null-terminated char* with CString and CStr, and never let Rust drop memory C still holds. Tools like bindgen generate the declarations from C headers.
extern "C" {
fn abs(input: i32) -> i32; // from libc
}
fn main() {
let n = unsafe { abs(-7) }; // unsafe: crossing the FFI boundary
println!("{}", n); // 7
}Measure first with a release build, because debug builds are far slower and misleading. Use a profiler like perf with flamegraphs or a sampling tool to find hot spots, and criterion for stable microbenchmarks. Guessing at bottlenecks in a language this fast usually wastes effort on the wrong code.
Then fix in order of impact: better algorithms and data structures, cutting allocations (reserve capacity, reuse buffers, avoid needless clones), and only then reaching for parallelism with rayon or SIMD. The discipline (profile a release build, don't guess) is what the interviewer is really scoring.
Key point: The trap is optimizing a debug build. Leading with 'release build, then profile' shows you've actually done this rather than read about it.
Message passing with channels (std::sync::mpsc or crossbeam) lets threads communicate by sending owned values instead of sharing memory, which sidesteps locks entirely and matches Rust's ownership grain. For data parallelism, rayon turns sequential iterators into parallel ones with par_iter, handling the thread pool for you.
Shared state with Arc<Mutex<T>> is still valid, but The production-ready answer knows when to avoid it: prefer channels for pipelines and ownership hand-off, rayon for CPU-bound data parallelism, and reserve locks for genuinely shared mutable state where contention is low.
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 0..3 { tx.send(i).unwrap(); }
});
for received in rx {
println!("{}", received); // 0 1 2
}Diagnose with cargo build --timings for a per-crate breakdown and cargo llvm-lines to find which generic functions explode into the most code. The usual culprits are heavy monomorphization, large dependency trees, and procedural macros.
Reduce them by splitting a large crate into smaller ones so parallel and incremental builds do less work, trimming or feature-gating dependencies, replacing some generics with trait objects to cut monomorphized copies, and using a faster linker like lld or mold. For local iteration, cargo check skips codegen entirely.
Clarify requirements first: read-heavy or write-heavy, eviction policy, TTL, and whether it's shared across async tasks or OS threads. Then pick the shape. A read-heavy cache wants Arc<RwLock<HashMap<K, V>>> so many readers proceed in parallel and writers take an exclusive lock. For high contention, a sharded map (many locks over key ranges) or a ready-made crate like dashmap avoids one global lock.
The closing step is the operational edges: bounding size with an LRU eviction policy, expiring entries by storing an insertion time, and choosing the lock granularity to match the read/write mix. Structuring the answer as clarify, data structure, locking, eviction is what the question is really scoring.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
type Cache<K, V> = Arc<RwLock<HashMap<K, V>>>;
fn get<K: std::hash::Hash + Eq, V: Clone>(cache: &Cache<K, V>, key: &K) -> Option<V> {
cache.read().unwrap().get(key).cloned() // shared read lock
}Key point: The RwLock-vs-sharding choice and the clarify-first structure matter more than any single API call.
Rust competes with C and C++ for systems work and with Go for backend services. Against C and C++ it keeps the manual control and predictable performance while removing the memory-safety footguns those languages leave to the programmer. Against Go it trades a garbage collector and a gentle learning curve for no GC pauses, finer control, and a compiler that refuses unsafe sharing. The cost is real: Rust's learning curve is steep and compile times are slow, and saying that out loud is itself an interview signal, because it shows you pick tools on merits rather than hype.
| Language | Memory model | Best at | Watch out for |
|---|---|---|---|
| Rust | Ownership, no GC | Safe systems code, WASM, CLIs | Steep learning curve, slow compiles |
| C | Manual malloc/free | OS kernels, tiny embedded | Use-after-free, buffer overruns |
| C++ | Manual plus RAII | Games, engines, large systems | Undefined behavior, complex spec |
| Go | Garbage collected | Networked services, tooling | GC pauses, less low-level control |
Prepare in layers, and practice out loud. Most Rust rounds move from concept questions about ownership to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Rust interview flow
Earlier rounds increasingly run as AI coding interviews. Ownership and error handling are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Rust questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works