15 selected Rust backend interview questions grouped by seniority level. Use them to review fundamentals, practical trade-offs, and senior-level production reasoning.
1Explain Rust's ownership rules and how they shape practical backend choices such as cloning, borrowing, and sharing application state.
Rust gives each value a single owner. Moving a non-`Copy` value transfers ownership, and the value is dropped when its owner goes out of scope. Code can either borrow data immutably through shared references or mutably through an exclusive mutable reference. In backend code, this shapes whether a function takes ownership, borrows temporarily, clones to obtain an independent owned value, or shares long-lived state through handles such as `Arc`, with synchronization or other concurrency primitives when shared mutation is needed.
use std::sync::Arc;
struct AppState {
service_name: String,
}
async fn handler(state: Arc<AppState>, request_id: String) -> String {
// Borrow: no ownership transfer of the String inside state.
let name: &str = &state.service_name;
// Clone only if an owned independent value is needed.
let owned_name = name.to_owned();
format!("{owned_name}:{request_id}")
}
fn configure(state: Arc<AppState>) {
// Cloning Arc increments the reference count; it does not clone AppState.
let state_for_route = Arc::clone(&state);
let _ = state_for_route;
}
2What is the difference between borrowing with &T and &mut T, and how do these rules affect API and handler signatures?
`&T` is a shared reference: it allows read-only access and many shared references to the same value may be active at once. `&mut T` is an exclusive mutable reference: it allows mutation, but while it is active no other shared or mutable references to the same value may be used. These rules shape APIs by making read-only functions accept `&T` or narrower borrowed types such as `&str`, while mutating functions accept `&mut T`, take ownership, or use interior mutability/synchronization. Backend handlers usually avoid plain `&mut` access to shared application state across concurrent requests and instead use shared handles plus synchronization or other ownership patterns.
struct User {
name: String,
login_count: u64,
}
fn display_name(user: &User) -> &str {
&user.name
}
fn record_login(user: &mut User) {
user.login_count += 1;
}
fn main() {
let mut user = User { name: "Ada".into(), login_count: 0 };
let name = display_name(&user); // shared borrow for reading
println!("{name}");
record_login(&mut user); // exclusive mutable borrow for mutation
}
3How does Box work, and what problems does heap allocation via Box solve?
`Box<T>` is an owning smart pointer whose `T` is stored on the heap while the `Box` handle is a fixed-size pointer-like value. It has single ownership and drops/frees the heap allocation when it goes out of scope. `Box` provides indirection, which helps with large values, recursive types that need a known size, dynamically sized values, and trait objects such as `Box<dyn Trait>`. Moving a `Box` transfers ownership of the allocation without moving or copying the heap-stored value itself.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Describe Result and Option and the primary idiomatic ways to work with absence and fallible operations.
`Option<T>` represents either `Some(T)` or `None` and is used for expected absence of a value. `Result<T, E>` represents either `Ok(T)` or `Err(E)` and is used for fallible operations where error information matters. Idiomatic handling includes `match`, `if let`/`let else`, combinators such as `map`, `and_then`, `ok_or`, `unwrap_or`, and `unwrap_or_else`, and the `?` operator for early propagation from functions returning compatible `Option` or `Result` types. `unwrap` and `expect` are best reserved for invariants, tests, prototypes, or unrecoverable situations, not normal recoverable backend errors.
5How does the ? operator work, and what enables conversion between different error types?
The `?` operator is shorthand for propagating failure. For `Result`, `expr?` yields the `Ok` value and continues, or returns early from the current function with an `Err`; for `Option`, it yields the `Some` value or returns `None`. The enclosing function must return a compatible type. For `Result`, different error types can compose because the source error is converted into the function's error type using `From`/`Into`-style conversion, often implemented manually or derived with helper crates such as `thiserror`.
use std::{fs, io, num::ParseIntError};
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn read_number(path: &str) -> Result<i32, AppError> {
let s = fs::read_to_string(path)?; // io::Error -> AppError
let n = s.trim().parse::<i32>()?; // ParseIntError -> AppError
Ok(n)
}
6Explain pattern matching on enums and how exhaustive matching improves domain and API correctness.
Enums model a value that can be one of a fixed set of variants, and variants may carry data. `match` branches by variant and can destructure the data inside. Rust checks that enum matches are exhaustive unless a wildcard or catch-all pattern is used. This improves domain and API correctness because when a new state or response variant is added, the compiler can force code that matches on it to decide how to handle the new case instead of silently ignoring it.
7What are newtype wrappers, and how do they improve type safety for IDs, money, tokens, and secrets?
A newtype wrapper is a distinct Rust type, often a one-field tuple struct, around an existing representation, such as `struct UserId(Uuid);` or `struct AccessToken(String);`. It improves type safety because the compiler will not confuse semantically different values that share the same underlying type, such as `UserId` versus `OrderId`, cents versus dollars, or raw strings versus validated tokens. Newtypes can also centralize construction and validation rules and control traits such as `Display`, `Debug`, `Serialize`, or redaction behavior for secrets.
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);
struct SecretToken(String);
impl std::fmt::Debug for SecretToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretToken(**redacted**)")
}
}
fn load_user(id: UserId) {
// query by user id
}
fn example(user_id: UserId, order_id: OrderId) {
load_user(user_id);
// load_user(order_id); // does not compile: expected UserId, found OrderId
}
8How does the Drop trait work, including drop order, RAII cleanup, and limitations around async cleanup?
`Drop` is Rust’s deterministic cleanup hook. When an owned value is destroyed, such as when it goes out of scope, Rust automatically calls its `drop(&mut self)` method if it implements `Drop`, then drops its fields. This supports RAII: resources like files, sockets, locks, transactions, permits, or buffers are tied to owning values/guards and are released when those values are dropped. Local variables are dropped in reverse order of creation; for a struct with `Drop`, the struct’s `drop` method runs before its fields are dropped, and fields are dropped in declaration order. `Drop::drop` is synchronous and cannot be `async` or awaited, so graceful async cleanup usually needs an explicit async close/shutdown/flush method or another design; `Drop` can only do synchronous or best-effort cleanup.
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
struct Pair {
first: Guard,
second: Guard,
}
impl Drop for Pair {
fn drop(&mut self) {
println!("drop Pair");
}
}
fn main() {
let _a = Guard("a");
let _pair = Pair {
first: Guard("first"),
second: Guard("second"),
};
let _b = Guard("b");
}
9Explain lifetimes and why explicit lifetime annotations are sometimes required in Rust APIs.
A lifetime describes how long a reference is valid, allowing the compiler to reject dangling references. Lifetime annotations are compile-time constraints that express relationships between references; they do not extend the lifetime of the underlying data or add runtime behavior. Many simple signatures are handled by lifetime elision, but explicit annotations are needed when the compiler cannot infer how input and output references relate, when types store references, or when an API needs to express generic borrowing constraints.
fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
struct UserView<'a> {
name: &'a str,
}
fn main() {
let a = String::from("short");
let b = String::from("longer");
let result = longest(&a, &b);
let view = UserView { name: result };
println!("{}", view.name);
}
10What does the 'static lifetime mean, and how does it interact with spawned tasks and backend API design?
`'static` on a reference means the referenced data is valid for the entire program, as with string literals or `static` items. A bound like `T: 'static` means values of type `T` do not contain non-`'static` borrowed references, so they can be held for an arbitrary lifetime; it does not mean the value itself must live forever or cannot be dropped. Spawned tasks often require `'static` futures because the executor may keep running them after the spawning stack frame has returned. In backend code, this usually means moving owned data into the task with `async move`, using owned types, or cloning shared handles such as `Arc`, rather than borrowing local variables. APIs should use `'static` bounds for stored callbacks, background jobs, and long-lived state, but avoid unnecessary `'static` bounds for short-lived borrowed operations.
use std::sync::Arc;
struct AppState {
name: String,
}
fn spawn_background(state: Arc<AppState>, user_id: String) {
tokio::spawn(async move {
// The task owns an Arc handle and a String, so it does not borrow this function's stack.
println!("{}:{user_id}", state.name);
});
}
11Describe how Rust uses traits for polymorphism, and compare impl Trait, generics, and dyn Trait.
Rust uses traits to express shared behavior. Generic bounds such as `fn f<T: Trait>(x: T)` usually use static dispatch through monomorphization. Argument-position `impl Trait` is mostly shorthand for an anonymous generic parameter. Return-position `impl Trait` hides one concrete return type chosen by the function. `dyn Trait` is a trait object used behind a pointer such as `&dyn Trait` or `Box<dyn Trait>`; it uses dynamic dispatch through a vtable and supports runtime type erasure/heterogeneous values subject to object-safety restrictions.
12What are associated types on traits, and when are they preferable to generic type parameters?
Associated types are named type placeholders declared inside a trait and specified by each implementation, for example `Iterator::Item`. They are preferable when the type is part of the trait's contract and each implementor has one natural choice for it. Generic type parameters are preferable when the caller should choose the type or when the same implementor may need multiple implementations for different type choices.
13How would you implement the transactional outbox pattern in a Rust service?
To implement the Transactional Outbox pattern in a Rust service, write domain data updates and outgoing event payloads atomically within a single database transaction (e.g., using `sqlx::Transaction`). An `outbox` table stores the destination topic, payload, and status or creation timestamp. A background asynchronous worker (or a Change Data Capture process like Debezium) then polls or streams pending events and publishes them to the message broker (such as Kafka, RabbitMQ, or NATS). When using a polling worker in Rust (e.g., in a `tokio::spawn` loop), using `SELECT ... FOR UPDATE SKIP LOCKED` allows multiple service instances to fetch and dispatch outbox rows concurrently without lock contention. Once broker delivery is acknowledged, the worker updates the outbox record status or deletes the row. Because network retries can result in at-least-once delivery, downstream consumers must be designed to handle messages idempotently.
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub async fn create_user_with_outbox(
mut tx: Transaction<'_, Postgres>,
username: &str,
email: &str,
) -> Result<(), sqlx::Error> {
let user_id = Uuid::new_v4();
sqlx::query!(
"INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
user_id, username, email
)
.execute(&mut *tx)
.await?;
let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
sqlx::query!(
"INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
Uuid::new_v4(),
"user_events",
payload
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn run_outbox_relay(pool: PgPool) {
loop {
let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
let rows = sqlx::query!(
"SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
)
.fetch_all(&mut *tx)
.await;
if let Ok(events) = rows {
for event in events {
// publish_to_broker(&event.topic, &event.payload).await;
let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
.execute(&mut *tx)
.await;
}
let _ = tx.commit().await;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
14How would you design a multi-step operation that updates a database and calls an external API?
Designing a multi-step operation that spans a database write and an external API call requires managing partial failures and network partitions without relying on two-phase commit (2PC). 1. **Persist State / Intent First**: Record an initial state (such as `PENDING` or `INITIATED`) and the required payload inside the local database transaction. Commit this transaction before initiating the external network call to avoid holding database locks and connection pool slots during slow network I/O. 2. **Idempotent External Request**: Call the external API using a deterministic idempotency key (e.g., derived from the operation's UUID). This ensures repeated attempts do not cause duplicate real-world side effects (such as double charging). 3. **Transition State & Handle Timeouts**: If the API call returns a successful response, update the database record to `COMPLETED`. If it fails permanently, mark it `FAILED` and execute compensating actions. If the call times out or returns a network error, mark the record as `INDETERMINATE` / `REQUIRES_RECONCILIATION` and let a background worker reconcile the state by querying the provider's status endpoint or retrying with the same idempotency key.
use uuid::Uuid;
pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
let payment_id = Uuid::new_v4();
let idempotency_key = format!("pay_{}", payment_id);
// 1. Commit intent locally before making external network calls
sqlx::query!(
"INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
payment_id, user_id, amount, idempotency_key
)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// 2. Call external API with timeout and idempotency key
let client = reqwest::Client::new();
let api_result = client.post("https://api.payment.com/v1/charges")
.header("Idempotency-Key", &idempotency_key)
.json(&serde_json::json!({ "amount": amount }))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
// 3. Update state based on outcome; handle timeouts via reconciliation
match api_result {
Ok(resp) if resp.status().is_success() => {
sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Ok(_) => {
sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Err(_) => {
sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
15How would you design a multi-tenant Rust backend with strict data isolation and dynamic routing?
Designing a multi-tenant Rust backend involves three key areas: tenant identification, data isolation strategy, and dynamic routing. 1. **Tenant Extraction & Context Propagation**: An HTTP middleware (e.g., in Axum or Actix-web) resolves the tenant identity from JWT claims, subdomains, or headers. It constructs a strongly typed `TenantContext` stored in request extensions. Handlers use Rust type extractors to enforce that business logic cannot execute without a valid tenant context. 2. **Data Isolation Strategy**: - *Shared Database with Column / Row-Level Security (RLS)*: All tenants share tables with a `tenant_id` column. Isolation is enforced via PostgreSQL RLS using connection-level session variables (e.g., `SET LOCAL app.tenant_id = $1`) or query builder wrappers. - *Schema-per-Tenant*: Tenants share a database instance but have isolated schemas; routing adjusts the search path per connection. - *Database-per-Tenant*: Tenants have separate databases. The service maintains a pool registry (e.g., `Arc<DashMap<TenantId, PgPool>>` or an LRU pool cache) to dynamically resolve connection pools. 3. **Leak Prevention**: To avoid cross-tenant contamination in asynchronous Tokio applications, tenant context must be passed explicitly across task boundaries rather than stored in global state or thread-local storage (`thread_local!`), which breaks across async worker thread migrations.
use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);
#[derive(Clone)]
pub struct AppState {
pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}
#[derive(Clone)]
pub struct TenantContext {
pub tenant_id: TenantId,
pub pool: PgPool,
}
#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
type Rejection = (axum::http::StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let tenant_header = parts.headers.get("X-Tenant-ID")
.and_then(|v| v.to_str().ok())
.ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
let tenant_id = TenantId(tenant_header.to_string());
let pool = match state.pool_registry.get(&tenant_id) {
Some(p) => p.value().clone(),
None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
};
Ok(TenantContext { tenant_id, pool })
}
}