Rust Interview Questions and Answers

Last updated:

Check out 60 of the most common Rust interview questions, then take an AI-powered practice interview

Systems ProgrammingWebAssemblyBlockchainPerformanceMemory Safety
60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

Explain Rust's three ownership rules and what actually happens when the compiler reports E0382 (borrow of moved value).

BasicOwnership

Answer

The rules: every value has exactly one owner, only one owner at a time, and the value is dropped when the owner goes out of scope. Assignment, passing by value, and returning by value all move ownership for types that do not implement Copy. After a move, the original binding is statically dead: using it triggers E0382, which is not a runtime error but the compiler proving your program would otherwise double-free or use freed memory.

Under the hood a move is a bitwise copy of the value's stack representation (for a String: pointer, length, capacity, 24 bytes on 64-bit) plus a compile-time rule that the source may no longer be used. Nothing is copied on the heap and no destructor runs at the move site, which is why moves are cheap regardless of payload size. Interviewers probe the mechanics: they will ask why i32 assignment does not invalidate the source (it implements Copy because it is plain stack data with no Drop), why String cannot implement Copy (it owns a heap buffer, so bitwise duplication would create two owners of one allocation), and how you fix an E0382 in practice: borrow with & when the callee only needs to read, clone explicitly when you genuinely need two owners, or restructure so ownership flows one way. The strongest answers mention that Rust inserts drop glue at the point where the last owner exits scope, and that partial moves out of a struct leave the remaining fields usable but the whole struct unusable.

fn takes_ownership(s: String) -> usize {
    s.len()
} // s dropped here, heap buffer freed

fn main() {
    let name = String::from("goodspace");
    let len = takes_ownership(name);
    // println!("{name}"); // error[E0382]: borrow of moved value: `name`

    // Fix 1: borrow instead of moving
    let name2 = String::from("goodspace");
    let len2 = borrows(&name2);
    println!("{name2} is {len2} bytes"); // still valid

    // Fix 2: explicit clone when two owners are required
    let name3 = name2.clone();
    let _ = takes_ownership(name3);
    let _ = len;
}

fn borrows(s: &str) -> usize {
    s.len()
}

Key Points

  • One owner per value; drop runs when the owner leaves scope
  • Move = bitwise copy of the stack part + source statically invalidated
  • E0382 is compile-time prevention of use-after-free / double-free
  • Fixes: borrow (&), clone deliberately, or redesign ownership flow
💡 Pro Tip: When asked to fix an E0382, reach for a borrow first and say why: cloning silences the compiler but can hide an allocation on a hot path, and interviewers notice which instinct fires first.
Q2

What are Rust's borrowing rules, and why does the compiler reject simultaneous &mut and & references with E0502?

BasicBorrowing

Answer

At any point you may have either any number of shared references (&T) or exactly one mutable reference (&mut T) to a value, never both, and no reference may outlive the value it points to. E0502 (cannot borrow x as mutable because it is also borrowed as immutable) enforces the aliasing XOR mutability invariant. The reason is not pedantry: it eliminates entire bug classes at compile time.

Iterator invalidation is the classic example: pushing to a Vec while iterating it can reallocate the buffer, leaving the iterator pointing at freed memory; in C++ that is undefined behaviour discovered in production, in Rust it is E0502 discovered before commit. The same rule makes data races impossible in safe code, because a data race requires two threads with aliased access where at least one writes. Since Rust 2018 the borrow checker uses non-lexical lifetimes (NLL): a borrow ends at its last use, not at the closing brace, so many patterns that looked illegal pre-2018 compile fine now.

Interviewers often test this exact point by showing code where a shared borrow's last use precedes the mutable borrow and asking whether it compiles (it does). They also probe the standard workarounds when the rules feel too strict for a correct program: split borrows via slice::split_at_mut, restructuring to compute indices before mutating, or interior mutability (RefCell, Mutex) which moves the check to runtime. Saying 'the borrow checker is wrong here so I use unsafe' is an interview-ending answer; the checker is conservative but the safe escape hatches almost always suffice.

fn main() {
    let mut scores = vec![10, 20, 30];

    let first = &scores[0];       // shared borrow starts
    println!("first = {first}");  // NLL: shared borrow ends HERE (last use)
    scores.push(40);               // ok in edition 2018+

    // This version fails:
    let first = &scores[0];
    scores.push(50); // error[E0502]: cannot borrow `scores` as mutable
    // println!("{first}");        // ...because `first` is still live here

    // Split borrows when you need two &mut into one slice:
    let (left, right) = scores.split_at_mut(1);
    left[0] += right[0];
}

Key Points

  • Many &T or one &mut T, never both simultaneously
  • Prevents iterator invalidation and data races at compile time
  • NLL: a borrow ends at its last use, not at end of scope
  • split_at_mut and interior mutability are the sanctioned escape hatches
Q3

What is the difference between String, &str, and str, and when should a function take each?

BasicStrings

Answer

str is the primitive string slice type: a UTF-8 byte sequence of unknown compile-time size (it is !Sized), so you almost never handle a bare str; you handle it behind a pointer. &str is a fat pointer (pointer + length, 16 bytes on 64-bit) to UTF-8 data that lives somewhere else: a string literal in the binary's read-only data segment, a slice of a String, or a slice of a byte buffer validated with std::str::from_utf8. String is the owned, growable, heap-allocated variant: pointer, length, and capacity, analogous to Vec<u8> with a UTF-8 invariant. API design guidance interviewers expect: functions that only read text should take &str, because String derefs to &str via Deref coercion, so callers can pass either without allocation.

Functions that need to store or consume the text should take String (or impl Into<String>), making the ownership transfer explicit at the call site. Returning &str from a function is only possible when the data outlives the call, which is where lifetime questions begin. Common gotchas worth naming: indexing a String with s[0] does not compile because UTF-8 is variable-width; you use s.chars().nth(0) or byte slicing with explicit ranges that must fall on char boundaries or the code panics with 'byte index is not a char boundary'. len() returns bytes, not characters: "नमस्ते".len() is 18, chars().count() is 6, an important detail for anyone handling Hindi or other Indic text in production. Concatenation with + consumes the left operand; format! borrows both.

fn shout(input: &str) -> String {
    input.to_uppercase()
}

fn main() {
    let literal: &str = "hire me";        // points into the binary
    let owned: String = String::from("hire me"); // heap allocation

    // Deref coercion: &String -> &str, so both calls work
    println!("{}", shout(literal));
    println!("{}", shout(&owned));

    let hindi = "नमस्ते";
    println!("bytes = {}", hindi.len());          // 18
    println!("chars = {}", hindi.chars().count()); // 6

    // let ch = hindi[0]; // does not compile: String is not indexable
    let slice = &hindi[0..6]; // ok: falls on char boundaries (नम)
    println!("{slice}");
}

Key Points

  • &str = fat pointer to UTF-8 owned elsewhere; String = owned heap buffer
  • Take &str for read-only params; take String to signal ownership transfer
  • len() counts bytes; slicing off a char boundary panics
  • Deref coercion lets &String pass wherever &str is expected
Q4

Copy vs Clone in Rust: what does each trait actually do, and why can a type containing a String never be Copy?

BasicOwnership

Answer

Clone is an explicit, potentially expensive duplication: calling .clone() runs arbitrary code, for String it allocates a new heap buffer and memcpys the bytes. Copy is a marker trait with no methods that changes move semantics: assignments and function calls duplicate the value bitwise instead of moving it, so the source stays valid. Copy requires Clone as a supertrait, and the compiler enforces two conditions: every field must itself be Copy, and the type must not implement Drop.

A struct containing String can never be Copy because String owns a heap allocation; bitwise duplication would produce two Strings whose destructors both free the same pointer, a guaranteed double-free, which is exactly what the ownership system exists to prevent. That is also why Copy and Drop are mutually exclusive: Copy means 'duplicating the bits is the whole story' and Drop means 'there is cleanup logic', and both cannot be true. Types that are Copy in std: all integers and floats, bool, char, shared references &T (copying a pointer is fine, the borrow rules control aliasing separately), and tuples/arrays of Copy types.

Notably &mut T is not Copy, because duplicating it would create two simultaneous mutable references. Practical guidance interviewers listen for: derive Copy on small plain-data types (a 2D point, a config flag enum) for ergonomics, but do not slap Copy on anything larger than a couple of machine words, since implicit copies of a 200-byte struct in a hot loop are a silent performance cost that would at least be visible as .clone() otherwise.

#[derive(Debug, Clone, Copy, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}

#[derive(Debug, Clone)] // Copy impossible: String is not Copy
struct Candidate {
    name: String,
    score: u32,
}

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };
    let p2 = p1;            // bitwise copy, p1 still usable
    println!("{p1:?} {p2:?}");

    let c1 = Candidate { name: "Asha".into(), score: 91 };
    let c2 = c1.clone();     // explicit deep copy (new heap buffer)
    // let c3 = c1;          // this would MOVE c1, not copy it
    println!("{} {}", c1.name, c2.score);
}

Key Points

  • Clone = explicit method call, may allocate; Copy = implicit bitwise duplication
  • Copy types cannot implement Drop and all fields must be Copy
  • &T is Copy, &mut T is not
  • Derive Copy only for small plain-data types
Q5

How do Option<T> and Result<T, E> replace null and exceptions, and what exactly does the ? operator desugar to?

BasicError Handling

Answer

Rust has no null and no exceptions for recoverable errors. Absence is Option<T> (Some(value) or None) and fallibility is Result<T, E> (Ok(value) or Err(error)). Because both are ordinary enums, the type system forces the caller to acknowledge the failure path: you cannot use the T inside without matching, mapping, or explicitly opting into a panic with unwrap()/expect().

The ? operator is the ergonomic core: written after an expression returning Result, it desugars approximately to a match that returns Ok's payload on success and does an early return Err(From::from(e)) on failure. That From::from call is the detail interviewers fish for: it means ? auto-converts the error type into the caller's error type whenever a From impl exists, which is precisely how thiserror's #[from] attribute and anyhow's blanket conversions make error plumbing nearly invisible. ? also works on Option inside functions returning Option, and since Rust 1.26 main itself can return Result, so ? works top to bottom. Production judgement to state: unwrap() is acceptable in tests and for invariants you can prove locally (document with expect("reason") so the panic message says why it was impossible), but in request-handling paths every unwrap is a latent 500-and-restart.

Useful combinators worth naming instead of match ladders: map, and_then, ok_or, unwrap_or_else, and Option::take for moving a value out of a &mut Option. A candidate who reaches for if result.is_ok() { result.unwrap() } instead of pattern matching signals they have not internalised the model.

use std::fs;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    BadPort(ParseIntError),
}

impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self { ConfigError::BadPort(e) }
}

fn read_port(path: &str) -> Result<u16, ConfigError> {
    let raw = fs::read_to_string(path)?; // io::Error -> ConfigError via From
    let port: u16 = raw.trim().parse()?; // ParseIntError -> ConfigError
    Ok(port)
}

fn main() {
    match read_port("port.txt") {
        Ok(p) => println!("listening on {p}"),
        Err(e) => eprintln!("config error: {e:?}"),
    }
}

Key Points

  • Option models absence, Result models fallibility, both as plain enums
  • ? = early return of Err(From::from(e)), enabling error-type conversion
  • unwrap/expect are deliberate panics; avoid on request paths
  • Combinators (map, and_then, ok_or) beat is_ok()/unwrap() ladders
💡 Pro Tip: If asked to review code, flagging a bare unwrap() on an I/O result and suggesting ? with a typed error is one of the fastest credibility wins available.
Q6

How does pattern matching work in Rust, and what do match exhaustiveness, if let, and let-else each give you?

BasicPattern Matching

Answer

match compares a value against patterns top to bottom and must be exhaustive: if you match a u8 or an enum and miss a case, the compiler emits E0004 (non-exhaustive patterns) listing exactly which variants are uncovered. Exhaustiveness is a refactoring safety net: add a variant to an enum and every match in the codebase that fails to handle it becomes a compile error, which is why idiomatic Rust avoids the catch-all _ arm on enums you own; a wildcard silently swallows future variants. Patterns are rich: literals, ranges (1..=5), destructuring of structs/enums/tuples, or-patterns (Msg::Quit | Msg::Close), guards (Some(n) if n > 0), and @ bindings that capture while testing (id @ 100..=999).

Bindings in arms follow normal ownership rules, so matching on a value moves data into the arm unless you match on a reference or use ref. When you only care about one pattern, if let Some(x) = opt runs a block on match and optionally an else block; while let drives loops off an iterator-like source. let-else, stabilised in Rust 1.65, is the refutable-binding form: let Some(user) = lookup(id) else { return Err(...) }; the else block must diverge (return, break, continue, panic), after which the binding is available unwrapped in the enclosing scope with no extra indentation. Interviewers often ask candidates to rewrite a nested match ladder using let-else precisely because it distinguishes people writing 2026 Rust from people writing 2019 Rust.

enum Payment {
    Upi { vpa: String },
    Card { last4: u16 },
    NetBanking,
}

fn describe(p: &Payment) -> String {
    match p {
        Payment::Upi { vpa } => format!("UPI via {vpa}"),
        Payment::Card { last4: n @ 0..=9999 } => format!("card **{n:04}"),
        Payment::Card { .. } => "invalid card".into(),
        Payment::NetBanking => "netbanking".into(),
        // no `_` arm: adding a variant later becomes a compile error here
    }
}

fn require_upi(p: Payment) -> Result<String, &'static str> {
    // let-else (Rust 1.65+): unwrap or diverge, no nesting
    let Payment::Upi { vpa } = p else {
        return Err("only UPI supported");
    };
    Ok(vpa)
}

Key Points

  • match must be exhaustive; E0004 lists missed variants
  • Avoid `_` on your own enums so new variants break loudly
  • Guards, ranges, or-patterns, and @ bindings compose
  • let-else (1.65) flattens the unwrap-or-early-return pattern
Q7

Compare arrays, Vec<T>, and slices in Rust: memory layout, growth behaviour, and when an API should accept &[T].

BasicCollections

Answer

An array [T; N] has its length in the type, lives entirely on the stack (or inline in its containing struct), and cannot grow; [0u8; 4096] is 4096 bytes with zero heap involvement. Vec<T> is the owned growable buffer: a (pointer, length, capacity) triple on the stack pointing at a heap allocation. When a push exceeds capacity, Vec allocates a larger buffer (growth is amortised doubling), memcpys the elements over, and frees the old one, which is why pushing while holding a reference into the Vec is rejected by the borrow checker: the reallocation would dangle that reference.

If you know the size ahead, Vec::with_capacity(n) avoids the repeated reallocations, a standard first optimisation when profiling shows allocator time. A slice &[T] is a borrowed fat-pointer view (pointer + length) over any contiguous sequence: an array, a Vec, or a subrange of either. The API-design rule interviewers want stated: accept &[T] (or &str for text) in function signatures because both arrays and Vecs coerce to it, and return Vec<T> when the function produces ownership.

Indexing with v[i] panics on out-of-bounds ('index out of bounds: the len is 3 but the index is 7'); v.get(i) returns Option<&T> for the checked path, the right choice when the index comes from user input. Bounds checks exist in release builds too, though the optimiser eliminates provably-safe ones, and iterator-based loops typically avoid them entirely, which is one reason idiomatic iterator code often outperforms hand-written index loops.

fn average(readings: &[f64]) -> Option<f64> {
    if readings.is_empty() {
        return None;
    }
    Some(readings.iter().sum::<f64>() / readings.len() as f64)
}

fn main() {
    let fixed: [f64; 3] = [1.0, 2.0, 3.0]; // stack, size in type
    let mut dynamic: Vec<f64> = Vec::with_capacity(1000); // one allocation
    dynamic.extend([4.0, 5.0]);

    // Both coerce to &[f64]:
    println!("{:?}", average(&fixed));
    println!("{:?}", average(&dynamic));

    // Checked vs panicking access:
    let idx = 7;
    match dynamic.get(idx) {
        Some(v) => println!("{v}"),
        None => println!("index {idx} out of range"),
    }
    // dynamic[idx] would panic: index out of bounds
}

Key Points

  • [T; N] = fixed, stack; Vec<T> = ptr/len/cap on heap; &[T] = borrowed view
  • Vec growth reallocates: use with_capacity when the size is known
  • Accept &[T] in signatures, return Vec<T> for ownership
  • get() for user-controlled indices, [] only for proven-in-range access
Q8

How do structs and impl blocks work in Rust, and what is the difference between methods taking self, &self, and &mut self?

BasicStructs & Methods

Answer

Rust separates data (struct) from behaviour (impl blocks); there is no class keyword and no inheritance. A struct declares named fields; an impl Type block attaches associated functions and methods. An associated function has no self parameter and is called with path syntax, Type::new() being the convention for constructors since Rust has no constructor language feature; a method takes some form of self as its first parameter and is called with dot syntax.

The three receiver forms encode ownership intent directly in the signature: &self borrows immutably (readers: getters, calculations), &mut self borrows mutably (mutators: setters, state transitions), and self takes ownership, consuming the value, which is the backbone of builder APIs (each step returns Self) and of typestate designs where an operation invalidates the old state, for example Connection::close(self) making further use a compile error. Method-call syntax auto-references and auto-dereferences: point.norm() works whether you hold a Point, &Point, or Box<Point>, because the compiler inserts &, &mut, or * as needed to match the receiver, one of the few implicit conversions in the language. Field-level mutability does not exist; mutability is a property of the binding (let mut) or the reference.

Visibility is per-field: pub struct with private fields plus a constructor is the standard encapsulation pattern, and it is what makes invariants enforceable, since code outside the module cannot construct or mutate the struct arbitrarily. Multiple impl blocks for one type are allowed and commonly used to group trait implementations separately from inherent methods.

pub struct JobPosting {
    title: String,      // private: outsiders must use the constructor
    salary_lpa: (u8, u8),
    active: bool,
}

impl JobPosting {
    // associated function (no self): the conventional constructor
    pub fn new(title: impl Into<String>, min: u8, max: u8) -> Self {
        assert!(min <= max, "salary range inverted");
        Self { title: title.into(), salary_lpa: (min, max), active: true }
    }

    pub fn title(&self) -> &str {          // &self: read-only
        &self.title
    }

    pub fn deactivate(&mut self) {          // &mut self: mutation
        self.active = false;
    }

    pub fn into_archive_record(self) -> String { // self: consumes the value
        format!("archived: {}", self.title)
    }
}

Key Points

  • Data in struct, behaviour in impl; no inheritance
  • &self read, &mut self mutate, self consume (builders, typestate)
  • Auto-ref/deref makes dot-call work through references and Box
  • Private fields + pub constructor = enforceable invariants
Q9

Why are Rust enums called sum types, and how would Option<T> be defined if it were not in the standard library?

BasicEnums

Answer

A Rust enum is a tagged union: a value is exactly one of the declared variants, and each variant can carry its own data, from nothing (unit variant) to tuple or struct payloads. This is the algebraic 'sum type' from ML and Haskell, and it is the single most load-bearing modelling tool in the language: instead of a struct with nullable-ish fields and a comment explaining which combinations are valid, you enumerate the valid states so invalid ones are unrepresentable. Option<T> itself is nothing special: enum Option<T> { Some(T), None } defined in core, given prominence purely by convention and the prelude.

The memory story matters in interviews: an enum is sized to its largest variant plus a discriminant tag, but the compiler performs niche optimisation, so Option<&T>, Option<Box<T>>, and Option<NonZeroU32> are the same size as the underlying type because the compiler reuses invalid bit patterns (null pointer, zero) as the None encoding. That is why returning Option<Box<Node>> costs nothing over a nullable pointer in C, with the difference that the compiler forces you to check it. Behavioural details worth knowing: you can attach impl blocks to enums; #[non_exhaustive] on a public enum forces downstream crates to include a wildcard arm so you can add variants without a semver-major break; and explicit discriminants (enum Status { Active = 1, Blocked = 5 }) support integer conversion for FFI and serialisation. The interview follow-up is usually to model a domain (payment states, connection lifecycle) as an enum and match over it, so practice translating 'status: string + four booleans' designs into a single honest enum.

// Option<T> as you would write it yourself:
enum Maybe<T> {
    Just(T),
    Nothing,
}

impl<T> Maybe<T> {
    fn unwrap_or(self, fallback: T) -> T {
        match self {
            Maybe::Just(v) => v,
            Maybe::Nothing => fallback,
        }
    }
}

// Making invalid states unrepresentable:
enum InterviewStage {
    Applied,
    PhoneScreen { scheduled_at: u64 },
    Onsite { rounds_done: u8, total_rounds: u8 },
    Offer { lpa: u32 },
    Rejected { reason: String },
}

fn main() {
    let m: Maybe<u32> = Maybe::Just(7);
    println!("{}", m.unwrap_or(0));
    // Niche optimisation: same size as a bare pointer
    assert_eq!(
        std::mem::size_of::<Option<Box<u8>>>(),
        std::mem::size_of::<Box<u8>>()
    );
    let _ = InterviewStage::Applied;
}

Key Points

  • Enums are tagged unions; each variant carries its own payload
  • Model domains so invalid states cannot be constructed
  • Niche optimisation: Option<Box<T>> is pointer-sized
  • #[non_exhaustive] keeps public enums extensible without semver breaks
Q10

How do you define and implement a trait in Rust, and what do #[derive(Debug, Clone, PartialEq)] and default methods actually generate?

BasicTraits

Answer

A trait declares a set of required methods (and optionally associated types, constants, and provided default methods); impl TraitName for Type supplies the implementations. Traits are Rust's sole polymorphism mechanism, filling the roles interfaces, operator overloading (Add, Index), destructors (Drop), and conversions (From/Into) play elsewhere. Default methods have bodies in the trait itself and can call the required methods, so implementors get them free and may override them, a lighter-weight reuse tool than inheritance.

The derive macros are procedural macros that generate impls at compile time: #[derive(Debug)] writes an impl of std::fmt::Debug producing the {:?} representation, Clone generates a field-by-field .clone(), and PartialEq generates field-wise ==. Derives require every field to implement the same trait, and the error when one does not ('the trait Debug is not implemented for ...') points at the offending field. Std's most-derived traits: Debug, Clone, Copy, PartialEq/Eq, PartialOrd/Ord, Hash, Default.

The PartialEq vs Eq distinction is a classic probe: Eq is a marker asserting equality is a full equivalence relation; f64 is PartialEq but not Eq because NaN != NaN, which is also why f64 cannot be a HashMap key without a wrapper. The orphan rule governs where impls may live: you can implement your trait for any type, or any trait for your type, but not a foreign trait for a foreign type; the standard workaround is the newtype pattern, wrapping the foreign type in a local tuple struct. Coherence plus the orphan rule guarantee there is at most one impl anywhere, so trait resolution is unambiguous.

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
struct Money {
    paise: u64,
}

trait Notifiable {
    fn destination(&self) -> String;      // required
    fn preview(&self) -> String {          // default method
        format!("notify -> {}", self.destination())
    }
}

struct WhatsApp { phone: String }

impl Notifiable for WhatsApp {
    fn destination(&self) -> String {
        format!("wa:{}", self.phone)
    }
    // preview() inherited from the default
}

// Operator overloading is just a trait impl:
impl std::ops::Add for Money {
    type Output = Money;
    fn add(self, rhs: Money) -> Money {
        Money { paise: self.paise + rhs.paise }
    }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "₹{}.{:02}", self.paise / 100, self.paise % 100)
    }
}

Key Points

  • Traits cover interfaces, operators, conversions, and destructors
  • Derives are proc macros generating field-wise impls
  • Eq vs PartialEq: NaN makes floats PartialEq-only, hence not hashable
  • Orphan rule: no foreign trait for foreign type; newtype is the workaround
Q11

What does a lifetime annotation like <'a> actually mean, what are the elision rules, and when must you write lifetimes explicitly?

BasicLifetimes

Answer

A lifetime is the region of code during which a reference is valid; annotations like 'a do not change how long anything lives, they describe relationships between input and output references so the compiler can check callers without seeing the function body. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str states: the return borrows from the arguments, so it stays valid only while both do. Omit the relationship and you get E0106 (missing lifetime specifier), because the signature alone must carry the contract. Most functions never need explicit lifetimes thanks to the three elision rules: each elided input reference gets its own fresh lifetime; if there is exactly one input lifetime it is assigned to all elided outputs; and in methods, the &self lifetime is assigned to elided outputs.

So fn first_word(s: &str) -> &str elides cleanly (rule two), but a function taking two references and returning one does not, which is exactly the longest example. You must also write lifetimes on structs holding references (struct Excerpt<'a> { part: &'a str }), which reads as 'an Excerpt cannot outlive the string it points into'. The 'static lifetime means valid for the whole program: string literals are &'static str because they live in the binary. Two traps to name: 'static as a trait bound (T: 'static) means the type contains no non-static references, not that the value lives forever, a distinction central to tokio::spawn errors; and cloning to 'fix' a lifetime error is sometimes correct but often a sign the ownership design is inverted, and interviewers ask which it is in context.

// E0106 without <'a>: compiler cannot relate output to inputs
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

// Elision rule 2: one input lifetime flows to the output
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

// Structs holding references need the annotation:
struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let excerpt;
    {
        let first = novel.split('.').next().unwrap();
        excerpt = Excerpt { part: first };
    } // `first` (the &str) dies, but it borrowed from `novel`, which lives on
    println!("{}", excerpt.part); // fine: Excerpt tied to novel's lifetime
    println!("{}", longest(first_word(&novel), "fallback"));
}

Key Points

  • Lifetimes describe reference relationships; they never extend liveness
  • Three elision rules cover most signatures; E0106 when they cannot
  • Structs with references require lifetime parameters
  • T: 'static bounds mean 'no borrowed data inside', not 'immortal'
💡 Pro Tip: If you can explain why fn f(x: &str, y: &str) -> &str fails elision while a method returning &self.field passes, you have covered ninety percent of practical lifetime interview questions.
Q12

Walk through the cargo commands and files you touch daily: Cargo.toml vs Cargo.lock, cargo check vs build, clippy, fmt, and semver dependency specs.

BasicTooling

Answer

Cargo.toml is the manifest you edit: package metadata, [dependencies] with semver requirements, [dev-dependencies] for test-only crates, [features], and [profile] overrides. Cargo.lock is the machine-written record of the exact resolved versions of the whole dependency graph; you commit it for applications so builds are reproducible, and modern guidance commits it for libraries too. A spec like serde = "1.0.200" is a caret requirement: any 1.x >= 1.0.200, following the semver compatibility rule; cargo update moves within that range, while changing major versions requires editing the manifest.

The daily loop: cargo check runs the full compiler front-end (borrow checking included) without codegen, so it is the fast feedback command; cargo build compiles debug artefacts into target/debug; cargo build --release enables optimisations into target/release, easily 10-50x faster at runtime than debug builds, which matters when someone benchmarks debug Rust and concludes it is slow. cargo run builds then executes; cargo test builds and runs tests. cargo clippy is the lint suite that catches API misuse and non-idiomatic patterns (needless clones, manual reimplementations of iterator methods); cargo fmt applies rustfmt, and CI conventionally enforces cargo fmt --check plus cargo clippy -- -D warnings. cargo add serde --features derive edits the manifest for you. rustup manages toolchains: stable/beta/nightly channels, rustup update, per-directory overrides via rust-toolchain.toml so the whole team compiles with one pinned version. Also worth naming: cargo doc --open renders your API docs locally, and cargo tree prints the dependency graph, the first tool to reach for when two crates drag in conflicting versions of a shared dependency.

# Daily loop
cargo check                 # fast: typecheck + borrowcheck, no codegen
cargo clippy -- -D warnings # lints as errors, matches CI
cargo fmt --check           # formatting gate
cargo test                  # run tests
cargo build --release       # optimised binary in target/release

# Dependency management
cargo add serde --features derive   # writes to Cargo.toml
cargo add tokio --features full
cargo update                # bump within semver ranges, rewrites Cargo.lock
cargo tree -d               # show duplicate crates in the graph

# Toolchain pinning (rust-toolchain.toml)
# [toolchain]
# channel = "1.85"
# components = ["clippy", "rustfmt"]

Key Points

  • Cargo.toml = intent (semver ranges); Cargo.lock = exact resolution, commit it
  • cargo check for feedback speed; --release for any performance claim
  • CI gate: fmt --check + clippy -D warnings + test
  • cargo tree -d finds duplicate dependency versions
Q13

How does Rust's module system work: mod, use, pub variants, and how paths resolve in edition 2018 and later?

BasicModules

Answer

Modules are Rust's namespacing and privacy tool, and the file layout maps onto them: src/main.rs or src/lib.rs is the crate root; mod payments; in the root tells the compiler to load src/payments.rs (or src/payments/mod.rs in the older style); a nested mod refunds; inside that loads src/payments/refunds.rs. Declaring the module is mandatory; files not reachable through a mod declaration are simply not compiled, which surprises people coming from Python or Node where files are discovered by import path. Everything is private by default: private to the parent module, not to the file. pub exposes an item to whoever can see the module; the finer-grained forms matter in real codebases: pub(crate) is visible anywhere inside the crate but not to downstream users (the workhorse for internal helpers), pub(super) to the parent module only, and pub(in path) to a specific ancestor. use brings paths into scope and is purely ergonomic; you can always write the full path.

Paths anchor at crate:: (this crate's root), self:: (current module), or super:: (parent), and external crates are referenced by name directly since edition 2018 removed the extern crate ceremony. Re-exporting with pub use is how libraries build a flat, friendly API over a deep internal tree: pub use self::client::HttpClient; at the root lets users write mylib::HttpClient regardless of internal structure, and it is also how facade crates re-export their dependencies' types. Interviewers often check whether you know that privacy is module-based rather than type-based: two types in the same module can touch each other's private fields.

Key Points

  • mod declarations, not file presence, decide what gets compiled
  • Private-by-default, scoped to the parent module
  • pub(crate) for internals, pub use for flat public APIs
  • crate::/self::/super:: anchor paths; edition 2018 dropped extern crate
Q14

Explain mutability and shadowing in Rust: why is let mut required, and how is shadowing with let different from mutation?

BasicFundamentals

Answer

Bindings are immutable unless declared let mut, and the compiler rejects writes to immutable bindings with E0384 (cannot assign twice to immutable variable). Immutability by default is a deliberate design lever: most values in real programs are set once, and making mutation opt-in means a reader who sees let mut knows to watch that variable, while the borrow checker uses the same information to allow multiple shared references to anything not mutably borrowed. Note the unused-mut lint runs the other way: declare mut and never mutate, and the compiler warns you to remove it, keeping the signal honest.

Shadowing is different in kind: a second let with the same name creates a brand-new binding that hides the previous one for the rest of the scope. Because it is a new variable, the type may change, which enables the idiomatic parse pattern: let age: u32 = age.trim().parse()? where age was a String; no _str suffixed temporaries. Shadowing also lets you rebind as immutable after a setup phase: let mut config = load(); mutate it; then let config = config; freezes it for the remainder of the function.

The previous value is not dropped at the shadow point if it is borrowed elsewhere; it simply becomes unnameable. Distinguish all this from interior mutability (Cell, RefCell, Mutex, atomics), which permits mutation through a shared reference under controlled rules and is covered by its own questions. A quick interview check: shadowing inside an inner block ends with the block, so the outer binding is visible again afterwards, and candidates who confuse that with mutation get tripped up by printed output.

fn main() {
    let attempts = 3;
    // attempts = 4;            // error[E0384]: cannot assign twice

    let mut retries = 0;
    retries += 1;               // fine: declared mut

    // Shadowing: new binding, new type allowed
    let age = "29 ";           // &str
    let age: u32 = age.trim().parse().expect("numeric age");
    println!("age = {age}");

    // Freeze after setup
    let mut headers = vec!["x-request-id"];
    headers.push("x-tenant");
    let headers = headers;      // immutable from here on

    // Block-scoped shadowing
    let level = "info";
    {
        let level = "debug";
        println!("inner: {level}"); // debug
    }
    println!("outer: {level}");     // info
    let _ = (attempts, retries, headers);
}

Key Points

  • Immutable by default; E0384 on assignment without mut
  • Shadowing = new binding, can change type; mutation = same binding
  • Idiomatic for parse/transform pipelines and freezing after setup
  • Interior mutability is a separate mechanism with its own rules
Q15

When should a Rust program panic versus return a Result, and what do panic = "abort", catch_unwind, and RUST_BACKTRACE change?

BasicError Handling

Answer

The dividing line: Result is for expected, recoverable failures (file missing, network timeout, bad user input); panic! is for bugs, states the programmer believed impossible (index out of bounds, violated invariant, poisoned internal state). Library code should essentially never panic on bad input; it should return Result and let the application decide. Applications panic at startup for unrecoverable misconfiguration (fail fast before serving traffic) and rely on Result everywhere on request paths.

Mechanically, a panic by default unwinds: it runs Drop for everything on the stack frame by frame, which is why RAII cleanup still happens, then the thread dies; if the main thread panics the process exits with code 101. RUST_BACKTRACE=1 prints the stack trace (full for maximum detail), essential in any production incident. In Cargo.toml, panic = "abort" under [profile.release] replaces unwinding with immediate process termination: smaller binaries (no landing-pad tables), marginally faster code, and the honest semantics for servers where a panic means 'this process is in an unknown state, let the orchestrator restart it'.

The trade-off is that std::panic::catch_unwind stops working, so frameworks that isolate panics per request (tokio catches panics in spawned tasks and returns a JoinError rather than killing the runtime) need unwinding. catch_unwind is also mandatory at FFI boundaries, since unwinding across extern "C" is undefined behaviour. Implicit panic sources to name in review: unwrap/expect, slice indexing, integer division by zero, and arithmetic overflow, which panics in debug builds but wraps in release unless overflow-checks = true is set, a subtle behavioural difference between environments that has bitten real payment systems.

Key Points

  • Result for expected failure; panic for broken invariants
  • Unwinding runs Drop; abort skips it for smaller, honest server binaries
  • catch_unwind needed for per-task isolation and FFI boundaries
  • Debug overflow panics vs release wrapping: set overflow-checks explicitly
💡 Pro Tip: A crisp production stance impresses: panic=abort on services with an orchestrator, unwinding where a runtime isolates tasks, and overflow-checks=true in release for anything handling money.
Q16

Explain Rust iterators: iter() vs into_iter() vs iter_mut(), lazy adapters vs consumers, and what collect::<Result<Vec<_>, _>>() does.

BasicIterators

Answer

The three entry points differ by ownership: iter() yields &T leaving the collection intact, iter_mut() yields &mut T for in-place updates, and into_iter() consumes the collection yielding owned T. A for loop calls into_iter() implicitly, which is why iterating a Vec by value moves it; loop over &v when you need it afterwards. Adapters (map, filter, take, skip, enumerate, zip, chain, flat_map) are lazy: they build a nested iterator type and do nothing until a consumer (collect, sum, count, fold, for_each, any/all) drives it.

The compiler flags a forgotten consumer with the must_use warning 'iterators are lazy and do nothing unless consumed'. Because adapters compile into a single fused loop with no intermediate allocations, idiomatic chains usually match or beat hand-written loops after optimisation; the intermediate collect::<Vec<_>>() between steps is the actual anti-pattern to hunt in review, since each one allocates a full buffer for no semantic gain. collect() is powered by FromIterator and is target-type driven, hence the turbofish when inference needs help: the same chain can produce a Vec<T>, HashMap<K, V> (from tuples), or String (from chars). The interview favourite: collect::<Result<Vec<T>, E>>() over an iterator of Result<T, E> short-circuits, returning the first Err or Ok of the full Vec, replacing manual accumulation loops when parsing a batch.

Also know position/find versus filter+next, sum::<T>() needing its annotation, and that iterator chains on slices often eliminate bounds checks the index-based equivalent would pay. Implementing Iterator by hand needs one method, fn next(&mut self) -> Option<Self::Item>, and every adapter comes free.

fn main() {
    let raw = ["12", "7", "9"];

    // Short-circuiting batch parse: first bad entry aborts the collect
    let parsed: Result<Vec<u32>, _> = raw.iter().map(|s| s.parse::<u32>()).collect();
    println!("{parsed:?}"); // Ok([12, 7, 9])

    let mut scores = vec![40, 65, 90];
    for s in scores.iter_mut() {
        *s += 5;                    // in-place via &mut i32
    }

    let total: u32 = scores.iter().sum();
    let top: Vec<_> = scores
        .iter()
        .enumerate()
        .filter(|(_, s)| **s >= 70)
        .map(|(i, s)| format!("#{i}:{s}"))
        .collect();                 // one pass, one allocation (the Vec)
    println!("total={total} top={top:?}");

    let owned: Vec<i32> = scores.into_iter().collect(); // scores moved
    let _ = owned;
}

Key Points

  • iter()=&T, iter_mut()=&mut T, into_iter()=owned T (for loops use it)
  • Adapters are lazy; a consumer drives the single fused loop
  • collect is FromIterator-driven; Result<Vec<_>, E> short-circuits
  • Intermediate collects are the real allocation anti-pattern
Q17

How do closures work in Rust, and what decides whether a closure is Fn, FnMut, or FnOnce, and when the move keyword is required?

BasicClosures

Answer

A closure is an anonymous struct, generated by the compiler, whose fields are the captured variables, plus an implementation of one or more of the call traits. Capture mode is inferred per variable from how the body uses it, choosing the least restrictive option: read-only use captures by shared reference, mutation captures by mutable reference, and consuming (moving out, calling a self method) captures by value. Which traits the closure implements follows from what its body does to the captures: FnOnce (consumes captures, callable once) is implemented by every closure; FnMut (mutates captures, callable repeatedly) when nothing is moved out; Fn (only reads, callable through &self) when there is no mutation either.

The hierarchy nests: Fn: FnMut: FnOnce, so an Fn closure is accepted wherever FnMut or FnOnce is expected. Function signatures accept closures generically, fn retry(f: impl FnMut() -> bool), and choosing the loosest bound that works (prefer FnOnce for callbacks invoked once) is an API design point interviewers notice. The move keyword forces capture by value regardless of usage; it does not change which traits are implemented, only how captures are stored.

It becomes mandatory when the closure outlives the enclosing scope: thread::spawn and tokio::spawn both require F: 'static, so a closure borrowing a local produces 'closure may outlive the current function, but it borrows...' with the compiler suggesting move. With move, non-Copy values are transferred (the original binding dies) while Copy values are duplicated. Returning closures uses impl Fn(...) -> _ since each closure has an unnameable unique type, and storing heterogeneous callbacks needs Box<dyn Fn(...)>.

use std::thread;

fn apply_twice(mut f: impl FnMut() -> u32) -> u32 {
    f() + f()
}

fn make_adder(n: u32) -> impl Fn(u32) -> u32 {
    move |x| x + n              // move: n must live inside the closure
}

fn main() {
    let mut count = 0;
    let mut tick = || {          // FnMut: mutates a capture
        count += 1;
        count
    };
    println!("{}", apply_twice(&mut tick));

    let add5 = make_adder(5);    // Fn: only reads its capture
    println!("{}", add5(10));

    let greeting = String::from("hello from the spawned thread");
    let handle = thread::spawn(move || {
        // without `move`: error, closure may outlive the current function
        println!("{greeting}");
    });                          // greeting now owned by the thread
    handle.join().unwrap();
}

Key Points

  • Closure = compiler-generated struct of captures + call-trait impls
  • Fn reads, FnMut mutates, FnOnce consumes; Fn: FnMut: FnOnce
  • move forces by-value capture; required for 'static spawn bounds
  • Accept impl FnOnce/FnMut/Fn with the loosest bound that works
Q18

What is Box<T> for, and why does a recursive type like a linked list fail with E0072 without it?

BasicSmart Pointers

Answer

Box<T> is the simplest smart pointer: it allocates T on the heap and owns it, freeing the allocation when the Box drops. The Box itself is a single pointer on the stack, so moving a Box moves eight bytes no matter how large T is. Three canonical uses.

First, recursive types: enum List { Cons(i32, List), Nil } fails with E0072 ('recursive type has infinite size') because Rust must know every type's size at compile time and this one contains itself; Cons(i32, Box<List>) fixes it because a Box is pointer-sized, breaking the infinite layout. The compiler literally suggests inserting Box in its help text, and the same reasoning applies to AST nodes and tree structures. Second, trait objects: Box<dyn Error> or Box<dyn Fn()> store values whose concrete type is unknown at compile time; the unsized dyn value must live behind a pointer.

Third, moving genuinely large values cheaply, or keeping a huge struct off the stack entirely to avoid stack overflows in deep call chains. Box implements Deref and DerefMut, so method calls pass through transparently, and Drop, so cleanup is automatic; you almost never notice you are using one after construction. What Box is not: it is not shared ownership (that is Rc/Arc), not interior mutability (RefCell/Mutex), and not nullable (that is Option<Box<T>>, which niche optimisation keeps pointer-sized). A follow-up worth anticipating: Box<T> where T: Sized has no metadata, but Box<dyn Trait> and Box<[T]> are fat pointers carrying a vtable pointer or length respectively, so their size is two words, not one.

// error[E0072]: recursive type `List` has infinite size (without Box)
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn sum(list: &List) -> i32 {
    match list {
        Cons(v, rest) => v + sum(rest),
        Nil => 0,
    }
}

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    println!("{}", sum(&list));

    // Thin vs fat pointers:
    assert_eq!(std::mem::size_of::<Box<u64>>(), 8);          // thin
    assert_eq!(std::mem::size_of::<Box<dyn std::fmt::Debug>>(), 16); // fat

    // Trait object in a Box: concrete type erased
    let e: Box<dyn std::error::Error> = "boom".into();
    println!("{e}");
}

Key Points

  • Box = owned heap allocation, pointer-sized handle, auto-freed on drop
  • Fixes E0072 by giving recursive types a finite layout
  • Required home for unsized values: dyn Trait, [T], str
  • Box<dyn Trait> is a fat pointer (data + vtable)
Q19

Compare generic bounds (impl Trait / <T: Trait>) with dyn Trait: static vs dynamic dispatch, monomorphization, and when each belongs in an API.

BasicTraits

Answer

Generics are resolved at compile time by monomorphization: for each concrete type used, the compiler stamps out a specialised copy of the function, then inlines and optimises it like hand-written code. That is static dispatch: zero call overhead, full optimisation, at the cost of compile time and binary size growing with the number of instantiations. fn notify(x: impl Summary) and fn notify<T: Summary>(x: T) are the same thing (argument-position impl Trait is sugar), with the difference that the explicit form allows turbofish and using T multiple times to force one single type. dyn Trait is dynamic dispatch: a fat pointer pairing the data pointer with a vtable, and every method call is an indirect jump resolved at runtime. You need dyn when the concrete type genuinely varies at runtime: a Vec<Box<dyn Handler>> of heterogeneous plugins, or storing callbacks.

Note Vec<impl Trait> is not a thing; a generic Vec<T> is homogeneous, so heterogeneity is precisely the dyn use case. Return position differs too: impl Trait in return position means 'one specific type I will not name' (every return statement must produce the same type), while Box<dyn Trait> permits different types per branch. The runtime cost of dyn is an indirect call plus lost inlining; measurable in the hottest loops, irrelevant on an I/O path that then awaits a network round trip, and interviewers value that proportionality. Also mention the compile-time trade: heavy generic APIs in widely-used crates inflate compile times for every consumer, which is why some libraries offer &dyn interfaces internally, a pattern sometimes called polymorphization by hand.

trait Notify {
    fn send(&self, msg: &str) -> String;
}

struct Email;
struct Sms;

impl Notify for Email {
    fn send(&self, m: &str) -> String { format!("email: {m}") }
}
impl Notify for Sms {
    fn send(&self, m: &str) -> String { format!("sms: {m}") }
}

// Static dispatch: one specialised copy per concrete T
fn blast(n: &impl Notify, msg: &str) -> String {
    n.send(msg)
}

// Dynamic dispatch: heterogeneous collection needs dyn
fn broadcast(channels: &[Box<dyn Notify>], msg: &str) {
    for c in channels {
        println!("{}", c.send(msg)); // vtable call
    }
}

fn main() {
    println!("{}", blast(&Email, "offer released"));
    let all: Vec<Box<dyn Notify>> = vec![Box::new(Email), Box::new(Sms)];
    broadcast(&all, "interview at 4pm");
}

Key Points

  • Generics monomorphize: fast calls, bigger binaries, longer compiles
  • dyn Trait = fat pointer + vtable; needed for runtime heterogeneity
  • Return impl Trait = one hidden type; Box<dyn> = varies per branch
  • Judge dispatch cost against the surrounding work before optimising
Q20

Where do values live in Rust: stack vs heap, what does the Sized trait mean, and why do function arguments need known sizes?

BasicMemory Model

Answer

Rust has no runtime or garbage collector deciding placement; the rules are structural. Local variables, function arguments, and return values live on the stack, and a value goes to the heap only when something explicitly puts it there: Box::new, Vec/String/HashMap internals, Rc/Arc. A String on the stack is the 24-byte (ptr, len, cap) header; its character data is on the heap.

Composition follows naturally: a struct containing a Vec has the Vec header inline and the elements on the heap; an array [u8; 1024] inside a struct is fully inline. The stack requires every value's size at compile time, which is what the Sized marker trait encodes. Almost every type is Sized; the exceptions (dynamically sized types) are str, [T], and dyn Trait, which can only exist behind pointers (&str, Box<[T]>, &dyn Trait) that carry the missing metadata (length or vtable) in a fat pointer.

Generic parameters get an implicit T: Sized bound, loosened by writing T: ?Sized when your function only handles the type through a reference, which is exactly how impl Display for str is possible. Practical consequences worth voicing: recursion depth and large local arrays can overflow the default 8 MB main-thread stack (child thread stacks are configurable via thread::Builder::stack_size); heap allocation cost in Rust is the allocator call itself, not GC pressure, so amortising with with_capacity and reusing buffers are the levers; and 'moving to the heap' via Box makes moves of the value pointer-cheap while adding one pointer indirection on every access, a trade you should be able to reason about aloud.

Key Points

  • Stack by default; heap only via Box, collections, Rc/Arc
  • String/Vec = small stack header + heap payload
  • Sized is implicit; ?Sized re-admits str, [T], dyn Trait behind pointers
  • Fat pointers carry len or vtable for DSTs
Q21

A rustc error fills the screen. Walk through how you actually read Rust compiler diagnostics, and what rustc --explain and cargo fix give you.

BasicDeveloper Workflow

Answer

Rust's diagnostics are the best in mainstream compilers, and interviewers watching you debug care that you use them rather than pattern-match on panic. Anatomy of an error: the header line gives the error code and summary (error[E0382]: borrow of moved value: `name`); the span shows the offending code with carets; secondary spans annotate the history ('value moved here', 'move occurs because `name` has type String, which does not implement the Copy trait'); and a help section often contains a concrete, frequently correct suggestion ('consider borrowing here: `&name`'). Read the first error only: later errors are commonly cascade artefacts of the first, so fix one, recompile, repeat; cargo check makes that loop fast. rustc --explain E0382 prints a full prose explanation of the error class with minimal examples, useful for the borrow-checker codes (E0382 move, E0499 two mutable borrows, E0502 mixed borrows, E0597 borrowed value does not live long enough, E0106 missing lifetime). cargo fix applies the compiler's machine-applicable suggestions automatically, and cargo fix --edition performs edition migrations. cargo clippy layers on hundreds of lints beyond rustc's, each documented with rationale on the Clippy lint list, and clippy's suggestions teach idiom faster than any book.

Two habits that read as senior: when the borrow checker rejects something, articulate what memory-safety violation the rejected code could cause instead of mechanically appeasing the compiler; and when a trait-bound error spans thirty lines of generics, find the 'the trait X is not implemented for Y' line first, since everything above it is context. Warnings are promoted to errors in CI with -D warnings, so treat them as errors locally too.

Key Points

  • Fix the first error; the rest are usually cascade noise
  • Spans + help lines usually contain the exact correct fix
  • rustc --explain E#### for the concept; cargo fix for mechanical repairs
  • Explain what the borrow checker prevented, not just how you silenced it
Q22

What are Rust editions, how do 2015/2018/2021/2024 differ, and why can crates on different editions link together?

BasicLanguage Evolution

Answer

Editions are Rust's mechanism for making opt-in breaking changes to surface syntax without ever splitting the ecosystem. The edition is declared per crate in Cargo.toml (edition = "2024"), and, the crucial part, crates on different editions compile to the same internal representation and link freely, so a 2015-edition dependency from 2016 still works in your 2024-edition binary. This is the deliberate anti-Python-3 design.

What each edition changed: 2018 overhauled the module system (no more extern crate, uniform crate:: paths) and enabled non-lexical lifetimes' rollout era; 2021 tightened closure captures to individual fields rather than whole structs (disjoint capture), made arrays implement IntoIterator by value, and reserved syntax for future features; 2024, stabilised in Rust 1.85 (February 2025), is the largest yet: it changes impl Trait lifetime-capture defaults in return position, adjusts temporary lifetimes in if let and match scrutinees to drop earlier (removing a class of surprising deadlocks with mutex guards), makes unsafe attributes and unsafe extern blocks explicit, and enables the new expression-oriented semantics needed by let-chains, which stabilised shortly after in Rust 1.88. Migration is tool-assisted: cargo fix --edition rewrites your code to be compatible, you flip the edition field, and cargo fix --edition again cleans up idiom lints. Distinguish editions from release cadence: Rust ships a new stable compiler every six weeks, and features stabilise continuously regardless of edition; the edition only gates changes that would alter the meaning of existing code. Saying 'we stayed on edition 2021 because a proc-macro dependency lagged' shows real-world experience, since that is the common friction point.

Key Points

  • Per-crate opt-in; mixed-edition dependency graphs link fine
  • 2021: disjoint closure captures, arrays IntoIterator
  • 2024 (Rust 1.85): RPIT capture defaults, tighter temporary scopes, unsafe extern
  • cargo fix --edition automates migration; six-week releases are separate
Q23

Differentiate const, static, and static mut in Rust, and explain what the 'static lifetime means on data like string literals.

BasicFundamentals

Answer

A const is a compile-time constant that is inlined at every use site: it has no address of its own, must be annotated with its type, and its initialiser must be evaluable at compile time (const fn calls allowed). Use it for domain constants: const MAX_RETRIES: u32 = 3. A static is a single memory location living for the entire program: one address, shared by all references to it, initialised before main conceptually and required to be Sync if reachable from multiple threads.

Immutable statics are fine for large lookup tables where you want one copy rather than inlining. static mut is the historical footgun: every access is unsafe because the compiler cannot prevent data races on it, and modern Rust (edition 2024 lints hard against taking references to static mut) treats it as effectively deprecated. The correct patterns for global mutable state are an atomic (static COUNTER: AtomicU64), a lock (static CONFIG: Mutex<...>), or lazy one-time initialisation: OnceLock<T> (stabilised 1.70) for set-once values like a parsed config, and LazyLock<T> (stabilised 1.80) for compute-on-first-use, which absorbed the role of the lazy_static and once_cell crates into std. On lifetimes: 'static means 'valid for the rest of the program'.

String literals are &'static str because the bytes are baked into the binary's read-only segment. Box::leak deliberately produces &'static mut T by never freeing. And the perennially-confused point: a T: 'static bound on a generic (as tokio::spawn requires) constrains the type to own its data or hold only 'static references; an owned String satisfies it fine, no immortality involved.

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};

const MAX_RETRIES: u32 = 3;                 // inlined at each use
static REQUESTS: AtomicU64 = AtomicU64::new(0); // one location, thread-safe

static CONFIG: OnceLock<String> = OnceLock::new(); // set exactly once (1.70)

static KEYWORDS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
    // runs on first access (1.80)
    vec!["rust", "tokio", "serde"]
});

static AUDIT: Mutex<Vec<String>> = Mutex::new(Vec::new());

fn main() {
    REQUESTS.fetch_add(1, Ordering::Relaxed);
    CONFIG.set("prod".to_string()).unwrap();
    println!("env={} kw={:?}", CONFIG.get().unwrap(), &*KEYWORDS);
    AUDIT.lock().unwrap().push(format!("retries<={MAX_RETRIES}"));

    let banner: &'static str = "goodspace"; // lives in the binary
    println!("{banner}");
}

Key Points

  • const inlines everywhere; static is one addressed location
  • static mut is unsafe and effectively deprecated; use atomics/locks
  • OnceLock (1.70) and LazyLock (1.80) replaced lazy_static/once_cell
  • T: 'static means no borrowed data, not 'lives forever'
Q24

How do From, Into, TryFrom, and AsRef differ, and why does implementing From give you Into for free?

BasicConversions

Answer

These traits are Rust's conversion vocabulary, and knowing which to implement versus which to bound on is a small question that reliably separates candidates. From<T> is an infallible, owning conversion: impl From<u32> for Money defines Money::from(paisa). The standard library provides a blanket impl: impl<T, U> Into<U> for T where U: From<T>, so implementing From automatically grants the reverse Into, which is why the guidance is always implement From, never Into directly.

In APIs, bound on Into (fn new(name: impl Into<String>)) so callers can pass either &str or String; in bodies, call .into() and let inference pick the target, or Type::from(x) when explicitness reads better. TryFrom/TryInto are the fallible siblings returning Result<Self, Self::Error>: converting i64 to u32 can fail, so std implements TryFrom with TryFromIntError, and your own parse-and-validate constructors (TryFrom<&str> for Email) belong here rather than in a panicking From. The ? operator composes with these through From on the error type, the machinery behind thiserror's #[from].

AsRef<T> is different in kind: a cheap reference-to-reference conversion for generic borrowing, fn open(path: impl AsRef<Path>) being the canonical std example letting you pass &str, String, or PathBuf. Its sibling Borrow<T> adds the guarantee that Eq/Hash/Ord agree between owned and borrowed forms, which is what lets HashMap<String, V> look up by &str. Round out with Display-implies-ToString (never implement ToString directly) and FromStr powering str::parse::<T>(), and you have the whole conversion map interviewers sketch.

use std::convert::TryFrom;

struct Money { paise: u64 }

impl From<u64> for Money {
    fn from(paise: u64) -> Self { Money { paise } }
}
// Into<Money> for u64 arrives free via the blanket impl.

struct Percent(u8);

impl TryFrom<u8> for Percent {
    type Error = String;
    fn try_from(v: u8) -> Result<Self, Self::Error> {
        if v <= 100 { Ok(Percent(v)) } else { Err(format!("{v} > 100")) }
    }
}

fn label(name: impl Into<String>) -> String {   // ergonomic ownership intake
    let s: String = name.into();
    format!("[{s}]")
}

fn main() -> Result<(), String> {
    let m: Money = 4999u64.into();
    let p = Percent::try_from(87)?;
    println!("{} {} {}", label("rust"), m.paise, p.0);
    Ok(())
}

Key Points

  • Implement From; Into arrives via the blanket impl
  • TryFrom for fallible, validating conversions
  • Bound params on Into/AsRef for caller ergonomics
  • Borrow's Eq/Hash guarantee powers HashMap<String, _> lookup by &str
Q25

A struct holds a &str field and the compiler demands lifetimes everywhere it flows. Explain E0597, when a lifetime parameter on a struct is the right design, and when owning the data is.

IntermediateLifetimes

Answer

struct Token<'a> { raw: &'a str } declares that any Token instance is tied to a region 'a during which the borrowed string outlives it. Every impl block repeats the parameter (impl<'a> Token<'a>), and any struct containing a Token must either carry 'a itself or own the data instead. E0597 ('borrowed value does not live long enough') fires when you try to construct such a struct from data that dies first, the classic case being returning a Token borrowed from a local String: the String drops at the end of the function, so the compiler rejects the escape.

The design question interviewers actually care about: borrowed structs are for transient, zero-copy views, a parser producing tokens over an input buffer the caller holds, a config view over a memory-mapped file, serde's zero-copy &'a str deserialization. They maximise performance by never allocating, at the price of infecting every containing type with the lifetime. Owned structs (String instead of &str) are for data that outlives its source or crosses ownership boundaries: anything stored in a session map, sent over a channel, spawned into a task, or returned upward.

The 'static-bound rule of thumb: if the value must satisfy T: 'static (tokio::spawn, thread::spawn, most caches), borrowed fields are out. Cow<'a, str> is the hybrid, borrowing until modification forces ownership. A strong answer also names the anti-pattern of the self-referential struct, a struct trying to hold both a String and a &str into it, which safe Rust forbids because moving the struct would invalidate the interior pointer; the fixes are indices instead of references, restructuring ownership, or crates like ouroboros when truly unavoidable.

struct Token<'a> {
    raw: &'a str,
}

impl<'a> Token<'a> {
    fn kind(&self) -> &'static str {
        if self.raw.chars().all(|c| c.is_ascii_digit()) { "number" } else { "word" }
    }
}

// Zero-copy tokenizer: output borrows from input
fn tokenize(input: &str) -> Vec<Token<'_>> {
    input.split_whitespace().map(|raw| Token { raw }).collect()
}

// fn broken() -> Token<'static> {
//     let local = String::from("dies too soon");
//     Token { raw: &local }   // error[E0597]: `local` does not live long enough
// }

struct OwnedToken {
    raw: String,               // owns: free to store, send, spawn
}

fn main() {
    let line = String::from("pay 4999 INR");
    let tokens = tokenize(&line);
    println!("{} -> {}", tokens[1].raw, tokens[1].kind());
    let keep = OwnedToken { raw: tokens[1].raw.to_owned() };
    drop(line);                // borrowed tokens die with line; keep survives
    println!("kept {}", keep.raw);
}

Key Points

  • Struct lifetimes declare 'this view cannot outlive its source'
  • E0597 = constructing a borrow from data that drops first
  • Borrow for transient zero-copy views; own for stored/sent data
  • T: 'static requirements (spawn, caches) rule out borrowed fields
Q26

What makes a trait dyn-compatible (object safe), why does a generic method or a Self return type break it, and how do you work around E0038?

IntermediateTraits

Answer

To build a dyn Trait vtable, every callable method must have one compilable machine-code entry working through a type-erased &self. The rules (the Reference now calls this dyn compatibility, the older term is object safety): no generic type parameters on methods (monomorphization would need one vtable slot per instantiation, unbounded), no Self by value in arguments or return types (the size is erased, so the compiler cannot reserve space), no associated constants, and the trait cannot require Self: Sized. Violating them when you write Box<dyn MyTrait> produces E0038 ('the trait cannot be made into an object') with the offending method named.

Clone is the everyday example: fn clone(&self) -> Self returns Self by value, so Box<dyn Clone> is illegal. Workarounds, in order of frequency: add where Self: Sized to the offending method, which removes it from the vtable so the rest of the trait remains dyn-compatible (you lose that method on trait objects but keep it for concrete types); replace generic methods with dyn parameters (fn process(&self, input: &dyn Read) instead of <R: Read>); and for cloneable trait objects, the clone_box pattern: a helper trait with fn clone_box(&self) -> Box<dyn MyTrait>, plus a blanket impl for all T: MyTrait + Clone, then impl Clone for Box<dyn MyTrait> delegating to it (the dyn-clone crate packages exactly this). Associated types do not break dyn compatibility but must be pinned at the use site: dyn Iterator<Item = u32>.

Interviewers often push one level deeper: why can Vec<dyn Trait> not exist even for a dyn-compatible trait? Because dyn Trait is unsized and Vec stores elements inline, hence Vec<Box<dyn Trait>>.

trait Exporter {
    fn export(&self, row: &str) -> String;

    // Generic method would break dyn-compatibility (E0038)...
    // fn export_all<I: Iterator<Item = String>>(&self, rows: I);

    // ...unless fenced off the vtable:
    fn export_iter(&self, rows: &mut dyn Iterator<Item = String>) -> usize
    where
        Self: Sized,
    {
        rows.map(|r| self.export(&r)).count()
    }
}

// clone_box pattern for cloneable trait objects
trait CloneExporter: Exporter {
    fn clone_box(&self) -> Box<dyn CloneExporter>;
}
impl<T: Exporter + Clone + 'static> CloneExporter for T {
    fn clone_box(&self) -> Box<dyn CloneExporter> { Box::new(self.clone()) }
}
impl Clone for Box<dyn CloneExporter> {
    fn clone(&self) -> Self { self.clone_box() }
}

#[derive(Clone)]
struct Csv;
impl Exporter for Csv {
    fn export(&self, row: &str) -> String { format!("{row},") }
}

fn main() {
    let e: Box<dyn CloneExporter> = Box::new(Csv);
    let e2 = e.clone();
    println!("{}", e2.export("id=1"));
}

Key Points

  • Vtables need one entry per method: no generics, no by-value Self
  • E0038 names the offending method; where Self: Sized fences it off
  • clone_box / dyn-clone solves Clone for trait objects
  • Associated types must be specified: dyn Iterator<Item = T>
Q27

How do Rc<T> and RefCell<T> provide shared ownership and interior mutability, and what makes the combination panic with BorrowMutError at runtime?

IntermediateSmart Pointers

Answer

Rc<T> is single-threaded reference counting: clone() increments a count and hands back another owner of the same heap allocation, drop decrements, and the value frees when the count hits zero. It gives you shared ownership where the last user cannot be determined statically, graphs, caches, shared config in a single-threaded context, but the shared value is immutable, because Rc hands out only &T. RefCell<T> supplies interior mutability: mutation through a shared reference, with the borrow rules checked at runtime instead of compile time. borrow() returns Ref<T> (a shared guard), borrow_mut() returns RefMut<T> (exclusive), and the cell tracks outstanding guards; requesting a mutable borrow while any guard is live panics with 'already borrowed: BorrowMutError' (try_borrow_mut returns Result for the non-panicking path).

The classic production bug is calling a method that internally does borrow_mut() while an outer borrow() guard is still in scope, often invisible across function boundaries; the fix is scoping guards tightly, dropping them explicitly, or restructuring so one function owns the mutation. Rc<RefCell<T>> combines the two into the standard single-threaded shared-mutable cell. Complementary pieces: Cell<T> for Copy types avoids guards entirely by copying values in and out (get/set/replace); Weak<T> from Rc::downgrade breaks reference cycles, since two Rcs pointing at each other never hit zero and leak, the textbook parent-child tree design being strong child pointers and weak parent backpointers, with upgrade() returning Option<Rc<T>>. Neither Rc nor RefCell is Send or Sync, and the compiler enforces that: move one into thread::spawn or tokio::spawn and the error tells you to use Arc and Mutex, which is precisely the multi-threaded translation.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    name: String,
    parent: RefCell<Weak<Node>>,      // weak: breaks the cycle
    children: RefCell<Vec<Rc<Node>>>, // strong: parents own children
}

fn main() {
    let root = Rc::new(Node {
        name: "root".into(),
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });
    let leaf = Rc::new(Node {
        name: "leaf".into(),
        parent: RefCell::new(Rc::downgrade(&root)),
        children: RefCell::new(vec![]),
    });
    root.children.borrow_mut().push(Rc::clone(&leaf));

    println!("strong={} weak={}", Rc::strong_count(&root), Rc::weak_count(&root));
    if let Some(p) = leaf.parent.borrow().upgrade() {
        println!("{} -> parent {}", leaf.name, p.name);
    }

    let cell = RefCell::new(5);
    let r = cell.borrow();
    // cell.borrow_mut();  // would panic: already borrowed (BorrowMutError)
    drop(r);               // release the guard first
    *cell.borrow_mut() += 1;
}

Key Points

  • Rc = shared ownership via refcount; RefCell = runtime borrow checking
  • borrow_mut with a live guard panics: scope guards tightly
  • Weak breaks Rc cycles; upgrade() returns Option<Rc<T>>
  • Neither is Send/Sync: threads need Arc + Mutex instead
Q28

Arc<Mutex<T>> vs Arc<RwLock<T>>: how does sharing state across threads work, what is lock poisoning, and when do teams reach for parking_lot?

IntermediateConcurrency

Answer

Arc<T> is Rc with atomic reference counting, making the handle Send + Sync (when T is appropriately thread-safe) at the cost of atomic increments on clone. It provides shared immutable access; mutation requires pairing it with a synchronisation primitive. Mutex<T> in Rust owns its data, a major design difference from C++ or Java where locks and data are associated by convention only: the T is inside the Mutex, and .lock() returns a MutexGuard<T> that derefs to the data and releases the lock when dropped.

There is no unlock call to forget; RAII handles it, and the compiler prevents touching the data without the lock. std's lock() returns Result because of poisoning: if a thread panics while holding the guard, the mutex is flagged, and later lock() calls get Err(PoisonError), signalling the protected data may be mid-mutation-inconsistent; you can recover deliberately via into_inner() on the error. RwLock<T> allows many concurrent readers or one writer (read()/write()), the right shape for read-heavy shared state like config or routing tables; note std's RwLock does not specify writer-priority fairness, so heavy read pressure can starve writers depending on the platform. parking_lot provides drop-in Mutex/RwLock with no poisoning (lock() returns the guard directly, no Result), smaller size (one byte vs a platform mutex), and generally better contention behaviour, which is why many production codebases standardise on it. The gotchas interviewers listen for: keep critical sections tiny (compute outside, lock briefly), a double lock() on a non-reentrant std Mutex in one thread deadlocks, lock ordering must be consistent across threads to avoid ABBA deadlocks, and holding a std MutexGuard across an .await point breaks async runtimes, which is a separate question.

use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;

fn main() {
    let hits: Arc<Mutex<HashMap<String, u64>>> = Arc::new(Mutex::new(HashMap::new()));
    let routes: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(vec!["/jobs".into()]));

    let handles: Vec<_> = (0..4)
        .map(|i| {
            let hits = Arc::clone(&hits);
            let routes = Arc::clone(&routes);
            thread::spawn(move || {
                // many readers may hold read() concurrently
                let known = routes.read().unwrap().len();
                // keep the write section tiny
                let mut map = hits.lock().unwrap();
                *map.entry(format!("worker-{i}")).or_insert(0) += known as u64;
            }) // guards drop here: locks released
        })
        .collect();

    for h in handles {
        h.join().unwrap();
    }
    routes.write().unwrap().push("/companies".into());
    println!("{:?}", hits.lock().unwrap());
}

Key Points

  • Mutex owns its data; the guard's Drop is the unlock
  • Poisoning surfaces cross-thread panic inconsistency as Err
  • RwLock for read-heavy state; beware writer starvation
  • parking_lot: no poisoning, smaller, better under contention
Q29

What do the Send and Sync marker traits guarantee, how are they derived automatically, and why is Rc<T> neither while Arc<Mutex<T>> is both?

IntermediateConcurrency

Answer

Send means ownership of a value can move to another thread; Sync means &T can be shared across threads, and the two relate precisely: T is Sync exactly when &T is Send. They are auto traits: the compiler derives them structurally, a type is Send/Sync if all its fields are, with no code to write, and unsafe impl Send is the explicit, audited override for types wrapping raw pointers that are in fact thread-safe. This is the machinery that makes 'fearless concurrency' a compile-time theorem rather than a slogan: thread::spawn requires F: Send + 'static, channels require T: Send, and any attempt to smuggle non-thread-safe data across produces a trait-bound error naming the offending type, at the exact line, before the code ever runs.

Why Rc<T> is !Send and !Sync: its reference count is a plain (non-atomic) integer, so two threads cloning simultaneously race on the count, leading to premature frees or leaks; the compiler encodes that impossibility as missing markers rather than documentation. Arc fixes the count with atomics, so Arc<T> is Send + Sync when T: Send + Sync. RefCell<T> is Send (you may move it) but !Sync, because its runtime borrow flags are not atomic, and that is exactly why sharing mutable state across threads forces Mutex: Mutex<T> is Sync even for T that is only Send, since the lock serialises all access; hence Arc<Mutex<T>> as the canonical shared-mutable-state type.

Other !Send types worth naming: MutexGuard (unlocking on a different thread than locking is UB on some platforms), and raw pointers by default. In async Rust the same bounds resurface: a multi-threaded tokio runtime's spawn requires the future to be Send, so holding a Rc or RefCell across an .await produces the infamous 'future cannot be sent between threads safely' error, with the fix being Arc, Mutex, or confining the non-Send part to a scope without awaits.

Key Points

  • Send = movable across threads; Sync = &T sharable; Sync(T) == Send(&T)
  • Auto-derived structurally; unsafe impl is the audited escape hatch
  • Rc's non-atomic count makes it !Send + !Sync; Arc uses atomics
  • Mutex grants Sync by serialising; async Send bounds surface the same rules
Q30

How do you spawn and join OS threads in Rust, and what problem did std::thread::scope (1.63) solve that plain spawn cannot?

IntermediateConcurrency

Answer

std::thread::spawn takes a closure, runs it on a new OS thread, and returns JoinHandle<T>; .join() blocks until completion and returns Result<T, Box<dyn Any + Send>>, Err meaning the thread panicked (panics do not cross thread boundaries silently, you choose how to handle them at join). The closure must be Send + 'static: 'static is the pain point, because the spawned thread might outlive the caller's stack frame, so the closure may not borrow locals; everything must be moved in or be 'static. Pre-1.63, sharing a local Vec across worker threads meant wrapping it in Arc even when you logically knew the workers finished before the function returned, pure ceremony to satisfy the lifetime bound. std::thread::scope fixes this structurally: scope(|s| { s.spawn(...) }) guarantees every spawned thread joins before scope returns, so the compiler can safely allow borrowing non-'static locals, including &mut borrows of disjoint data.

That turned a whole class of fork-join parallelism (split a slice, process chunks, join) into safe, allocation-free code; before 1.63 the crossbeam crate's scope provided the same. Details that signal depth: a detached thread (dropping the JoinHandle) keeps running but the process exits when main returns regardless, so services join or park deliberately; thread::Builder lets you set a name (which shows up in panics and debuggers) and stack_size, the fix when deep recursion overflows the default child-thread stack; and thread-count sizing should follow std::thread::available_parallelism() rather than hard-coded constants. For CPU-bound data parallelism most production code skips manual threads entirely and uses rayon: par_iter() converts an iterator chain into work-stealing parallel execution across a global pool, and knowing when rayon is the better answer than hand-rolled threads is itself an interview signal.

use std::thread;

fn main() {
    let data = vec![3, 1, 4, 1, 5, 9, 2, 6];

    // Scoped threads (1.63+): borrow locals, no Arc ceremony
    let (left_sum, right_sum) = thread::scope(|s| {
        let (left, right) = data.split_at(data.len() / 2);
        let l = s.spawn(|| left.iter().sum::<i32>());
        let r = s.spawn(|| right.iter().sum::<i32>());
        (l.join().unwrap(), r.join().unwrap())
    }); // all scoped threads are joined by here, borrows end safely
    println!("{left_sum} + {right_sum} = {}", left_sum + right_sum);

    // Named thread with a bigger stack for deep recursion
    let handle = thread::Builder::new()
        .name("indexer".into())
        .stack_size(8 * 1024 * 1024)
        .spawn(|| "done")
        .expect("spawn failed");

    match handle.join() {
        Ok(msg) => println!("indexer: {msg}"),
        Err(_) => eprintln!("indexer panicked"),
    }
}

Key Points

  • spawn needs Send + 'static; join surfaces panics as Err
  • thread::scope guarantees joins, unlocking borrows of locals
  • Builder for names and stack size; available_parallelism for sizing
  • rayon's par_iter usually beats hand-rolled fork-join
Q31

Compare std::sync::mpsc with crossbeam-channel and tokio::sync::mpsc, and explain why bounded channels are a backpressure decision, not a tuning detail.

IntermediateConcurrency

Answer

std::sync::mpsc gives multi-producer single-consumer channels: channel() is unbounded (asynchronous send never blocks), sync_channel(n) is bounded (send blocks when full, and n=0 makes it a rendezvous where sender and receiver must meet). Senders clone; when every Sender drops, recv() returns Err(RecvError), the idiomatic shutdown signal, and a for msg in rx loop terminates cleanly. crossbeam-channel is the de facto production upgrade for sync code: MPMC (receivers clone too, enabling worker pools that share one queue), significantly faster, and it adds the select! macro for waiting on multiple channels plus after/tick timer channels; std's mpsc has no select. In async code you use tokio::sync::mpsc, whose bounded send is an async fn that awaits instead of blocking the thread, plus tokio's oneshot (single value, request-response pattern), broadcast (fan-out where every receiver sees every message, with lagging receivers dropped), and watch (latest-value-only, for config or shutdown flags); choosing the right one of these four is itself a common interview question.

The backpressure point is where senior candidates separate: an unbounded channel means a producer outpacing its consumer grows the queue without limit, which in production is a slow memory leak that ends in OOM-kill, typically during a traffic spike or a consumer stall, exactly when you can least afford it. A bounded channel converts that overload into blocking or awaiting at the producer, propagating slowdown upstream where it can be handled: shed load, return 429s, or spill to disk. The channel capacity is therefore a statement about how much burst you absorb before pushing back.

Interviewers phrase it as a war story: 'your service's memory climbs for hours then dies, the heap dump is full of queued messages, what happened?' Unbounded channel, stalled consumer, is the expected diagnosis.

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // Bounded: capacity 2 means the producer feels backpressure
    let (tx, rx) = mpsc::sync_channel::<String>(2);

    let producer = thread::spawn(move || {
        for i in 0..5 {
            // Blocks when the queue holds 2 undelivered messages
            tx.send(format!("job-{i}")).expect("receiver gone");
            println!("queued job-{i}");
        }
        // tx drops here -> receiver's loop ends
    });

    thread::sleep(Duration::from_millis(50)); // simulate slow consumer start
    for msg in &rx {
        println!("processing {msg}");
        thread::sleep(Duration::from_millis(10));
    }
    producer.join().unwrap();
    println!("all senders dropped, channel closed");
}

Key Points

  • std mpsc: MPSC, no select; crossbeam: MPMC + select!, faster
  • tokio mpsc/oneshot/broadcast/watch each solve a distinct shape
  • All-senders-dropped closes the channel: natural shutdown signal
  • Unbounded + slow consumer = delayed OOM; bounded = explicit backpressure
Q32

Why does calling an async fn in Rust do nothing until awaited, and what roles do Future, poll, Waker, and the executor play?

IntermediateAsync

Answer

An async fn does not run when called: it returns a state machine implementing Future, and nothing happens until something polls it. This laziness is the first practical gotcha, futures are inert values, and clippy will flag an unused one ('unused implementer of Future that must be used'). The Future trait has one method, poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>, returning Poll::Ready(value) when complete or Poll::Pending when blocked.

The compiler transforms the async fn's body into an enum-like state machine with one state per .await point; locals alive across an await become fields of that machine, which is why the future's size reflects everything you hold across awaits and why a large buffer held across an await bloats every task. The contract that makes this efficient: before returning Pending, a future must stash the Waker from Context; when the awaited event occurs (socket readable, timer fired), that waker is invoked, telling the executor to poll this task again. No polling loops, no busy waiting, the reactor (epoll/kqueue/io_uring under tokio's hood) drives wakeups.

Rust ships the Future trait and async/await syntax but deliberately no executor in std, which is why every async program picks a runtime, tokio being the overwhelming production default (async-std was formally discontinued, its ecosystem role absorbed by tokio and smol). .await is the only suspension point: between awaits, task code runs uninterrupted, so cooperative scheduling means a long compute loop without awaits starves every other task on that worker thread. Interviewers commonly ask you to narrate one poll cycle aloud, or ask why two sequential awaits do not run concurrently (a single state machine advances serially; concurrency requires join!/select! or spawning separate tasks).

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};

// A hand-written Future: what async/await compiles down to
struct Delay {
    deadline: Instant,
}

impl Future for Delay {
    type Output = &'static str;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if Instant::now() >= self.deadline {
            Poll::Ready("elapsed")
        } else {
            // Real impls register with a timer wheel; this demo self-wakes,
            // which busy-polls and is exactly what NOT to ship.
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

#[tokio::main]
async fn main() {
    let fut = Delay { deadline: Instant::now() + Duration::from_millis(10) };
    // Nothing has happened yet: `fut` is an inert state machine.
    println!("{}", fut.await);       // executor polls until Ready

    let (a, b) = tokio::join!(       // concurrency needs join!, not two awaits
        async { 1 + 1 },
        async { 20 + 22 },
    );
    println!("{a} {b}");
}

Key Points

  • async fn returns an inert state machine; awaits drive it
  • poll returns Ready/Pending; Pending must register the Waker
  • Locals held across .await become state-machine fields (size cost)
  • No executor in std: tokio is the production default runtime
Q33

Explain the tokio runtime: multi_thread vs current_thread flavors, why tokio::spawn demands Send + 'static, and what spawn_blocking is for.

IntermediateAsync

Answer

#[tokio::main] expands to building a Runtime and block_on-ing your async main. The default multi_thread flavor runs a worker pool (one thread per core by default, tunable via worker_threads) with work stealing: an idle worker steals queued tasks from busy ones, giving good utilisation under uneven load. current_thread runs everything on one thread, useful for tests (#[tokio::test] uses it by default), CLIs, and latency-sensitive single-core designs. tokio::spawn submits a future as an independent task and returns JoinHandle<T>; the bounds are Future + Send + 'static because work stealing may move the task between threads at any await point (hence Send) and the task may outlive the spawning scope (hence 'static, so the future must own its data, the reason you see .clone() or Arc before spawn in every real codebase). The compile error 'future cannot be sent between threads safely' almost always means a !Send type (Rc, RefCell, a raw pointer, or a std MutexGuard) is held across an .await; the fix is dropping it before the await or switching to a Send alternative.

If a spawned task panics, the panic is captured: the runtime keeps running and the JoinHandle resolves to Err(JoinError), so services must observe handles or panics vanish silently. spawn_blocking hands a synchronous, CPU-heavy or blocking closure to a separate blocking thread pool (up to 512 threads by default) so it cannot stall the async workers; use it for file I/O with std APIs, password hashing (bcrypt/argon2), compression, and image processing. Its cousin block_in_place converts the current worker to a blocking thread in place (multi_thread flavor only), avoiding a task move at the cost of subtler behaviour. Also name tokio::time::sleep vs std::thread::sleep, the latter freezing the whole worker, a top-three real-world async bug.

use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
    let permits = Arc::new(Semaphore::new(2)); // cap concurrent downstream calls

    let mut handles = Vec::new();
    for id in 0..5u32 {
        let permits = Arc::clone(&permits); // 'static: task owns its data
        handles.push(tokio::spawn(async move {
            let _permit = permits.acquire().await.expect("semaphore closed");
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
            id * 10
        }));
    }

    // CPU-heavy work goes to the blocking pool, not an async worker
    let digest = tokio::task::spawn_blocking(|| {
        (0..2_000_000u64).fold(0u64, |acc, x| acc.wrapping_add(x * x))
    });

    for h in handles {
        match h.await {
            Ok(v) => println!("task -> {v}"),
            Err(e) if e.is_panic() => eprintln!("task panicked: {e}"),
            Err(e) => eprintln!("task cancelled: {e}"),
        }
    }
    println!("digest = {}", digest.await.unwrap());
}

Key Points

  • multi_thread = work-stealing pool; current_thread for tests/CLIs
  • Send + 'static because tasks migrate threads and outlive scopes
  • Task panics surface only via JoinHandle: observe them
  • spawn_blocking for sync/CPU work; thread::sleep never in async
Q34

What actually goes wrong when you block inside an async task, why must a std::sync::MutexGuard never be held across .await, and how do you detect this in production?

IntermediateAsync

Answer

Tokio's scheduling is cooperative: a worker thread runs one task's poll until it returns Pending or Ready. A blocking call inside that poll, std::thread::sleep, a synchronous reqwest::blocking call, diesel on a sync connection, a long tight loop, freezes the entire worker, and every task queued on it stalls. With four workers, four concurrent blocking calls halt the whole runtime, manifesting as tail-latency cliffs where unrelated endpoints time out under modest load.

The MutexGuard case is nastier because it deadlocks outright: task A locks a std::sync::Mutex, then awaits; the scheduler parks A (guard still held, since it lives across the await inside the state machine) and runs task B on the same worker; B calls lock() on the same mutex and blocks the thread synchronously; A can never be polled again to release the guard. Single deadlocked worker, no panic, no error, just a hung service. The rules that follow: hold std mutex guards only in await-free sections (fine and fast for short critical sections, and clippy's await_holding_lock lint catches violations); use tokio::sync::Mutex when the guard genuinely must live across an await (its lock() is async and yields rather than blocking); push blocking work into spawn_blocking.

Note the interviewer-pleasing nuance: tokio's own docs recommend std Mutex for simple short-lived state because it is cheaper; the async Mutex is for the held-across-await case specifically. Detection: tokio-console (built on the runtime's tracing instrumentation) shows per-task poll durations and flags 'task has lost its waker' or long polls; a self-measuring heartbeat task that logs when its expected tick slips is a cheap production canary; and RUST_LOG plus tracing spans around suspicious sections localise the stall. Tokio also has a cooperative budget that forcibly yields hot tasks at await points, but it cannot help code that never reaches an await.

Key Points

  • Blocking a worker stalls every task scheduled on it
  • std MutexGuard across .await = silent single-worker deadlock
  • clippy::await_holding_lock and tokio-console catch it
  • std Mutex for short sync sections; tokio Mutex only across awaits
💡 Pro Tip: The phrase interviewers want verbatim: 'async concurrency is cooperative, so anything that blocks the thread blocks every task sharing it; I keep polls short and push blocking work to spawn_blocking.'
Q35

thiserror vs anyhow: how do you design error handling for a Rust library versus a Rust application, and how do the two crates interoperate?

IntermediateError Handling

Answer

The community consensus rule: libraries define concrete error enums with thiserror; applications propagate with anyhow. thiserror is a derive macro over your own enum: #[derive(Error, Debug)] plus #[error("display message {0}")] attributes generate the Display and std::error::Error impls, and #[from] on a variant generates the From impl that makes ? convert automatically. The enum remains a plain public type: callers can match on ConfigError::MissingKey vs ConfigError::Io and react differently, which is the entire point of library errors, they are API. thiserror adds zero runtime cost; it only writes the boilerplate you would write by hand. anyhow::Error is the opposite trade: a type-erased, boxed error (one word wide) that any std::error::Error converts into, so application code writes fn main() -> anyhow::Result<()> and sprinkles ? everywhere without defining types. Its power features: .context("reading config")/.with_context(|| format!(...)) wrap errors with human breadcrumbs as they bubble up, producing layered reports ('failed to start server: reading config: No such file or directory'), and backtrace capture integrates with RUST_BACKTRACE.

The interop: your binary uses anyhow at the top, your internal crates return thiserror enums, and ? at the boundary converts library errors into anyhow::Error automatically; downcast_ref::<ConfigError>() recovers the concrete type when the application must branch on it. Anti-patterns to call out: Box<dyn Error> in a library's public API (works, but loses matchability and Send/Sync clarity, anyhow does it better anyway), stringly-typed errors (Err("bad input".to_string())), and one giant crate-wide error enum where per-module enums would keep variants meaningful. In axum/tower services, the pattern extends to implementing IntoResponse for your error type so handlers return Result<Json<T>, ApiError> and failures map to correct status codes centrally.

use anyhow::{Context, Result};
use thiserror::Error;

// Library side: concrete, matchable errors
#[derive(Error, Debug)]
pub enum LedgerError {
    #[error("account {0} not found")]
    AccountMissing(String),
    #[error("insufficient balance: need {need} paise, have {have}")]
    Insufficient { need: u64, have: u64 },
    #[error("storage failure")]
    Storage(#[from] std::io::Error),   // enables `?` on io::Error
}

pub fn debit(account: &str, paise: u64) -> Result<u64, LedgerError> {
    if account != "acc_1" {
        return Err(LedgerError::AccountMissing(account.into()));
    }
    let have = 5_000u64;
    have.checked_sub(paise)
        .ok_or(LedgerError::Insufficient { need: paise, have })
}

// Application side: anyhow + context breadcrumbs
fn main() -> Result<()> {
    let remaining = debit("acc_1", 1_999)
        .context("processing payout batch 42")?; // LedgerError -> anyhow
    println!("remaining: {remaining}");

    if let Err(e) = debit("acc_9", 10) {
        if let LedgerError::AccountMissing(id) = &e {
            eprintln!("create account {id} first");
        }
    }
    Ok(())
}

Key Points

  • Libraries: thiserror enums (matchable API); apps: anyhow (ergonomics)
  • #[from] + ? wires conversions; .context() adds breadcrumbs
  • downcast_ref recovers concrete types from anyhow when needed
  • Web services: impl IntoResponse for the error type centralises mapping
Q36

How does serde work under the hood, and which derive attributes (rename_all, skip_serializing_if, default, tag, deny_unknown_fields) come up constantly in API work?

IntermediateEcosystem

Answer

serde splits serialisation into two independent halves bridged by a data model: #[derive(Serialize, Deserialize)] generates code describing your type in terms of serde's 29-type abstract model, and format crates (serde_json, serde_yaml, toml, bincode, postcard) translate that model to bytes. This is why one derive works for every format, and why serde is near zero-cost: the derive output monomorphizes and inlines against the chosen format with no reflection at runtime. Daily-driver attributes: #[serde(rename_all = "camelCase")] on the struct maps Rust snake_case fields to JSON camelCase, mandatory when talking to JS frontends; #[serde(rename = "type")] handles single fields colliding with Rust keywords. #[serde(skip_serializing_if = "Option::is_none")] omits null fields from output, shrinking payloads and matching API contracts that distinguish absent from null. #[serde(default)] fills missing fields on deserialisation from Default (or a custom function path), the backbone of backward-compatible config evolution. #[serde(deny_unknown_fields)] rejects payloads with unexpected keys, a validation and typo-detection tool for config files (though it conflicts with #[serde(flatten)], a known limitation worth naming).

For enums, the representation attributes decide the JSON shape: externally tagged is the default ({"Card": {...}}), #[serde(tag = "type")] gives internally tagged ({"type": "card", ...}), adding content = "data" gives adjacently tagged, and #[serde(untagged)] tries variants in order, matching whichever fits, convenient but with worse error messages and ordering pitfalls. Errors from serde_json are precise: 'missing field `amount` at line 3 column 1', and serde_path_to_error improves the path reporting in deep structures. For custom logic, #[serde(with = "module")] plugs in hand-written serialize/deserialize functions, the standard route for chrono timestamps or stringified numbers from legacy APIs.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct JobApplication {
    candidate_id: u64,
    expected_lpa: Option<u32>,
    #[serde(default)]                 // absent -> false
    willing_to_relocate: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    referral_code: Option<String>,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "method", rename_all = "snake_case")]
enum Payout {
    Upi { vpa: String },
    Bank { ifsc: String, account: String },
}

fn main() -> serde_json::Result<()> {
    let json = r#"{"candidateId": 42, "expectedLpa": 18}"#;
    let app: JobApplication = serde_json::from_str(json)?;
    println!("{app:?}"); // willing_to_relocate defaulted to false

    let p = Payout::Upi { vpa: "asha@upi".into() };
    println!("{}", serde_json::to_string(&p)?);
    // {"method":"upi","vpa":"asha@upi"}
    Ok(())
}

Key Points

  • Derive describes the type; format crates render it: zero reflection
  • rename_all, default, skip_serializing_if cover most API contracts
  • Enum tagging (external/internal/adjacent/untagged) decides JSON shape
  • deny_unknown_fields validates configs but conflicts with flatten
Q37

Where clauses, multiple trait bounds, and associated types: how do you write non-trivial generic APIs, and why does Iterator use an associated type instead of a generic parameter?

IntermediateGenerics

Answer

Bounds compose with +: fn cache<K: Hash + Eq + Clone, V>(...). Once signatures grow, where clauses keep them readable and unlock constraints inline syntax cannot express, like bounds on associated types (where I: Iterator, I::Item: Display) or on non-parameter types (where for<'a> &'a T: IntoIterator). The associated-type question is the classic: trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } uses an associated type because each concrete iterator yields exactly one item type; making it generic (trait Iterator<Item>) would permit one type to implement Iterator<u32> and Iterator<String> simultaneously, forcing every caller to disambiguate constantly.

The rule: generic parameter when multiple impls per type make sense (From<u32> and From<String> for one type are both useful), associated type when the implementing type determines it uniquely (Iterator::Item, Deref::Target, Add::Output). Associated types also clean up bounds: I: Iterator<Item = u32> reads directly. Then the cost model: monomorphization stamps a specialised copy per concrete instantiation, so a generic-heavy hot API used with thirty types produces thirty compiled bodies, inflating compile time and binary size ('generics bloat'); the standard mitigation is the inner-function pattern, a thin generic wrapper converting to concrete types (impl AsRef<Path> -> &Path) then calling a non-generic inner function that is compiled once, used pervasively inside std.

Blanket impls (impl<T: Display> MyTrait for T) apply a trait to everything meeting a bound, powerful but crate-wide: coherence forbids overlapping impls, so a blanket impl can conflict with specific ones, and E0119 ('conflicting implementations') is the error to recognise. Turbofish ::<> resolves inference ambiguity, most often on collect and parse. Finally, default type parameters like Add<Rhs = Self> explain why impl Add for Money needs no annotations in the common case.

use std::collections::HashMap;
use std::fmt::Display;
use std::hash::Hash;
use std::path::Path;

fn summarize<I>(items: I) -> String
where
    I: IntoIterator,
    I::Item: Display,          // bound on an associated type
{
    items
        .into_iter()
        .map(|x| x.to_string())
        .collect::<Vec<_>>()
        .join(", ")
}

fn tally<K>(keys: impl IntoIterator<Item = K>) -> HashMap<K, u32>
where
    K: Hash + Eq,
{
    let mut m = HashMap::new();
    for k in keys {
        *m.entry(k).or_insert(0) += 1;
    }
    m
}

// Inner-function pattern: generic shell, concrete core (compiled once)
fn load(path: impl AsRef<Path>) -> std::io::Result<String> {
    fn inner(path: &Path) -> std::io::Result<String> {
        std::fs::read_to_string(path)
    }
    inner(path.as_ref())
}

fn main() {
    println!("{}", summarize([1, 2, 3]));
    println!("{:?}", tally(["rust", "go", "rust"]));
    let _ = load("Cargo.toml");
}

Key Points

  • where clauses express bounds on associated and non-parameter types
  • Associated type = one impl per type; generic param = many impls
  • Monomorphization bloat: inner-function pattern compiles the core once
  • E0119 coherence conflicts limit blanket impls
Q38

How do you organise tests in a Rust project: #[cfg(test)] modules, the tests/ directory, doc tests, #[should_panic], and what cargo nextest adds?

IntermediateTesting

Answer

Rust bakes testing into the language and cargo. Unit tests live beside the code in a #[cfg(test)] mod tests block, compiled only under cargo test, with private-item access because the module is inside the crate; this is where you exercise internal invariants. Integration tests live in the top-level tests/ directory: each file there compiles as a separate crate linking your library through its public API only, which makes them an honest check of what users can actually do; shared helpers go in tests/common/mod.rs (a mod, not a file directly under tests/, to avoid it becoming its own test crate).

Doc tests are the underrated third kind: every ``` code block in /// documentation compiles and runs under cargo test, so examples in your API docs cannot rot; hide setup lines from rendered docs with a leading #. Assertions: assert!, assert_eq!/assert_ne! (printing both sides on failure), custom messages with format args, and matches!(value, Pattern) for enum assertions. #[should_panic(expected = "substring")] passes only if the code panics with a message containing the substring, and always use expected, otherwise any panic (including an unrelated index-out-of-bounds) makes the test pass. Tests returning Result<(), E> let you use ? instead of unwrap ladders.

Runner behaviour worth knowing: tests run in parallel threads by default, so tests mutating shared state (env vars, a fixture directory, a test database) race; fixes are --test-threads=1, the serial_test crate, or per-test isolated state. #[ignore] marks expensive tests, run via --ignored. cargo nextest is the modern runner most Rust teams adopt: process-per-test isolation (one segfault cannot take down the run), noticeably faster wall-clock on big suites, clean per-test output, retries for flaky tests, and JUnit XML for CI dashboards. It does not run doc tests, so CI pairs cargo nextest run with cargo test --doc.

pub fn parse_lpa(s: &str) -> Result<u32, String> {
    let n: u32 = s
        .trim()
        .strip_suffix("LPA")
        .unwrap_or(s.trim())
        .trim()
        .parse()
        .map_err(|_| format!("bad lpa: {s:?}"))?;
    if n > 200 {
        return Err("implausible lpa".into());
    }
    Ok(n)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_with_suffix() -> Result<(), String> {
        assert_eq!(parse_lpa(" 24 LPA ")?, 24); // ? works: test returns Result
        Ok(())
    }

    #[test]
    fn rejects_garbage() {
        let err = parse_lpa("lots").unwrap_err();
        assert!(err.contains("bad lpa"), "unexpected: {err}");
    }

    #[test]
    #[should_panic(expected = "boom")]
    fn panics_with_message() {
        panic!("boom: invariant violated");
    }

    #[test]
    #[ignore = "hits real network"]
    fn live_smoke() { /* cargo test -- --ignored */ }
}

Key Points

  • Unit tests inside #[cfg(test)] see private items; tests/ sees only public API
  • Doc tests keep examples honest; hide setup with #
  • Parallel by default: serialise tests touching shared state
  • nextest: process isolation, speed, retries; pair with cargo test --doc
Q39

There is no built-in mocking in Rust. How do you make code with external dependencies (databases, HTTP, clocks) testable, and where does mockall fit?

IntermediateTesting

Answer

The core technique is ports-and-adapters via traits: define a trait for the capability (trait PaymentGateway { fn charge(&self, paise: u64) -> Result<TxnId, GatewayError>; }), have production code depend on the trait (generically, or via Box<dyn PaymentGateway>/Arc<dyn ...> when object safety and runtime wiring suit better), and implement it twice: the real adapter (Razorpay/Stripe HTTP client) and test doubles. Because Rust has no runtime reflection or monkey-patching, this seam must be designed in, which interviewers frame positively: the language forces the dependency inversion that is optional discipline elsewhere. Hand-rolled fakes are the first tool and often the best: an InMemoryGateway holding a Mutex<Vec<Charge>> lets assertions inspect exactly what was recorded, with zero macro magic and total clarity. mockall automates the expectation style: #[automock] on the trait generates MockPaymentGateway, and tests script it with mock.expect_charge().with(eq(4999)).times(1).returning(|_| Ok(TxnId(7))); unmet expectations fail on drop. mockall shines for verifying interaction protocols (called exactly once, with these arguments, in this order via Sequence), and supports async trait methods.

Clock and time: never call SystemTime::now() or Utc::now() deep in logic; inject a Clock trait or pass timestamps as arguments, and in tokio, tokio::time::pause() plus tokio::time::advance() fast-forward timers deterministically in #[tokio::test], turning a retry-with-backoff test from minutes to milliseconds. HTTP: wiremock spins a local server with scripted responses to test the real client stack end to end, a complement rather than an alternative to trait seams. The design guidance to voice: keep trait boundaries at genuine architectural seams (storage, gateway, clock, mailer), not around every struct, and prefer state-verifying fakes over interaction-verifying mocks when either works, because fakes couple tests to behaviour rather than call sequences and survive refactors better.

use mockall::{automock, predicate::eq};

#[derive(Debug, PartialEq)]
pub struct TxnId(pub u64);

#[automock]
pub trait PaymentGateway {
    fn charge(&self, paise: u64) -> Result<TxnId, String>;
}

pub fn collect_fee(gw: &dyn PaymentGateway, lpa: u32) -> Result<TxnId, String> {
    let fee_paise = u64::from(lpa) * 100; // toy pricing
    if fee_paise == 0 {
        return Err("free tier: nothing to charge".into());
    }
    gw.charge(fee_paise)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn charges_the_computed_fee_exactly_once() {
        let mut gw = MockPaymentGateway::new();
        gw.expect_charge()
            .with(eq(1800u64))
            .times(1)
            .returning(|_| Ok(TxnId(42)));

        assert_eq!(collect_fee(&gw, 18).unwrap(), TxnId(42));
    } // unmet expectations would panic here, failing the test

    #[test]
    fn zero_lpa_never_hits_the_gateway() {
        let gw = MockPaymentGateway::new(); // no expectations: any call fails
        assert!(collect_fee(&gw, 0).is_err());
    }
}

Key Points

  • Trait seams at architectural boundaries make doubles possible
  • Hand-rolled fakes verify state; mockall verifies interactions
  • Inject clocks; tokio::time::pause/advance make timer tests instant
  • wiremock exercises the real HTTP client against scripted responses
Q40

Explain Deref coercion and the Drop trait: how does auto-deref make Box and String APIs seamless, in what order do values drop, and why can you not call .drop() yourself?

IntermediateSmart Pointers

Answer

Deref coercion: when a type implements Deref<Target = U>, the compiler automatically converts &T to &U at method calls and function-argument positions, repeating as needed. This is why &String passes where &str is expected (String: Deref<Target = str>), &Vec<T> where &[T] is expected, and why methods of T are callable directly on Box<T>, Rc<T>, and Arc<T>. Method resolution tries the receiver type, then &, then &mut, then derefs step by step, which is what makes smart pointers ergonomically invisible.

The design rule interviewers probe: implement Deref only for genuine smart pointers or transparent wrappers (a newtype over String reasonably derefs to str); using Deref to fake inheritance (making Manager deref to Employee to 'inherit' methods) is an acknowledged anti-pattern because coercion is implicit, confuses method resolution, and breaks trait bounds, composition with explicit accessors is the honest design. Drop: the destructor trait, fn drop(&mut self), called automatically when a value goes out of scope; this is RAII, and it is why Rust needs no defer, finally, or with statement, files close, guards unlock, connections return to pools, deterministically. Order is precise and testable: within a scope, variables drop in reverse declaration order; within a struct or tuple, fields drop in declaration order; and temporaries at the end of a statement.

You cannot call value.drop() explicitly (E0040, 'explicit use of destructor method') because the compiler would still schedule the automatic drop, double-dropping; the sanctioned early-drop is std::mem::drop(value), which is literally an empty function taking ownership, the move ends the value's life. Related tools: mem::replace and mem::take swap values out from behind &mut without tripping the borrow checker, and ManuallyDrop/mem::forget suppress destructors for FFI handoffs. A subtle point that lands well: Drop::drop runs while unwinding too, and a panic inside a drop during unwinding aborts the process, so destructors should never panic.

struct PoolConn {
    id: u32,
}

impl Drop for PoolConn {
    fn drop(&mut self) {
        // runs deterministically at scope end (RAII), even on early return
        println!("returning conn {} to pool", self.id);
    }
}

fn takes_str(s: &str) -> usize { s.len() }

fn main() {
    let owned = String::from("deref me");
    // &String -> &str via Deref coercion:
    println!("{}", takes_str(&owned));

    let boxed = Box::new(vec![1, 2, 3]);
    // Box<Vec<i32>> -> Vec method via auto-deref:
    println!("len={}", boxed.len());

    let _a = PoolConn { id: 1 };
    let b = PoolConn { id: 2 };
    drop(b); // early release: std::mem::drop takes ownership
    // b.drop();            // error[E0040]: explicit destructor calls not allowed
    let _c = PoolConn { id: 3 };
    println!("end of main");
    // Drop order now: _c first, then _a (reverse declaration; b already gone)
}

Key Points

  • Deref chains convert &String->&str, &Box<T>->&T at call sites
  • Deref-as-inheritance is an anti-pattern; compose explicitly
  • Reverse declaration order for locals; declaration order for fields
  • E0040 blocks value.drop(); mem::drop is the early-release door
Q41

What problem does Cow<'a, str> solve, and when would you return Cow from a function instead of String or &str?

IntermediatePerformance

Answer

Cow (clone-on-write) is enum Cow<'a, B> { Borrowed(&'a B), Owned(B::Owned) }: a value that is borrowed until ownership becomes necessary. Its sweet spot is the maybe-modify function: sanitising user input where 95% of strings are already clean. Returning String forces an allocation per call even when nothing changed; returning &str is impossible when you sometimes must produce modified data (the modified value would have no owner to borrow from).

Cow<str> returns Cow::Borrowed(input) on the clean path (zero allocation) and Cow::Owned(fixed) only when work was done. Std uses this shape itself: String::from_utf8_lossy returns Cow<str>, borrowed when the bytes were valid UTF-8, owned only when replacement characters were inserted, and Path::to_string_lossy likewise. Ergonomics are good because Cow<str> derefs to &str, so callers read it like any string; .into_owned() converts to String when storage is needed, and to_mut() gives &mut access, cloning at that moment if still borrowed (the literal clone-on-write).

Cow also earns its keep in deserialised structs: #[serde(borrow)] with Cow<'a, str> fields lets serde borrow from the input buffer when possible (no escapes in the JSON string) and allocate only when unescaping forces it, a real win for high-throughput parsers. Costs to acknowledge: the enum adds a discriminant and a branch on access, the lifetime parameter propagates into containing types, and if profiling shows the escape-hatch path dominates anyway, plain String is simpler. The interview framing is usually 'this hot function allocates on every call, most calls change nothing, fix the signature', and Cow is the expected answer, with bonus points for noting that changing a public signature from String to Cow is a breaking change, so it pays to choose early.

use std::borrow::Cow;

// Mask phone numbers only when present: usually zero-allocation
fn mask_phones(input: &str) -> Cow<'_, str> {
    if input.chars().filter(|c| c.is_ascii_digit()).count() < 10 {
        return Cow::Borrowed(input);          // clean path: no allocation
    }
    let masked: String = input
        .chars()
        .map(|c| if c.is_ascii_digit() { 'x' } else { c })
        .collect();
    Cow::Owned(masked)                         // dirty path: one allocation
}

fn main() {
    let clean = mask_phones("call me maybe");
    let dirty = mask_phones("call 9876543210 now");

    // Deref makes Cow read like &str either way:
    println!("{} / {}", clean.len(), &*dirty);

    match (&clean, &dirty) {
        (Cow::Borrowed(_), Cow::Owned(_)) => println!("as expected"),
        _ => println!("allocation profile changed"),
    }

    let stored: String = dirty.into_owned(); // own it when persisting
    println!("stored: {stored}");
}

Key Points

  • Cow = borrowed until modification forces ownership
  • Right return type for maybe-modify functions (from_utf8_lossy pattern)
  • serde(borrow) + Cow<str> gives conditional zero-copy parsing
  • Costs: discriminant + branch + lifetime infection; measure first
Q42

HashMap in practice: how does the entry API avoid double lookups, why is the default hasher 'slow', and what lets you look up a HashMap<String, V> with a &str?

IntermediateCollections

Answer

std's HashMap is a SwissTable-style open-addressing table (the hashbrown crate was adopted into std), with amortised O(1) operations and no ordering guarantees, iteration order even varies run to run because of per-process hasher seeding, a deliberate property that catches tests asserting on order. The entry API is the idiomatic answer to read-modify-write: map.entry(key).or_insert(0) does one hash lookup and returns &mut V, versus the naive contains_key-then-insert doing two; or_insert_with(|| expensive()) defers construction, and or_default() covers Default types. The counter idiom *map.entry(word).or_insert(0) += 1 is near-mandatory interview vocabulary, and and_modify(|v| ...).or_insert(1) expresses update-or-init explicitly.

Hasher: the default is SipHash-1-3, chosen for HashDoS resistance, an attacker who can predict hashes can construct keys that all collide, degrading a service's maps to O(n) per operation, so a keyed, unpredictable hash is the safe default for anything touching untrusted input. For internal maps with trusted keys on hot paths, swapping to rustc-hash (FxHashMap, used by the compiler itself) or ahash via the type alias is a routine, measurable win; the interview point is knowing the trade-off exists and defaulting to safety. Borrowed lookup: get's signature is get<Q>(&self, k: &Q) where K: Borrow<Q>, Q: Hash + Eq + ?Sized, and because String: Borrow<str> with the guarantee that hash and equality agree between the forms, map.get("key") works on HashMap<String, V> without allocating a String.

This is the Borrow trait earning its existence, and explaining it cleanly is a strong intermediate signal. Also worth naming: keys must not mutate in ways that change their hash while in the map (why keys are effectively immutable), BTreeMap when you need sorted iteration or range queries, and with_capacity to pre-size known-cardinality maps.

use std::collections::HashMap;

fn main() {
    let text = "rust go rust java rust go";

    // Entry API: one lookup per word, the canonical counter
    let mut freq: HashMap<&str, u32> = HashMap::new();
    for word in text.split_whitespace() {
        *freq.entry(word).or_insert(0) += 1;
    }

    // update-or-init, spelled explicitly:
    freq.entry("kotlin").and_modify(|c| *c += 1).or_insert(1);

    // Borrowed lookup: HashMap<String, _> queried by &str via Borrow
    let mut salaries: HashMap<String, u32> = HashMap::new();
    salaries.insert("backend".to_string(), 24);
    let lpa = salaries.get("backend");      // no String allocation
    println!("{lpa:?}");

    // or_insert_with defers cost until actually needed
    let mut cache: HashMap<u64, Vec<u8>> = HashMap::with_capacity(1024);
    cache.entry(7).or_insert_with(|| vec![0u8; 4096]);

    let mut counts: Vec<_> = freq.into_iter().collect();
    counts.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
    println!("{counts:?}"); // sort explicitly: iteration order is random
}

Key Points

  • entry().or_insert / and_modify: one lookup for read-modify-write
  • SipHash default = HashDoS resistance; FxHash/ahash for trusted hot paths
  • Borrow lets HashMap<String, V> answer &str lookups allocation-free
  • Iteration order is randomised: never assert on it
Q43

How does Rust handle integer overflow in debug vs release builds, and when do you reach for checked_add, wrapping_add, saturating_add, and TryFrom instead of as?

IntermediateCorrectness

Answer

Plain arithmetic on fixed-width integers panics on overflow in debug builds ('attempt to add with overflow') and wraps silently two's-complement in release builds by default. That asymmetry is a trap: code that panics loudly in development can corrupt values quietly in production. The knob is overflow-checks = true under [profile.release] in Cargo.toml, and for anything computing money, quantities, or indices from external input, turning it on is cheap insurance; Rust's rule that this is a program error (not UB, unlike C's signed overflow) means even the wrapping build is defined behaviour, just probably not the behaviour you wanted.

When overflow is a real possibility, encode intent in the method name: checked_add returns Option (None on overflow), the right choice for ledger math where you convert None into a domain error; overflowing_add returns (value, bool) when you need both; saturating_add clamps at the type's bounds, appropriate for gauges, progress percentages, and rate counters where pegging at max beats wrapping to zero; wrapping_add declares wraparound as intended semantics, correct for hash mixing, ring-buffer indices, and cryptographic kernels. Casting has the same discipline: as between integer types truncates silently (300u32 as u8 is 44) and saturates float-to-int; it never panics and never errors, which makes it fine for provably-lossless widening and dangerous elsewhere. u8::try_from(n) via TryFrom returns Result and is the honest narrowing conversion; clippy's cast_possible_truncation lint (pedantic group) flags risky as usage. Two adjacent gotchas worth naming: usize::MAX underflow via subtraction (len - 1 on an empty collection panics in debug, wraps to 18 quintillion in release, then indexes catastrophically; use checked_sub or saturating_sub), and mixing signed/unsigned requiring explicit conversion, since Rust has no implicit numeric coercion at all.

fn main() {
    let balance: u64 = 5_000;
    let debit: u64 = 7_500;

    // Ledger math: overflow/underflow is a domain error, not a wrap
    match balance.checked_sub(debit) {
        Some(rest) => println!("balance {rest}"),
        None => println!("declined: insufficient funds"),
    }

    // Gauge: clamp, never wrap
    let mut retry_backoff_ms: u32 = 3_800_000_000;
    retry_backoff_ms = retry_backoff_ms.saturating_add(1_000_000_000);
    println!("backoff {retry_backoff_ms}"); // pegged at u32::MAX

    // Intentional wraparound: ring buffer index
    let head: u8 = 250;
    println!("next slot {}", head.wrapping_add(10)); // 4

    // Casting: `as` truncates silently; TryFrom is honest
    let big: u32 = 300;
    println!("as u8 -> {}", big as u8);              // 44 (!)
    println!("try_from -> {:?}", u8::try_from(big));  // Err(TryFromIntError)

    // Classic release-mode bomb without checked_sub:
    let items: Vec<u8> = vec![];
    let last = items.len().checked_sub(1);
    println!("last index: {last:?}");                 // None, not a wrap
}

Key Points

  • Debug panics, release wraps: set overflow-checks=true for money paths
  • checked_/saturating_/wrapping_ encode intent in the name
  • as truncates silently; TryFrom returns Result for narrowing
  • len()-1 underflow is the classic release-only index bomb
Q44

Cargo workspaces and feature flags: how do you structure a multi-crate Rust project, and what is the feature-unification gotcha that breaks builds?

IntermediateTooling

Answer

A workspace is a root Cargo.toml with [workspace] members = [...] listing crates that share one Cargo.lock and one target/ directory: consistent dependency versions across the project and shared compilation caching. Typical service layout: a thin bin crate (api), domain logic crates (core, storage), and internal libraries, with path dependencies (core = { path = "../core" }) wiring them. Since Cargo 1.64, [workspace.dependencies] declares versions once at the root and members reference them with serde = { workspace = true }, ending version-drift between members; [workspace.package] similarly shares edition and version metadata.

Commands scale naturally: cargo build -p api builds one member, cargo test --workspace runs everything, and workspace-hack crates (cargo-hakari) exist for very large monorepos to stabilise feature resolution. Features are additive conditional compilation: [features] in Cargo.toml maps names to enabled code (#[cfg(feature = "metrics")]) and to optional dependencies (dep:prometheus syntax); default = [...] sets the out-of-box set, and consumers opt out with default-features = false, the standard route to slim builds (serde without derive, tokio with only the pieces you need). The unification gotcha: cargo computes the union of features requested for a crate across the whole dependency graph, so if any crate anywhere enables a feature, every crate gets that build of the dependency.

Consequences: your carefully minimal default-features = false is silently overridden by a transitive dependency enabling std, and, the breaking case, features must be additive by design; a 'this feature disables X' or two mutually-exclusive features (two TLS backends both enabled by different dependents) produce compile errors that only appear in workspace builds, not when building the crate alone. cargo tree -e features diagnoses who enabled what. This is a favourite senior question because it only bites people who have shipped real multi-crate projects.

# root Cargo.toml
[workspace]
members = ["api", "core", "storage"]
resolver = "2"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

# core/Cargo.toml
[package]
name = "core"
edition = "2024"

[dependencies]
serde = { workspace = true }
prometheus = { version = "0.14", optional = true }

[features]
default = []
metrics = ["dep:prometheus"]   # additive: only ADDS capability

# api/Cargo.toml (excerpt)
# [dependencies]
# core = { path = "../core", features = ["metrics"] }
# tokio = { workspace = true }

# Diagnose feature unification surprises:
#   cargo tree -e features -i prometheus
#   cargo build -p core --no-default-features

Key Points

  • One lock + one target dir; workspace.dependencies kills version drift
  • Features are additive cfg switches wired to optional deps (dep:)
  • Unification: the union of requested features wins graph-wide
  • Mutually-exclusive features break only in combined builds; design additively
Q45

Beyond cargo clippy defaults: how do you configure lint levels, what do the pedantic and nursery groups contain, and what does a production Rust CI pipeline enforce?

IntermediateTooling

Answer

Clippy organises lints in groups: correctness (deny-by-default, near-certain bugs like eq_op or approximate float equality), suspicious, style, complexity, perf (warn-by-default, e.g. needless_collect, large_enum_variant), plus opt-in pedantic (strict idiom: must_use_candidate, missing_errors_doc, cast_possible_truncation), nursery (still-stabilising lints), and restriction (situational prohibitions like unwrap_used and expect_used, never enabled wholesale, cherry-picked for codebases where a panic is unacceptable). Since Cargo 1.74 the clean configuration home is [lints] in Cargo.toml (or [workspace.lints] shared workspace-wide): declare clippy::pedantic = "warn", then dial individual lints back with allow where the team disagrees; attribute-level #[allow(clippy::too_many_arguments)] with a justifying comment handles local exceptions. Additional thresholds (cognitive-complexity-threshold, type-complexity-threshold, MSRV via rust-version) live in clippy.toml.

A production CI pipeline typically runs: cargo fmt --check (formatting is non-negotiable and never discussed in review), cargo clippy --workspace --all-targets --all-features -- -D warnings (lints as errors, including tests and benches), cargo nextest run plus cargo test --doc, cargo audit (RustSec advisory database scan of Cargo.lock for known CVEs in dependencies) or the broader cargo deny check which also enforces licence allowlists and bans duplicate versions, and often cargo doc with warnings denied so public docs stay complete. Teams shipping binaries add cargo build --release and a size check; unsafe-heavy crates add Miri runs on a schedule. Two culture points interviewers listen for: -D warnings belongs in CI but not necessarily in local builds (RUSTFLAGS forcing it locally makes exploratory coding miserable, and cap-lints means dependency warnings never break you anyway), and every #[allow] should carry a comment, because an unexplained allow is just a suppressed bug report. Mentioning that clippy ships machine-applicable fixes via cargo clippy --fix rounds out the answer.

Key Points

  • Groups: correctness denies by default; pedantic/restriction are opt-in
  • [lints] in Cargo.toml (1.74+) centralises config; clippy.toml for thresholds
  • CI: fmt --check, clippy -D warnings, nextest + doc tests, cargo audit/deny
  • Every #[allow] needs a written justification
Q46

Walk through building a production HTTP service with axum: handlers, extractors, State, and how tower middleware layers compose.

IntermediateWeb Services

Answer

axum (maintained within the tokio project) won the 2026 framework race because it is a thin, macro-free layer over hyper and tower: routing plus extractors, with everything else (timeouts, tracing, compression, auth) arriving as reusable tower middleware. A handler is any async function whose parameters implement FromRequestParts/FromRequest (extractors) and whose return implements IntoResponse. Extractors declaratively pull typed data: Path<u32> for URL segments, Query<Params> deserialising the query string via serde, Json<Body> for JSON bodies (rejecting malformed input with 400/422 automatically), headers via TypedHeader, and State<AppState> for shared application state.

Ordering rule that trips people: exactly one extractor may consume the request body, so Json/Bytes must be the last parameter. State is axum's dependency injection: build an AppState struct (usually cheap-to-clone: Arc-wrapped pool and config), attach with .with_state(state), and every handler can request State<AppState>; the compiler verifies at build time that the state type matches, a compile-error upgrade over stringly-typed request extensions. Errors: implement IntoResponse for your error enum, mapping variants to status codes and a JSON body, then handlers return Result<Json<T>, ApiError> and ? just works.

Middleware composes via .layer(): ServiceBuilder stacks TraceLayer (request/response tracing spans), TimeoutLayer, CompressionLayer, CorsLayer, ConcurrencyLimitLayer, wrapping the router so cross-cutting concerns stay out of handlers; layers apply outside-in in the order stacked. Under load, remember hyper serves each connection on the runtime, so the blocking rules from the async questions apply verbatim: sqlx (async) for the database, spawn_blocking for bcrypt. Testing is a quiet strength: a Router implements tower::Service, so oneshot() drives requests through the full stack in-process, no TCP socket needed, making handler tests fast and deterministic.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Json},
    routing::get,
    Router,
};
use serde::Serialize;
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    pool: Arc<Vec<&'static str>>, // stand-in for sqlx::PgPool
}

#[derive(Serialize)]
struct Job { id: u32, title: String }

enum ApiError { NotFound }

impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "no such job").into_response(),
        }
    }
}

async fn get_job(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Job>, ApiError> {
    let title = state.pool.get(id as usize).ok_or(ApiError::NotFound)?;
    Ok(Json(Job { id, title: title.to_string() }))
}

#[tokio::main]
async fn main() {
    let state = AppState { pool: Arc::new(vec!["Rust Engineer", "SRE"]) };
    let app = Router::new().route("/jobs/{id}", get(get_job)).with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Key Points

  • Handlers = async fns of extractors returning IntoResponse
  • State<T> is compile-checked DI; body extractor goes last
  • tower layers (Trace/Timeout/Cors) compose cross-cutting concerns
  • Router implements Service: oneshot() tests need no socket
Q47

sqlx vs diesel for database access: what does compile-time query checking actually verify, and how do query!, offline mode, and connection pooling work in practice?

IntermediateDatabases

Answer

The two dominant choices embody opposite philosophies. diesel is a sync ORM-ish query builder: schema inferred into Rust types (schema.rs via diesel CLI), queries composed with a typed DSL, and mistakes caught by the type system without touching a database; async arrives via the separate diesel-async crate. sqlx is async-first, ORM-free: you write raw SQL, and its headline feature is the query! macro family, which at compile time connects to the database in DATABASE_URL (or reads cached metadata), asks the server to prepare your SQL, and verifies the statement parses, the referenced tables and columns exist, parameter counts match, and result column types map onto the Rust types you are binding into. A typo'd column name or an INT bound into a String is a compile error with the exact query span, catching at build time the class of bug that otherwise surfaces as a runtime 500. query_as!(Struct, ...) maps rows into your struct by name, checking nullability too: a nullable column must land in Option<T> or the macro refuses. Offline mode makes this CI-friendly: cargo sqlx prepare captures query metadata into a .sqlx/ directory you commit, and with SQLX_OFFLINE=true builds verify against the cache, so CI needs no live database; forgetting to re-run prepare after editing a query is the everyday failure, showing up as 'failed to find data for query' errors in CI.

Runtime pieces: PgPool (bounded connection pool, tune max_connections against Postgres limits), transactions via pool.begin() returning a Transaction guard that rolls back on drop unless .commit().await is called, an RAII detail interviewers like, and compile-time-unchecked query() for dynamic SQL, where you accept runtime checking and must guard against injection by always binding parameters, never formatting values into SQL strings. Migrations ship in-crate: sqlx::migrate!() embeds ./migrations and applies them at startup, keeping schema drift visible in code review.

use sqlx::postgres::PgPoolOptions;

#[derive(Debug)]
struct Candidate {
    id: i64,
    email: String,
    expected_lpa: Option<i32>, // nullable column MUST be Option
}

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .connect(&std::env::var("DATABASE_URL").expect("set DATABASE_URL"))
        .await?;

    // Verified against the live schema (or .sqlx cache with SQLX_OFFLINE=true):
    // wrong column name / type mismatch = COMPILE error
    let hired = sqlx::query_as!(
        Candidate,
        "SELECT id, email, expected_lpa FROM candidates WHERE status = $1 LIMIT 10",
        "hired"
    )
    .fetch_all(&pool)
    .await?;

    // Transaction: rolls back on drop unless committed (RAII)
    let mut tx = pool.begin().await?;
    sqlx::query!("UPDATE candidates SET status = $1 WHERE id = $2", "archived", 42i64)
        .execute(&mut *tx)
        .await?;
    tx.commit().await?;

    println!("{} hired candidates", hired.len());
    Ok(())
}

Key Points

  • query! verifies SQL, columns, param counts, and nullability at build time
  • cargo sqlx prepare + SQLX_OFFLINE=true keeps CI database-free
  • Transaction guard rolls back on drop; commit is explicit
  • Dynamic SQL: bind parameters always, never format values in
Q48

How does the tracing crate implement structured observability in Rust services: spans vs events, #[instrument], and wiring subscribers to JSON logs and OpenTelemetry?

IntermediateObservability

Answer

tracing (the tokio ecosystem's framework, having effectively superseded log for services) is built on two primitives: events, point-in-time records replacing log lines (info!, warn!, error! with structured key-value fields, not interpolated strings), and spans, named periods of time with fields that nest to form a tree per request. The killer feature for async: spans correctly follow tasks across await points and thread migrations via the .instrument(span) combinator, where a thread-local logging MDC silently loses context the moment a task hops workers, which is exactly why log-style thread-local approaches break under tokio. #[instrument] on a function creates a span per call, capturing arguments as fields automatically (opt out per-arg with skip, mandatory for secrets and large payloads; add err to record error returns). Emission is decoupled from collection: a Subscriber (typically tracing_subscriber::registry() composed of layers) decides what happens, fmt layer for human or JSON output (.json() giving one structured object per event, what Loki/CloudWatch ingestion wants), EnvFilter honouring RUST_LOG syntax (RUST_LOG=info,my_crate::payments=debug,sqlx=warn) for per-module runtime verbosity, and tracing-opentelemetry bridging spans into OTel traces so the same instrumentation feeds Jaeger/Tempo/SigNoz with no second annotation pass.

In axum, tower-http's TraceLayer opens a span per HTTP request with method/path/status, and your nested spans hang off it, giving flame-style request breakdowns for free. Field syntax details that read as fluency: %field uses Display, ?field uses Debug, field = value records typed values, and empty fields declared in #[instrument(fields(user_id = tracing::field::Empty))] can be recorded later via Span::current().record(). Costs: disabled levels are nearly free (compile-time max-level features can strip them entirely), but JSON formatting on every event is real CPU, so keep hot-path events at debug and let EnvFilter gate them in production.

use tracing::{info, instrument, warn};
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};

#[instrument(skip(password), fields(attempt = 1), err)]
async fn login(email: &str, password: &str) -> Result<u64, String> {
    info!("verifying credentials");           // inherits span fields
    if password.len() < 8 {
        warn!(reason = "short password", "rejecting");
        return Err("invalid credentials".into());
    }
    Ok(42)
}

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
        .with(fmt::layer().json())            // one JSON object per event
        .init();

    let user = login("asha@example.com", "correct horse").await;
    info!(user_id = ?user, "login flow finished");

    // Span following a spawned task across workers:
    use tracing::Instrument;
    tokio::spawn(
        async { info!("inside the job span") }
            .instrument(tracing::info_span!("bg_job", job_id = 7)),
    )
    .await
    .unwrap();
}

Key Points

  • Events = structured log points; spans = nested timed contexts
  • Spans survive await/thread hops; thread-local MDC does not
  • #[instrument(skip(...), err)] auto-fields functions; skip secrets
  • Registry + EnvFilter + JSON fmt + tracing-opentelemetry = full pipeline
Q49

Async fn in traits: what did Rust 1.75 stabilise, what still forces the async-trait crate in 2026, and where do GATs fit into the story?

AdvancedAsync

Answer

Rust 1.75 (December 2023) stabilised async fn in traits (AFIT) and return-position impl Trait in traits (RPITIT), the features are one mechanism, since async fn sugar is exactly a method returning impl Future. Before that, traits could not have async methods at all, and the ecosystem ran on the async-trait proc macro, which rewrites each method to return Pin<Box<dyn Future + Send>>, one heap allocation and a dynamic dispatch per call. Native AFIT removes both costs: the returned future is a concrete, statically dispatched type.

The enabling groundwork was generic associated types (GATs, stabilised 1.65): the desugared trait needs an associated type generic over the lifetime of &self (type Fut<'a>: Future where Self: 'a), because the returned future borrows the receiver; without lifetime-parameterised associated types the desugaring is inexpressible. What still hurts in 2026, and what interviewers dig for: dynamic dispatch, dyn Trait with AFIT methods does not work in the general case (the future type differs per implementor, so its size is unknowable through erasure), and the Send-bound problem, a generic caller that spawns the returned future needs it Send, but plain AFIT gives you no place to say so; trait_variant::make generates a Send-bounded variant, or you write the RPITIT desugar explicitly with + Send bounds. So the practical rule that shows judgement: public traits designed for static dispatch use native AFIT (and version-pin accordingly); traits that must be object-safe for runtime plugin-style wiring, or that get spawned by generic executors, still use #[async_trait] and accept its boxing cost, which is trivially amortised against any real I/O the method performs. Mention that tower's Service trait predates AFIT and still uses associated Future types, so reading production tower code requires fluency in the desugared form.

use std::future::Future;

// Native AFIT (1.75+): zero boxing, static dispatch
trait JobStore {
    async fn fetch(&self, id: u64) -> Option<String>;
}

// The explicit desugar when you must add Send for spawn-ability:
trait JobStoreSend {
    fn fetch(&self, id: u64) -> impl Future<Output = Option<String>> + Send;
}

struct Memory;

impl JobStore for Memory {
    async fn fetch(&self, id: u64) -> Option<String> {
        (id == 1).then(|| "Rust Engineer".to_string())
    }
}

impl JobStoreSend for Memory {
    fn fetch(&self, id: u64) -> impl Future<Output = Option<String>> + Send {
        async move { (id == 1).then(|| "SRE".to_string()) }
    }
}

async fn spawn_lookup<S>(store: S) -> Option<String>
where
    S: JobStoreSend + Send + 'static,
{
    tokio::spawn(async move { store.fetch(1).await })
        .await
        .unwrap()
}

#[tokio::main]
async fn main() {
    println!("{:?}", Memory.fetch(1).await); // AFIT path
    println!("{:?}", spawn_lookup(Memory).await);
}

Key Points

  • 1.75: AFIT/RPITIT native, no per-call Box; GATs (1.65) made it expressible
  • dyn + AFIT does not work generally; plugins still use #[async_trait]
  • Send bounds need the explicit desugar or trait-variant
  • tower::Service shows the pre-AFIT associated-Future style
Q50

Why does Pin<&mut Self> appear in Future::poll? Explain self-referential state machines, Unpin, and when you actually write Box::pin or pin! in application code.

AdvancedAsync

Answer

The compiler-generated future for an async fn stores everything alive across an .await as fields. If your code holds a reference to another local across an await (let buf = [0u8; 1024]; let slice = &buf[..]; read(slice).await), both the buffer and the reference into it become fields of the same struct: a self-referential struct. Moving such a value is memory-unsafe, the interior pointer would still aim at the old address, and safe Rust normally makes self-referential structs impossible for exactly that reason.

Pin is the contract that squares the circle: Pin<P> wraps a pointer and guarantees the pointee will never be moved again, so poll takes Pin<&mut Self>, letting the state machine safely contain self-references once pinned. Unpin is the escape valve: most types do not care about their address (an i32, a String, almost everything you write by hand), and for T: Unpin, Pin<&mut T> is freely convertible to &mut T, so pinning is a no-op. Compiler-generated futures are deliberately !Unpin.

Where this touches application code: awaiting a future normally hides all pinning, but APIs that poll a future repeatedly without consuming it need it pinned first, tokio::select! polling the same future across loop iterations is the everyday case, and the error 'cannot be unpinned' or E0277 on !Unpin types is your cue. Fixes: tokio::pin!(fut) (or std's std::pin::pin! macro, stabilised 1.68) pins to the stack, free; Box::pin(fut) pins to the heap, needed when the pinned future must move between owners (stored in a struct, returned, or type-erased as Pin<Box<dyn Future>>). Also name futures::stream::Stream, whose poll_next takes Pin for identical reasons, and the safety asymmetry: constructing Pin around data you might later move requires unsafe (Pin::new_unchecked), which is why the macros and Box::pin exist as safe fronts. Very few engineers ever implement poll by hand; interviewers ask this to check you understand the machinery you stand on.

use std::time::Duration;
use tokio::time::{interval, sleep};

#[tokio::main]
async fn main() {
    // A future polled across iterations must be pinned:
    let deadline = sleep(Duration::from_millis(200));
    tokio::pin!(deadline); // stack-pin; without this, select! fails to compile

    let mut ticker = interval(Duration::from_millis(50));
    let mut ticks = 0u32;

    loop {
        tokio::select! {
            _ = &mut deadline => {          // re-polling the SAME future
                println!("deadline after {ticks} ticks");
                break;
            }
            _ = ticker.tick() => {
                ticks += 1;
            }
        }
    }

    // Heap pinning for storable/erased futures:
    let boxed: std::pin::Pin<Box<dyn std::future::Future<Output = u32> + Send>> =
        Box::pin(async { 7 });
    println!("{}", boxed.await);
}

Key Points

  • Awaits can create self-referential state machines; moving them is UB
  • Pin = 'this value will never move again'; Unpin opts most types out
  • select! re-polling requires pin!/Box::pin; the E0277 Unpin error is the tell
  • Pin::new_unchecked is unsafe; macros and Box::pin are the safe fronts
Q51

What is cancellation safety in async Rust? Show how tokio::select! can silently drop half-completed work and the patterns that prevent data loss.

AdvancedAsync

Answer

Cancellation in Rust async is just Drop: a future that is dropped before completion simply stops existing, no cancellation exception, no notification, whatever its state machine held is dropped and whatever it was mid-way through never finishes. tokio::select! runs several futures and, when one completes, drops all the others, so every non-winning branch gets cancelled at its current await point on every loop iteration. A future is 'cancellation safe' if dropping it mid-flight loses nothing: tokio mpsc's recv() is (a message is either delivered or still in the channel), but read_exact() is not (it may have consumed a partial read into your buffer before being dropped: bytes gone), write_all() may have written a prefix, and an async block that received a work item and was about to process it drops the item on the floor. The tokio docs annotate methods with cancellation-safety notes precisely because select!-in-a-loop is the standard server shape (handle messages, shutdown signal, timers) and the bug is silent: under load you lose one message per races-with-shutdown, an unreproducible data-loss ticket.

Defensive patterns: keep select! branches to a single cancellation-safe await and move all subsequent processing after the select! (win the race first, then do the work outside); pin long-lived futures outside the loop with &mut so they resume rather than restart each iteration; for graceful shutdown, prefer tokio_util::sync::CancellationToken plus explicit draining over dropping tasks, and spawn work that must complete into its own task, because tokio::spawn detaches execution from the awaiting caller, dropping a JoinHandle does not cancel the task (JoinHandle::abort does, at the next await point). For cleanup that must run on cancellation, RAII guards (Drop impls) are the only reliable hook, async drop does not exist in stable Rust, so any async cleanup needs an explicit shutdown path. Senior loops at infrastructure companies treat this topic the way kernel interviews treat race conditions: the differentiator between people who have run async Rust in production and people who have read about it.

use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

async fn worker(mut rx: mpsc::Receiver<String>, shutdown: CancellationToken) {
    loop {
        tokio::select! {
            // recv() is cancellation-safe: a lost race leaves the msg queued
            maybe = rx.recv() => {
                let Some(job) = maybe else { break }; // channel closed
                // Do the work AFTER winning the race, not inside the branch
                // that select! might cancel next iteration.
                process(job).await;
            }
            _ = shutdown.cancelled() => {
                // Drain deterministically instead of dropping queued work
                while let Ok(job) = rx.try_recv() {
                    process(job).await;
                }
                break;
            }
        }
    }
}

async fn process(job: String) {
    println!("processed {job}");
}

#[tokio::main]
async fn main() {
    let (tx, rx) = mpsc::channel(64);
    let token = CancellationToken::new();
    let handle = tokio::spawn(worker(rx, token.clone()));

    tx.send("index-resumes".into()).await.unwrap();
    tx.send("send-digest".into()).await.unwrap();
    token.cancel();               // request shutdown; worker drains first
    handle.await.unwrap();
}

Key Points

  • Cancellation = Drop at an await point; losers of select! are dropped
  • recv() is cancel-safe; read_exact/write_all and stateful blocks are not
  • One safe await per branch; process after the select!
  • Dropping a JoinHandle detaches, abort() cancels; drain on shutdown
Q52

What does the unsafe keyword actually permit, what invariants must you uphold manually, and how do Miri and SAFETY comments fit into a production unsafe-code workflow?

AdvancedUnsafe

Answer

unsafe unlocks exactly five abilities: dereferencing raw pointers, calling unsafe functions (including FFI), implementing unsafe traits (Send, Sync), accessing static mut, and accessing union fields. It does not disable the borrow checker or type checking; it removes the compiler's proof obligation for a specific, enumerated set of operations and transfers it to you. The invariants you now guarantee by hand: no dangling or unaligned dereferences, no aliasing violations (constructing two live &mut to one location is instant UB even if never used to write), initialised memory for every read (MaybeUninit exists because mem::uninitialized was unsound), valid values for types (a bool holding 3, a str that is not UTF-8), and no data races.

Violations are undefined behaviour: the optimiser assumes they cannot happen, so symptoms range from nothing to miscompiled release-only heisenbugs. The professional workflow interviewers want to hear: first, avoid, most unsafe in application code is unnecessary (there is a safe API or a crate that encapsulates it); second, minimise and encapsulate, keep the unsafe surface behind a small module boundary whose public API is sound for all inputs, the Vec model, where users of the safe wrapper cannot cause UB no matter what they do; third, document every block with a // SAFETY: comment stating exactly which precondition holds and why (clippy's undocumented_unsafe_blocks lint, restriction group, enforces this, and #![deny(unsafe_op_in_unsafe_fn)] makes unsafe operations inside unsafe fns require their own blocks, edition-2024 default behaviour); fourth, verify: Miri (cargo +nightly miri test) interprets your MIR and detects UB dynamically, use-after-free, out-of-bounds, aliasing discipline violations per its tree-borrows/stacked-borrows models, uninitialised reads, and memory leaks, making it the closest thing Rust has to a UB proof harness, run in CI on unsafe-heavy crates; cargo-geiger inventories unsafe across your dependency tree when auditing. Finally the honest framing: unsafe is not a performance switch, safe Rust compiles to the same code in almost all cases, it is for FFI, novel data structures, and hardware access, and 'I would first try to delete the unsafe' is usually the winning interview move.

/// Splits one mutable slice into two non-overlapping halves.
/// A safe API wrapping an unsafe core (this is how std does it).
fn split_two<T>(v: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    assert!(mid <= v.len(), "mid out of bounds"); // uphold the precondition
    let len = v.len();
    let ptr = v.as_mut_ptr();
    // SAFETY: [0, mid) and [mid, len) never overlap, and both ranges
    // are in bounds because mid <= len (asserted above). Therefore the
    // two &mut slices alias no element.
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut xs = [1, 2, 3, 4, 5];
    let (a, b) = split_two(&mut xs, 2);
    a[0] += 10;
    b[0] += 100;
    println!("{xs:?}"); // [11, 2, 103, 4, 5]
}

// Verify the aliasing reasoning dynamically:
//   rustup +nightly component add miri
//   cargo +nightly miri test

Key Points

  • Five powers only; the borrow checker stays on everywhere else
  • UB includes merely creating aliasing &mut, not just using it
  • Encapsulate behind sound safe APIs; SAFETY comments are mandatory
  • Miri in CI is the UB harness; unsafe is for FFI/data structures, not speed
Q53

How do you call C from Rust and expose Rust to C: extern "C", #[repr(C)], bindgen vs cbindgen, ownership across the boundary, and why panics must never unwind into C?

AdvancedFFI

Answer

Declaring C functions uses an extern "C" block (edition 2024 requires writing unsafe extern, making the trust explicit), and every call is unsafe because the compiler cannot verify the foreign side. Data crossing the boundary must have a defined layout: #[repr(C)] on structs fixes field order and C-compatible padding, since Rust's default repr may reorder fields; enums crossing need #[repr(C)] or #[repr(i32)]-style explicit discriminants; and only FFI-safe types may appear in signatures (raw pointers, c_int and friends from std::ffi, repr(C) types), with the compiler's improper_ctypes lint flagging violations like passing a String or a trait object. Strings are the everyday pain: Rust &str is length-delimited UTF-8, C wants NUL-terminated char*; CString::new allocates a NUL-terminated copy for outbound (failing if the input contains interior NULs), CStr::from_ptr wraps inbound pointers for borrowed access, and c"literal" C-string literals (stabilised 1.77) remove boilerplate for constants.

Tooling: bindgen generates Rust declarations from C headers at build time via build.rs (wrestling with it is a rite of passage on any -sys crate); cbindgen goes the other way, generating a C header from your #[no_mangle] pub extern "C" fn exports for embedding Rust in C/C++ apps; cargo builds the artefacts via crate-type = ["cdylib"] or staticlib. Ownership discipline is the design core: every allocation must be freed by the allocator that created it, so a Rust library exporting create/destroy pairs uses Box::into_raw to hand C an opaque pointer and Box::from_raw in the destroy function to reclaim and drop it, and never lets C free Rust memory or vice versa. Finally, unwinding across an FFI boundary is undefined behaviour: any exported Rust function must catch panics with std::panic::catch_unwind and convert them to an error code (the extern "C-unwind" ABI exists for the deliberate cross-language-unwind case). A follow-up worth anticipating: Miri cannot execute foreign code, so FFI-heavy crates lean on sanitizers (ASan via -Z sanitizer=address) and valgrind instead.

use std::ffi::{c_char, c_int, CStr, CString};

// Inbound: declare the C side (libc's strlen)
unsafe extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

// Outbound: an opaque Rust object handed to C
pub struct Scorer {
    weight: f64,
}

#[no_mangle]
pub extern "C" fn scorer_new(weight: f64) -> *mut Scorer {
    Box::into_raw(Box::new(Scorer { weight }))
}

#[no_mangle]
pub extern "C" fn scorer_score(s: *const Scorer, years: c_int) -> f64 {
    let result = std::panic::catch_unwind(|| {
        // SAFETY: caller contract, s came from scorer_new and is not freed
        let scorer = unsafe { &*s };
        scorer.weight * f64::from(years)
    });
    result.unwrap_or(-1.0) // never unwind into C: UB
}

#[no_mangle]
pub extern "C" fn scorer_free(s: *mut Scorer) {
    if !s.is_null() {
        // SAFETY: reclaim ownership from the pointer we handed out
        drop(unsafe { Box::from_raw(s) });
    }
}

fn main() {
    let c_string = CString::new("goodspace").expect("no interior NUL");
    // SAFETY: c_string is a valid NUL-terminated pointer for the call
    let n = unsafe { strlen(c_string.as_ptr()) };
    println!("strlen said {n}");
    let back = unsafe { CStr::from_ptr(c_string.as_ptr()) };
    println!("round-trip: {:?}", back.to_str());
}

Key Points

  • repr(C) fixes layout; improper_ctypes flags non-FFI-safe signatures
  • CString/CStr bridge NUL-terminated C strings; c"..." literals since 1.77
  • Box::into_raw / from_raw pairs keep each allocator freeing its own memory
  • catch_unwind at every exported fn; unwinding into C is UB
Q54

Your Rust service is slow. Walk through the real optimisation workflow: release-profile settings (lto, codegen-units, panic), cargo flamegraph, criterion, and allocator swaps.

AdvancedPerformance

Answer

Step zero, always: confirm the measurement is a release build; debug builds run 10-50x slower and 'Rust is slower than Python' bug reports are perennially debug-build artefacts. Then squeeze the [profile.release] knobs in Cargo.toml: opt-level = 3 is default; lto = "thin" is cheap and usually worthwhile, lto = "fat" enables whole-program inlining across all crates for a few percent more at significant link-time cost; codegen-units = 1 stops splitting crates into parallel codegen chunks, trading compile time for better optimisation; panic = "abort" drops unwinding tables; strip = "symbols" shrinks the binary. RUSTFLAGS="-C target-cpu=native" unlocks AVX2/AVX-512 vectorisation for machines you control (do not ship such binaries to unknown CPUs).

For hot-loop crates, profile-guided optimisation (cargo-pgo) adds low single digits more. Measurement discipline: criterion for microbenchmarks, it runs warmups, applies statistical analysis, detects regressions against saved baselines, and its black_box prevents the optimiser deleting your benchmarked code, a classic rookie result of '0ns per iteration'; cargo flamegraph (perf-based) for macro profiling, where wide frames show where wall time actually goes, with debug = true in the release profile to keep symbols readable. The usual Rust-specific culprits, in rough frequency order: allocation churn (clone in hot loops, format! for keys, collect between iterator stages; fix with borrowing, with_capacity, buffer reuse, sometimes an arena like bumpalo), the global allocator itself under multithreaded churn (swapping in mimalloc or jemallocator via #[global_allocator] is two lines and routinely wins 5-20% on allocation-heavy servers), unbuffered I/O (raw File reads syscall per call; wrap in BufReader/BufWriter), hashing (SipHash on hot internal maps; switch to FxHashMap), bounds checks in tight index loops (rewrite with iterators or chunks_exact, verify with cargo-show-asm before resorting to anything unsafe), and accidental synchronous contention (one hot Mutex; shard it or use atomics). The interview-winning shape is methodology over trivia: measure, find the widest frame, fix, re-measure, and never claim a win without numbers from criterion or production p99s.

# Cargo.toml: production release profile
[profile.release]
opt-level = 3
lto = "fat"           # whole-program inlining (slower links)
codegen-units = 1      # better codegen, slower compile
panic = "abort"
strip = "symbols"

[profile.release-profiling]  # separate profile keeping symbols
inherits = "release"
debug = true
strip = "none"

# Allocator swap (main.rs):
#   #[global_allocator]
#   static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

# Workflow:
#   cargo bench                                  # criterion baselines
#   cargo flamegraph --profile release-profiling # find the wide frames
#   RUSTFLAGS="-C target-cpu=native" cargo build --release
#   cargo build --timings                        # compile-time analysis

Key Points

  • Never profile debug builds; that mistake explains most 'slow Rust'
  • lto=fat + codegen-units=1 + panic=abort is the standard max profile
  • criterion (with black_box) for micro; flamegraph for macro
  • Allocation churn and the allocator itself are the usual suspects
Q55

Atomics and memory ordering in Rust: what do Relaxed, Acquire, Release, and SeqCst actually guarantee, and how do you test lock-free code with loom?

AdvancedConcurrency

Answer

std::sync::atomic types (AtomicUsize, AtomicBool, AtomicU64, AtomicPtr) provide lock-free operations, load, store, fetch_add, swap, and compare_exchange, each taking an Ordering that constrains how this access may be reordered relative to other memory operations, by both the compiler and the CPU. Rust inherits the C++ memory model. Ordering::Relaxed guarantees only atomicity of this one operation: no torn reads, but zero synchronisation with surrounding memory, correct for standalone counters (metrics, IDs) where nothing else depends on the value's timing.

Acquire on a load and Release on a store form the pairing that publishes data: everything written before a Release store is visible to a thread whose Acquire load observes that store, the mechanism behind every ready-flag pattern, and precisely what Mutex lock/unlock does internally. Use Relaxed on the flag and the reader can observe flag == true while seeing stale data it guards: a real, architecture-dependent bug that x86's strong ordering often hides until you deploy on ARM (Graviton, Apple Silicon), which is why 'it worked on my Mac... which is also ARM' no longer even saves you. SeqCst adds a single global order over all SeqCst operations, needed rarely (multi-variable consensus like Dekker's algorithm) and the default people reach for out of caution; the pragmatic guidance is Relaxed for pure counters, Acquire/Release for publication, SeqCst when you cannot prove the weaker ones sufficient. compare_exchange takes two orderings (success, failure) and returns Result with the observed value, the primitive under spinlocks and lock-free stacks; compare_exchange_weak may fail spuriously but maps cheaper onto LL/SC architectures, hence its use in retry loops.

Testing: loom is the standard tool, replace std::sync with loom's shims under #[cfg(loom)] and it model-checks your test by exhaustively exploring thread interleavings and the memory-model reorderings, catching ordering bugs deterministically that stress tests hit once a month. Interviewers rarely expect you to write a lock-free queue; they expect you to explain Acquire/Release publication correctly and to know that crossbeam-epoch exists so you do not hand-roll memory reclamation.

use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

static PROCESSED: AtomicU64 = AtomicU64::new(0);

fn main() {
    // Relaxed: standalone metric, no surrounding data depends on it
    PROCESSED.fetch_add(1, Ordering::Relaxed);

    // Acquire/Release publication: flag guards the payload
    let payload = Arc::new(std::sync::Mutex::new(Vec::<u32>::new()));
    let ready = Arc::new(AtomicBool::new(false));

    let (p, r) = (Arc::clone(&payload), Arc::clone(&ready));
    let producer = thread::spawn(move || {
        p.lock().unwrap().extend([1, 2, 3]); // writes BEFORE the release store
        r.store(true, Ordering::Release);     // publish
    });

    let (p2, r2) = (Arc::clone(&payload), Arc::clone(&ready));
    let consumer = thread::spawn(move || {
        while !r2.load(Ordering::Acquire) {   // observe the publication
            std::hint::spin_loop();
        }
        // Guaranteed to see the writes that preceded the Release store
        println!("consumer sees {:?}", p2.lock().unwrap());
    });

    producer.join().unwrap();
    consumer.join().unwrap();
    println!("processed={}", PROCESSED.load(Ordering::Relaxed));
}

Key Points

  • Relaxed = atomicity only; Acquire/Release = happens-before publication
  • Wrong ordering hides on x86, detonates on ARM (Graviton)
  • compare_exchange(_weak) powers CAS loops; weak for LL/SC targets
  • loom model-checks interleavings; crossbeam for reclamation
Q56

Zero-copy techniques in Rust services: serde lifetime-borrowing deserialization, the bytes crate, and where zero-copy actually pays off versus complicating the design.

AdvancedPerformance

Answer

Zero-copy means parsing or moving data while pointing into the original buffer instead of allocating copies. serde supports it natively through lifetimes: deserialize into a struct with &'a str fields and serde borrows string slices directly from the input (the Deserialize<'de> lifetime is exactly this machinery), turning a 10k-string JSON parse from 10k allocations into zero on the happy path. The constraints define the design: the input buffer must outlive the parsed struct (so from_str works but from_reader cannot borrow, since the reader's buffer is transient), and JSON escape sequences force copies because the unescaped form differs from the raw bytes, which is why Cow<'a, str> with #[serde(borrow)] is the production shape, borrowed when clean, owned only when unescaping demands it. This is a real technique in log pipelines, tick-data parsers, and proxies, and a real trap in request handlers: the moment the parsed value must be sent to another task or stored past the request, the 'static requirement kills the borrow and you own the data anyway, so zero-copy parse-then-immediately-process is the pattern, not zero-copy-everywhere.

The bytes crate solves the adjacent problem for network payloads: Bytes is a cheaply cloneable, reference-counted view over a shared buffer, and slice operations produce new Bytes sharing the same allocation, so a framing layer can split a socket read into per-message views without copying, which is why tokio-util codecs, hyper bodies, and tonic all speak Bytes; BytesMut is the writable accumulation side, split_to() carving off frames while retaining the tail. Related tools worth naming with their trade-offs: memmap2 maps files so the OS pages data in on demand (with the caveat that the file changing underneath you is UB territory requiring care), and rkyv achieves true zero-deserialization by laying data out so the archived bytes are directly usable, at the cost of format lock-in and unsafe validation trade-offs. The senior framing: allocation avoided is latency and allocator contention saved, but every lifetime added is API complexity spent; measure with flamegraphs first, apply zero-copy at the hot boundary only, and let the rest of the codebase own its data in peace.

use bytes::{Buf, BytesMut};
use serde::Deserialize;
use std::borrow::Cow;

#[derive(Deserialize, Debug)]
struct LogLine<'a> {
    level: &'a str,                    // borrows straight from the input
    #[serde(borrow)]
    message: Cow<'a, str>,             // borrowed unless escapes force owning
    latency_ms: u64,
}

fn main() {
    let raw = r#"{"level":"warn","message":"slow db","latency_ms":812}"#;
    let line: LogLine = serde_json::from_str(raw).unwrap();
    println!("{:?} borrowed={}", line, matches!(line.message, Cow::Borrowed(_)));
    // `line` cannot outlive `raw`: parse, process, drop.

    // bytes: split frames off a network buffer without copying payloads
    let mut buf = BytesMut::from(&b"\x00\x05hello\x00\x02hi"[..]);
    while buf.len() >= 2 {
        let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
        buf.advance(2);
        let frame = buf.split_to(len).freeze(); // Bytes sharing the allocation
        println!("frame: {:?}", std::str::from_utf8(&frame).unwrap());
    }
}

Key Points

  • Deserialize<'de> + &'a str/Cow borrows from the input buffer
  • Escapes force copies: Cow + serde(borrow) is the honest shape
  • Bytes/BytesMut share one allocation across frames (hyper/tonic native)
  • Apply at hot boundaries only; 'static storage ends the borrow anyway
Q57

How do the newtype and typestate patterns turn runtime bugs into compile errors, and why do Rust API designers lean on them so heavily?

AdvancedAPI Design

Answer

Both patterns weaponise the type system against entire bug categories. Newtype: wrap a primitive in a single-field tuple struct, struct UserId(u64), struct OrderId(u64), and the compiler now rejects passing an OrderId where a UserId belongs, eliminating the transposed-arguments bug that plagues (u64, u64) signatures; a Paise(u64) money type whose arithmetic you define (and whose Display renders rupees) prevents unit confusion the way NASA wishes Mars Climate Orbiter's software had. Newtypes are zero-cost, the wrapper compiles away entirely, and they are also the sanctioned orphan-rule workaround: you cannot impl Display for Vec<u8> (foreign trait, foreign type), but you can for struct Payload(Vec<u8>).

Validation-bearing newtypes go further: an Email type whose only constructor is TryFrom<&str> running real validation means every Email instance in the program is valid by construction, so functions taking Email need no defensive checks, 'parse, don't validate' as a design philosophy. Typestate encodes a state machine in the type parameter: Connection<Disconnected> and Connection<Connected> are different types, connect() consumes the former and returns the latter (self-by-value receivers are the enabling trick), and query() exists only on Connection<Connected>, so calling it before connecting is not a runtime panic but a method-does-not-exist compile error. Zero-sized marker types make this free at runtime, with PhantomData carrying the parameter when no field uses it.

Real-world sightings interviewers appreciate: embedded-hal GPIO pins (Pin<Output> vs Pin<Input>, so writing to an input pin cannot compile), builders whose build() only exists once required fields are set (each setter shifting a type parameter from Missing to Set), and protocol implementations where message ordering is enforced structurally. The honest limits: typestate multiplies types (documentation and error-message overhead), does not fit states decided at runtime (a connection that may drop needs runtime representation anyway), and past two or three states an enum plus runtime checks is often clearer; strong candidates present it as a scalpel for critical invariants, not a lifestyle.

use std::marker::PhantomData;

// Newtypes: IDs that cannot be swapped
#[derive(Debug, Clone, Copy, PartialEq)]
struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct OrderId(u64);

fn refund(user: UserId, order: OrderId) -> String {
    format!("refund order {order:?} for user {user:?}")
}

// Typestate: query() is unreachable before connect()
struct Disconnected;
struct Connected;

struct Conn<S> {
    addr: String,
    _state: PhantomData<S>,
}

impl Conn<Disconnected> {
    fn new(addr: impl Into<String>) -> Self {
        Conn { addr: addr.into(), _state: PhantomData }
    }
    fn connect(self) -> Conn<Connected> {         // consumes the old state
        Conn { addr: self.addr, _state: PhantomData }
    }
}

impl Conn<Connected> {
    fn query(&self, sql: &str) -> String {
        format!("[{}] {sql}", self.addr)
    }
}

fn main() {
    let user = UserId(7);
    let order = OrderId(7);
    // refund(order, user);        // compile error: mismatched types
    println!("{}", refund(user, order));

    let conn = Conn::new("db:5432");
    // conn.query("...");          // compile error: method not found
    let conn = conn.connect();
    println!("{}", conn.query("SELECT 1"));
}

Key Points

  • Newtypes: zero-cost distinct types kill argument-transposition bugs
  • Parse-don't-validate: valid-by-construction wrappers end defensive checks
  • Typestate: self-consuming transitions make wrong call orders unrepresentable
  • Know the limits: runtime-decided states still need enums
Q58

Rust to WebAssembly in production: wasm-bindgen and wasm-pack for the browser, wasm32-wasip1 for server-side, and what actually cannot cross the boundary.

AdvancedWebAssembly

Answer

Rust is the dominant source language for serious Wasm work because it needs no garbage collector or heavy runtime in the module. Two distinct targets matter. Browser: compile to wasm32-unknown-unknown, where wasm-bindgen generates the JS glue that Wasm's raw numeric ABI cannot express, #[wasm_bindgen] on functions and structs produces bindings that marshal strings, typed arrays, and JS objects across the boundary, and web-sys/js-sys expose Web APIs and JS builtins as typed Rust. wasm-pack wraps the workflow (wasm-pack build --target web) producing an npm-consumable package with TypeScript definitions.

The boundary is the performance story: every crossing pays marshalling cost (strings are copied in and out of Wasm linear memory), so the winning architecture is chunky calls, hand over a buffer, compute heavily inside, return a small result, and the losing one is chatty per-element calls, which can end up slower than plain JS. Real browser use cases: Figma's rendering engine is the canonical case study, image/video codecs, parsers, crypto, spreadsheet engines; the wrong use case is DOM-manipulating UI glue. Limits to name: no direct DOM access (everything routes through JS imports), threads require SharedArrayBuffer plus cross-origin-isolation headers and are still awkward, and binary size needs active management (opt-level = "z", lto, wasm-opt from binaryen; panic machinery and format! bloat modules fast).

Server-side: wasm32-wasip1 targets WASI, giving sandboxed filesystem/clock/random access; runtimes like wasmtime and wasmer execute modules with capability-based security and sub-millisecond cold starts, which is why edge platforms (Cloudflare Workers, Fastly Compute, Fermyon Spin) run Rust-compiled Wasm as their serverless substrate, cold-starting orders of magnitude faster than container-based FaaS. The component model and WASI's evolution toward composable, language-agnostic interfaces is the direction of travel in 2026, worth mentioning as awareness without overclaiming production maturity. Interview framing: Wasm questions are usually judgement questions, when the sandboxing/startup/portability wins justify the boundary costs, not syntax questions.

use wasm_bindgen::prelude::*;

// Chunky boundary: one call, heavy compute inside, small result out
#[wasm_bindgen]
pub fn score_resume(text: &str, keywords: Box<[JsValue]>) -> f64 {
    let lower = text.to_lowercase();
    let hits = keywords
        .iter()
        .filter_map(|k| k.as_string())
        .filter(|k| lower.contains(&k.to_lowercase()))
        .count();
    hits as f64 / keywords.len().max(1) as f64
}

#[wasm_bindgen]
pub struct Matcher {
    needle: String,
}

#[wasm_bindgen]
impl Matcher {
    #[wasm_bindgen(constructor)]
    pub fn new(needle: String) -> Matcher {
        Matcher { needle }
    }
    pub fn count(&self, haystack: &str) -> u32 {
        haystack.matches(&self.needle).count() as u32
    }
}

// Build:  wasm-pack build --target web --release
// Size:   [profile.release] opt-level = "z", lto = true; then wasm-opt -Oz
// JS:     const { score_resume } = await import('./pkg/matcher.js');

Key Points

  • wasm-bindgen marshals rich types; wasm-pack packages for npm
  • Chunky calls win; chatty boundary crossings lose to plain JS
  • Size discipline: opt-level z, lto, wasm-opt; panics bloat modules
  • wasm32-wasip1 + wasmtime = sandboxed sub-ms serverless (edge platforms)
Q59

What changed in the Rust 2024 edition and recent releases that interviewers expect you to know: async closures, let chains, precise capturing, and the new temporary-scope rules?

AdvancedLanguage Evolution

Answer

Edition 2024 shipped with Rust 1.85 (February 2025) and is the baseline new projects generate. The changes with interview weight: first, async closures, async || { ... }, stabilised in 1.85 alongside the AsyncFn/AsyncFnMut/AsyncFnOnce trait family; previously a closure returning an async block could not express lending its captures to the returned future, so higher-order async APIs (retry combinators, middleware) were contorted, and the new traits give them honest bounds. Second, let chains (1.88, edition 2024 only): if let Some(user) = lookup(id) && user.active && let Some(plan) = user.plan { ... } chains pattern bindings and boolean conditions in one if without the nested-if pyramid, and bindings flow left to right; rightward-drift code is now an anachronism reviewers flag.

Third, RPIT lifetime-capture defaults changed: in edition 2024, return-position impl Trait captures all in-scope lifetimes by default (matching what async fn always did), where edition 2021 captured only lifetimes named in the type; the migration surface is the use<'a, T> precise-capturing syntax (stabilised 1.82) that lets you declare exactly which generics and lifetimes the opaque type holds, the fix when a returned impl Iterator suddenly borrows more than intended and stops being 'static. Fourth, tighter temporary scopes: temporaries in if let and match scrutinee tails drop earlier in edition 2024, which quietly removes a real deadlock class, if let Ok(g) = mutex.lock() previously kept the guard alive through the else branch, and code relying on the old timing gets a lint plus cargo fix --edition rewrites. Fifth, explicitness upgrades: unsafe extern blocks, unsafe attributes (#[unsafe(no_mangle)]), and static mut references becoming hard errors push unsafety to the surface.

Also fair game as recent-stable features: C-string literals c"..." (1.77), inline const expressions (1.79), LazyLock (1.80), and #[expect(lint)] (1.81) which warns when the suppressed lint stops firing. If asked about the current version, the honest 2026 answer is the cadence: stable releases every six weeks, editions every three years, and cargo fix --edition making migration mechanical, so teams track stable closely rather than pinning ancient toolchains.

// Edition 2024 (rust 1.88+ for let chains)
struct User { active: bool, plan: Option<String> }
fn lookup(id: u32) -> Option<User> {
    (id == 1).then(|| User { active: true, plan: Some("gold".into()) })
}

// let chains: bindings + conditions, no pyramid
fn plan_of(id: u32) -> Option<String> {
    if let Some(user) = lookup(id)
        && user.active
        && let Some(plan) = user.plan
    {
        return Some(plan);
    }
    None
}

// Precise capturing (1.82): declare exactly what the RPIT holds
fn ids<'a>(names: &'a [String]) -> impl Iterator<Item = usize> + use<> {
    // use<> captures NOTHING: the iterator is 'static despite the &'a input
    (0..names.len()).collect::<Vec<_>>().into_iter()
}

#[tokio::main]
async fn main() {
    // async closures (1.85): AsyncFn bounds for higher-order async APIs
    let fetch = async |id: u32| -> Option<String> { plan_of(id) };
    println!("{:?}", fetch(1).await);

    let names = vec!["asha".to_string()];
    let it = ids(&names);
    drop(names);                 // fine: use<> promised no borrow
    println!("{:?}", it.collect::<Vec<_>>());
}

Key Points

  • 1.85/edition 2024: async closures + AsyncFn traits, unsafe extern/attrs
  • let chains (1.88) kill the nested-if pyramid
  • RPIT captures all lifetimes by default in 2024; use<> opts out precisely
  • Earlier scrutinee temporary drops remove an if-let guard deadlock class
Q60

A Rust service in production: what are the failure modes that actually page you at 3 AM, given that memory safety is already guaranteed?

AdvancedProduction

Answer

Rust deletes segfaults and data races from the pager, but the operational failure surface is alive and well, and senior interviews increasingly run on war-story diagnostics. Memory growth without a leak-in-the-C-sense: unbounded channels and queues absorbing a slow consumer (the OOM arrives hours after the stall), caches with no eviction, Vec buffers that grew to a burst's high-water mark and are retained forever (shrink_to_fit exists), Arc cycles never dropping (Weak breaks them), and allocator fragmentation on long-lived processes, where swapping in jemalloc also buys you its heap-profiling hooks; on Kubernetes the symptom is OOMKilled with exit code 137 and a flat 'leak' in your own metrics because RSS and allocator-held memory diverge. Panics: unwrap on a 'cannot fail' path fails during exactly the incident (a malformed message, a NaN, an empty Vec), and the blast radius depends on your panic policy, abort restarts the pod, unwinding in tokio kills one task, silently if nobody awaits the JoinHandle, so a panic-counting metric hooked via std::panic::set_hook is cheap insurance.

Async stalls: the blocking-call-on-a-worker and guard-across-await deadlocks from earlier questions, plus their subtler cousin, a future dropped by a timeout mid-write leaving a connection in a corrupt protocol state (cancellation safety again); tokio-console and long-poll detection are the instruments. Resource exhaustion: file descriptors (every hyper connection is one; ulimit and connection caps), connection-pool starvation where p99 explodes while CPU idles (pool wait-time metrics tell this story instantly), and disk from unbounded tracing output. Then the deployment-shaped failures: a release built with different feature flags than CI tested (feature unification strikes), overflow-checks differing between profiles changing arithmetic behaviour, and glibc-version mismatches for binaries copied between distros (musl static builds sidestep it).

The observability kit that answers all of these: tracing with per-request spans, RUST_BACKTRACE=full plus panic = "abort" with a crash-reporting wrapper or unwinding plus panic hooks, tokio-console in staging, jemalloc profiling, and p99/p999 dashboards per endpoint. The meta-answer interviewers reward: Rust moves failures from memory corruption (undebuggable, security-relevant) to logic and capacity (debuggable with instruments), which is precisely the trade you wanted.

Key Points

  • OOM via unbounded queues/caches/Arc cycles, not dangling pointers
  • Panics: count them with set_hook; unobserved JoinHandles hide them
  • Async stalls and cancellation corruption need tokio-console + spans
  • FD/pool exhaustion and feature-flag drift are the quiet killers
💡 Pro Tip: Bring one concrete war story to interviews: 'memory climbed for six hours then OOMKilled; heap profile showed an unbounded mpsc; we bounded it at 10k and added a queue-depth gauge' is worth more than any syntax answer.

Companies Hiring Rust

Juspay
Polygon
Cloudflare
Amazon
Microsoft
Google

Salary Insights

Average in India
₹12-35 LPA

Frequently Asked Questions

How much does a Rust developer earn in India in 2026?

The typical band is ₹12-35 LPA, noticeably above equivalent-seniority Java or Node roles because supply is thin. Freshers who can genuinely demonstrate ownership-model fluency enter around ₹8-14 LPA at product companies; three to five years of systems experience lands ₹20-35 LPA; and staff-level infrastructure or blockchain-protocol roles at places like Polygon, or remote positions with US/EU firms paying in dollars, go well past ₹50L equivalent. The highest-paying niches are payments infrastructure (Juspay's Hyperswitch ecosystem), trading systems, blockchain protocol engineering, and cloud infrastructure teams at Amazon, Microsoft, and Google India offices.

How long does it take to prepare for a Rust interview?

Coming from C++ or another systems language, six to eight weeks of focused work is realistic: two weeks on ownership/borrowing/lifetimes until E0382/E0502 errors feel readable, two on traits and generics, two on async and tokio, and the rest on tooling and building one real project. Coming from Python or JavaScript, budget three to four months, because the ownership model is a genuinely new mental model, not new syntax. The highest-leverage preparation is a small production-shaped project (an axum API with sqlx, tests, and tracing) plus reading compiler errors daily; interviewers can tell within minutes whether you have fought the borrow checker or only read about it.

Do Indian companies hire Rust freshers, or is it experienced-only?

Mostly experienced-first, but the fresher path exists and is widening. Companies rarely post 'Rust fresher' roles; instead they hire strong C++/systems freshers and train them, so the practical route is: demonstrate systems fundamentals (memory, concurrency, networking), contribute to visible Rust open source (Hyperswitch actively mentors contributors and is Indian-run, making it the single best signal for Indian applicants), and apply to blockchain and infrastructure startups where team-wide Rust adoption means they expect to train. For experienced engineers, one production Rust project plus deep knowledge of your current stack beats five toy projects; most Rust hires in India are internal transitions or lateral moves from C++/Go.

Is Rust worth learning in 2026 compared to sticking with Go or C++?

As a career bet, yes, with eyes open about market size. The Rust job pool in India is far smaller than Go's or Java's, but it is growing from real production adoption (Linux kernel, Windows internals, AWS infrastructure, Cloudflare's edge, most new blockchain protocols) rather than hype, and scarcity keeps salaries at a premium. Against Go: Go wins on hiring liquidity and ramp speed for standard backend services; Rust wins where performance ceilings, memory predictability (no GC pauses), or correctness guarantees matter, and knowing both is a strong combination. Against C++: Rust is increasingly the default for new systems code, and C++ engineers pick it up fastest, so for them it is nearly pure upside. Learn it for leverage in systems, infrastructure, and blockchain niches, not as a general-purpose CRUD credential.

Which Rust topics do interviewers actually spend the most time on?

Ownership, borrowing, and lifetimes dominate every loop, expect live coding where the interviewer watches you resolve real borrow-checker errors rather than recite rules. Second is concurrency: Send/Sync reasoning, Arc<Mutex<T>> patterns, and increasingly async Rust specifics like why blocking a tokio worker stalls unrelated requests and what holding a lock across .await does. Third is traits and API design: dyn vs generics trade-offs, error-handling architecture with thiserror/anyhow, and reading trait-bound errors. Production-oriented roles add tooling (clippy, cargo features, testing strategy) and war-story debugging. Pure algorithm rounds in Rust are rarer; when they occur, fluency with iterators, HashMap's entry API, and slices is what distinguishes idiomatic candidates from translated-from-Python ones.

What should be in my portfolio to get shortlisted for Rust roles in India?

Three things outperform everything else. First, merged pull requests to a recognised Rust project: Hyperswitch (Juspay) is the standout for Indian candidates because reviewers there are the exact people hiring, but tokio-ecosystem crates, rust-analyzer, or any protocol implementation carry weight; a merged PR proves you can pass someone else's code review in Rust. Second, one production-shaped service in a public repo: axum or actix-web, sqlx with migrations, proper error types, tracing, tests including integration tests, and a CI pipeline running clippy and fmt, hiring managers read the error-handling and test structure before anything else. Third, for blockchain roles specifically, anything demonstrating protocol-level work: a simplified consensus implementation, Solana program contributions, or zk-circuit tooling. A crates.io publication with real downloads, however small, rounds out the signal.

Introduction

Rust interviews are unlike interviews for any other mainstream language because the compiler itself is the topic. An interviewer at a company running Rust in production will hand you code that fails with E0382 (borrow of moved value) or E0502 (cannot borrow as mutable) and watch how you reason about ownership, not whether you have memorised syntax. In India the Rust hiring wave is concentrated in payments infrastructure (Juspay's open-source Hyperswitch payment switch is written entirely in Rust), blockchain engineering (Polygon's zero-knowledge proving stack), and the India offices of Cloudflare, Amazon, Microsoft, and Google, all of which now run Rust in latency-critical or security-critical paths.

Expect four broad areas in a serious Rust loop: the ownership and borrowing model including lifetimes and smart pointers, fearless concurrency (Send, Sync, Arc<Mutex<T>>, atomics) and async Rust on tokio, the cargo ecosystem (clippy, serde, thiserror, sqlx, criterion, Miri), and production judgement, meaning you can explain why holding a std::sync::Mutex guard across an .await point deadlocks a runtime, or why an unbounded mpsc channel is an OOM waiting to happen. Since async fn in traits stabilised in Rust 1.75 and the 2024 edition landed in 1.85, interviewers also probe whether your knowledge is current or frozen in 2021.

This guide contains 60 Rust interview questions ordered basic to advanced, each answered the way a working Rust engineer would answer in a real loop: concrete APIs, exact compiler error codes, cargo commands, and the production failure modes behind the question. The basic section rebuilds ownership from first principles, the intermediate section covers the traits, concurrency, and tooling questions that decide mid-level offers, and the advanced section goes where senior loops go: Pin, cancellation safety, unsafe contracts and Miri, FFI, memory ordering, and release-profile tuning. Work through the code samples in a scratch project; typing them against rustc teaches more than reading ever will.

Ready to practice Rust interviews?

Don't just read, practice these Rust questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview