C# vs Rust Interview Questions
Reviewed by Mark Dickie · Last updated
C# and Rust are both systems-leaning languages used for performance-critical software, but they differ sharply in memory model and ecosystem: C# runs on a managed runtime with garbage collection, while Rust enforces memory safety at compile time through its ownership and borrowing system. For interviews, the split matters because they test different muscles. C# questions tend to focus on the .NET runtime, LINQ, async/await patterns, and object-oriented design, while Rust questions drill into lifetimes, traits, and the borrow checker. Neither language is harder in a vacuum; the difficulty depends on what the role expects you to know and how deeply the interviewer probes the type system. A backend role at a .NET shop will lean C#, whereas infrastructure or systems roles increasingly default to Rust.
| Aspect | C# | Rust |
|---|---|---|
| Memory model | Garbage-collected, managed runtime | Ownership + borrow checker, no GC |
| Typical interview focus | .NET runtime, LINQ, async/await, OOP patterns | Lifetimes, traits, generics, concurrency safety |
| Common roles | Enterprise backend, game dev (Unity), Windows services | Systems programming, CLI tools, embedded, webAssembly |
| Learning curve for interviews | Easier if you know Java or C++; runtime abstractions feel familiar | Steeper due to borrow checker, but patterns become repetitive |
How to decide which to prepare for:
- Check the job description's stack. If it lists .NET, Entity Framework, or ASP.NET Core, that is a C# interview. If it mentions systems, embedded, or WebAssembly, expect Rust.
- Look at the team's domain. Enterprise and cloud-backend roles skew C#. Infrastructure, networking, and performance-engineering roles increasingly skew Rust.
- Assess your existing background. Java or C++ experience transfers quickly to C#. C or C++ with a tolerance for strict compilers transfers to Rust, though you will spend time learning lifetimes.
- If you must choose one, pick the language the job posts in its requirements rather than the one you find more interesting. Interviewers rarely bend on this.
Below you will find live interview questions drawn from both languages, plus a side-by-side comparison table built from real candidate attempt data so you can see where others struggle before you sit down to answer.
C# vs Rust, side by side
How C# and Rust compare on Tarmac’s interview questions.
| Metric | C# | Rust |
|---|---|---|
| Practice questions | 6 | 6 |
| Average score | — | — |
| Hardest question (% who miss it) | — | — |
| Average time per question | — | — |
Practice questions
In C#, CancellationToken.None is a static readonly token whose IsCancellationRequested property always returns false and whose CanBeCanceled property always returns false. It is safe to pass to any async method that accepts a CancellationToken.#
Options
- True
- False
Show answer
True
A CancellationToken is a lightweight value type that exposes read-only members like IsCancellationRequested and Register. Cancellation is triggered exclusively through its CancellationTokenSource (via Cancel, CancelAfter, or linking), never through the token itself.
You write let a = String::from("hi"); let b = a; println!("{}", a);. What does the compiler do?#
Options
- Compile error:
awas moved intob, so usingaafterward is a use-after-move - It compiles and prints
hi;let b = acopies the string - It compiles but prints garbage because
anow points to freed memory - Runtime panic:
ahas been dropped
Show answer
Reading a after let b = a is a compile error. String owns a heap buffer and is not Copy, so let b = a moves ownership to b and invalidates a; reading a afterward is a use-after-move that the borrow checker rejects before runtime ("borrow of moved value"). Copy types like i32 would duplicate instead. To keep both, use a.clone() or borrow with &a.
String owns a heap allocation and does not implement Copy, so let b = a is a move: ownership transfers to b and a is invalidated at compile time. Using a afterward is a use-after-move, and the borrow checker rejects it before the program ever runs — there is no runtime garbage or panic, just a compile error (borrow of moved value: a). Contrast a Copy type like i32, where let b = a duplicates the bits and both stay usable. To keep both String handles valid you must a.clone() (a deep copy) or borrow with &a. This compile-time move tracking is what lets Rust free each allocation exactly once without a garbage collector.
In C#, what is the recommended return type for an async method that performs work but does not produce a result value?#
Options
TaskvoidThreadint
Show answer
The recommended return type for an async method that does not produce a result is Task. Callers can await it to know when the work is done, whereas async void cannot be awaited and is discouraged outside event handlers because it complicates error handling.
An async method that completes without returning a value should return Task (or ValueTask). Callers can await it to know when the work is done. async void is discouraged outside event handlers because it cannot be awaited and makes exception propagation difficult. Thread and int are not valid async return types.
Inside fn load() -> Result<Config, io::Error>, you call let s = fs::read_to_string(path)?;. What does the ? operator do when read_to_string returns Err(e)?#
Options
- It returns early from
load, propagatingErr(e)(afterFromconversion) to the caller - It panics, unwinding the stack with the error message
- It logs the error and continues with a default empty string
- It retries
read_to_stringuntil it succeeds
Show answer
On Err(e), ? returns early from the enclosing function, propagating the error to the caller after converting it via the From trait into the function's declared error type. On Ok(v) it unwraps to v and continues. Unlike unwrap, ? never panics — it propagates. The From conversion is what lets one ? bridge many underlying error types into a single return error.
On Err(e), ? returns from the enclosing function immediately with that error, after converting it via the From trait into the function's declared error type — here both sides are io::Error, so no conversion is needed. On Ok(v) it unwraps to v and execution continues. ? is not unwrap: it never panics (that's option b's mistake); it propagates. The From conversion is the key feature — it lets you write ? across many underlying error types as long as your function's error type implements From for each, which is why Box<dyn Error> or a custom error enum with #[from] derives are common return types. ? only works in functions that return Result, Option, or another Try type.
In C#, a lock object used with the lock statement should be declared as a private field marked with the keyword _____ so that the reference cannot be reassigned after construction, which helps prevent deadlocks caused by external code changing the synchronization target.#
Show answer
In C#, a lock object used with the lock statement should be declared as a private field marked with the keyword **readonly** so that the reference cannot be reassigned after construction, which helps prevent deadlocks caused by external code changing the synchronization target.
Marking the lock object readonly guarantees the field reference is fixed for the object's lifetime. If the reference could be reassigned, different threads might lock on different objects and bypass mutual exclusion. The private modifier prevents external callers from locking on the same instance and causing contention or deadlock.
You have shared, reference-counted data that several threads must read. Why must you use Arc<T> rather than Rc<T> here?#
Options
Rcuses non-atomic reference counting and is notSend/Sync, so it can't cross threads;Arcupdates its count atomicallyRccan only hold one value, whileArccan hold manyArcis faster thanRcin all cases, so it's always preferredRcallocates on the stack andArcon the heap
Show answer
Rc<T> updates its reference count with non-atomic operations and is deliberately neither Send nor Sync, so the compiler forbids it across threads. Arc<T> uses atomic counting and is Send + Sync (when T is), so it compiles in threaded code. The atomics cost more, which is why single-threaded code keeps Rc. Arc only synchronizes the count — mutating the inner value still needs a Mutex.
Rc<T> updates its strong/weak counts with plain non-atomic integer operations, so concurrent clones/drops from multiple threads would race and corrupt the count — Rust prevents this statically by making Rc neither Send nor Sync, so the compiler rejects moving or sharing it across threads. Arc<T> (Atomically Reference Counted) uses atomic operations for the counts and is Send + Sync when T: Send + Sync, so it compiles in threaded code. The tradeoff is that atomics cost more than plain increments, which is exactly why Rc still exists — use it for single-threaded sharing and pay nothing for synchronization you don't need. Note Arc only makes the count thread-safe; mutating the inner T still needs a Mutex/RwLock.
What does the following C# code print to the console?#
int[] arr = { 1, 2, 3 };
List<int> list = new List<int>(arr);
list.Add(4);
arr[0] = 99;
Console.WriteLine(string.Join(",", arr));
Console.WriteLine(string.Join(",", list));Show answer
99,2,3
1,2,3,4
The List<int>(IEnumerable<int>) constructor copies each element from the source array into a new internal backing array, so the list is independent of arr. When arr[0] is set to 99 the list is unaffected. The first WriteLine prints the modified array (99,2,3), and the second prints the list with the appended 4 (1,2,3,4).
What is the key runtime difference between fn draw<T: Shape>(s: &T) (generic) and fn draw(s: &dyn Shape) (trait object)?#
Options
- The generic is monomorphized to static dispatch (no vtable); the trait object uses a vtable for dynamic dispatch at runtime
- They are identical after compilation;
dynis only a readability hint - The trait object is faster because it avoids generating multiple copies of the function
- The generic version uses a vtable; the trait object is inlined
Show answer
A generic fn draw<T: Shape> is monomorphized — the compiler emits a concrete copy per type and dispatches statically, so calls can be inlined with no runtime indirection. &dyn Shape is a trait object: a fat pointer carrying a vtable, and methods are resolved through that vtable at runtime (dynamic dispatch). Static dispatch is faster but can bloat the binary; dynamic dispatch keeps one copy and allows heterogeneous collections.
A generic fn draw<T: Shape> is monomorphized: the compiler stamps out a separate, concrete copy of draw for each T it's called with, and each call dispatches statically — the exact method is known at compile time, so it can be inlined, with zero runtime indirection. &dyn Shape is a trait object: a fat pointer (data pointer + vtable pointer) where the method is looked up through the vtable at runtime (dynamic dispatch). Static dispatch is typically faster per-call and inlinable but can bloat binary size with many monomorphized copies; dynamic dispatch keeps one copy and lets you store heterogeneous types (e.g. Vec<Box<dyn Shape>>) at the cost of an indirect call and lost inlining. Choosing between them is a classic Rust performance-vs-flexibility tradeoff.
A Task is created with new Task(Action), then started with .Start() on the default thread-pool TaskScheduler, and runs to successful completion. Place its TaskStatus values in the order they occur.#
Put these in order
- Created
- WaitingToRun
- Running
- RanToCompletion
Show answer
A Task created with new Task(Action) and started via Start() on the default thread-pool scheduler transitions through four statuses in order: Created → WaitingToRun → Running → RanToCompletion. The task starts in Created, is queued to the thread pool (WaitingToRun) when Start is called, enters Running when the delegate begins executing, and becomes RanToCompletion upon successful completion.
A Task constructed with new Task(...) begins in the Created status. Calling Start() schedules it with the default scheduler, transitioning it to WaitingToRun. When the thread pool picks it up and the delegate begins executing, the status becomes Running. Upon successful completion the status becomes RanToCompletion. These transitions are documented and sequential — the task passes through each state exactly once in this order.
Send means a type can be moved to another thread; Sync means &T can be shared across threads. Which of these types can safely be sent or shared across threads as written? Select all that apply.#
Options
Arc<i32>— sent to another threadRc<i32>— sent to another threadMutex<Vec<u8>>— shared by reference across threadsRefCell<i32>— shared by reference across threadsi32— sent to another thread
Show answer
Arc<i32> (Send + Sync), plain i32 (Send), and Mutex<Vec<u8>> (Sync, so shareable by reference) are all safe across threads. Rc<i32> is deliberately not Send because its count is non-atomic, and RefCell<i32> is not Sync because its borrow flags aren't atomic — sharing either across threads is a compile error. Send/Sync are auto-derived marker traits the compiler uses to rule out data races.
Arc<i32> is Send + Sync because its count is atomic, so it can move to another thread. Plain i32 is Send (and Sync), so it crosses freely. Mutex<Vec<u8>> is Sync when its contents are Send, so &Mutex<...> can be shared across threads — that's the whole point of a mutex. Rc<i32> is explicitly not Send: its non-atomic count would race, so it cannot be sent to another thread, so sending it is wrong. RefCell<i32> is Send but not Sync: its runtime borrow flags aren't atomic, so a shared &RefCell across threads could double-mutably-borrow undetected — Rust forbids it, so sharing it is wrong. These two marker traits are auto-derived structurally, and the borrow checker uses them to make data races a compile error rather than a runtime bug.
In C#, which statement correctly describes how a Dictionary<TKey, TValue> stores its entries?#
Options
- Each key must be unique; adding a duplicate key throws an ArgumentException.
- Duplicate keys are allowed; the latest value overwrites the previous one silently.
- Keys are sorted automatically in insertion order.
- A key can appear multiple times, and all values are stored in a list per key.
Show answer
In a C# Dictionary<TKey, TValue>, each key must be unique. Calling Add with a key that already exists throws an ArgumentException; to overwrite you use the indexer assignment dict[key] = value instead. Duplicate keys are never allowed.
A Dictionary<TKey, TValue> enforces key uniqueness by hash code comparison. Calling Add with a key that already exists throws an ArgumentException. To silently overwrite, you use the indexer (dict[key] = value) instead of Add. The dictionary does not maintain sort or insertion order (prior to .NET-specific ordered variants), and it does not store multiple values per key — that would be ILookup<TKey, TValue> or Dictionary<TKey, List<TValue>>.
Rust iterator adapters like .map() and .filter() are lazy: calling v.iter().map(f).filter(g) does no work until a consuming operation (such as .collect(), .sum(), or a for loop) drives the chain.#
Options
- True
- False
Show answer
True. Iterator adapters like map and filter are lazy — each returns a new iterator wrapping the previous one and touches no element until a consumer (collect, sum, for, etc.) drives the chain. A chain with no consumer does nothing, and the compiler warns about it. Laziness is what makes adapter chains zero-cost: the compiler fuses them into one tight loop with no intermediate allocations.
True. Adapters such as map, filter, take, and enumerate are lazy — each just returns a new iterator struct that wraps the previous one; no element is touched until a consumer pulls values through. Consumers include for loops and methods like collect, sum, count, fold, and for_each. A direct consequence: a chain of adapters with no consumer is a no-op (the compiler even warns iterators are lazy and do nothing unless consumed). Laziness is also what makes adapter chains a 'zero-cost abstraction' — the compiler fuses the whole chain into a single tight loop with no intermediate collections, so iter().map().filter().sum() allocates nothing in between and is as fast as a hand-written loop.