Skip to main content
C# · lesson 26 of 26
C# advanced Lesson 26 of 26

C# Interview Preparation

35 C# interview questions answered, with runnable proof for the tricky ones: boxing, override vs new, == vs Equals, deadlocks, and lost updates.

By SkillByExample · Updated

Core Language

These questions test your understanding of C#‘s type system and memory model. They come up in almost every interview because they reveal whether you understand what the runtime is actually doing, not just the syntax.

Q1: What is the difference between value types and reference types?

Value types (structs, enums, primitives) store their data directly where the variable lives — on the stack for locals, inline in the containing object on the heap. Assignment copies the value. Reference types (classes, interfaces, arrays) store a reference (pointer) to heap-allocated data. Assignment copies the reference; both variables point to the same object.

int a = 5;
int b = a;
b = 10;
Console.WriteLine(a);

var p1 = new PointStruct { X = 1 };
var p2 = p1;
p2.X = 99;
Console.WriteLine(p1.X);

var list1 = new List<int>();
var list2 = list1;
list2.Add(1);
Console.WriteLine(list1.Count);

struct PointStruct { public int X; }
$ dotnet run
5
1
1

The int and the struct were copied on assignment, so changing the copy left the original alone. The list variables hold references to the same list, so adding through list2 is visible through list1. A good follow-up answer: “on the stack” is a detail — a struct that is a field of a class lives on the heap inside that object; what defines a value type is copy semantics.


Q2: What is boxing and unboxing?

Boxing wraps a value type in a heap-allocated object. Unboxing extracts it back. Both incur a heap allocation and a copy, causing GC pressure in hot paths. The fix is generics — List<int> stores ints directly without boxing, while ArrayList boxes every int.

using System.Collections;

int n = 42;
object boxed = n;
n = 7;
Console.WriteLine($"{boxed} {n}");
Console.WriteLine((int)boxed + 1);

try { long wrong = (long)boxed; }
catch (InvalidCastException ex) { Console.WriteLine(ex.Message); }

long before = GC.GetAllocatedBytesForCurrentThread();
var bad = new ArrayList(1000);
for (int i = 0; i < 1000; i++) bad.Add(i);
Console.WriteLine($"ArrayList: {GC.GetAllocatedBytesForCurrentThread() - before:N0} bytes");

before = GC.GetAllocatedBytesForCurrentThread();
var good = new List<int>(1000);
for (int i = 0; i < 1000; i++) good.Add(i);
Console.WriteLine($"List<int>: {GC.GetAllocatedBytesForCurrentThread() - before:N0} bytes");
$ dotnet run
42 7
43
Unable to cast object of type 'System.Int32' to type 'System.Int64'.
ArrayList: 32,056 bytes
List<int>: 4,056 bytes

The box holds a copy: changing n afterwards did not change boxed. Unboxing must name the exact type — (long)boxed throws even though an int fits in a long; you would write (long)(int)boxed. The allocation numbers show the cost: the ArrayList boxed every int into its own 24-byte object, while List<int> stored them inline in one array.


Q3: What is the difference between const and readonly?

const is a compile-time constant baked into the IL — only primitives and strings are allowed. readonly is set at runtime (at declaration or in the constructor) and can hold any type. const is implicitly static; readonly can be instance or static. If you change a const in a library, all assemblies that reference it must be recompiled; readonly does not have this problem.


Q4: What is the difference between string and StringBuilder?

string is immutable — every operation creates a new string on the heap. StringBuilder is a mutable buffer that appends to an internal array and only allocates the final string at the end. For building strings in a loop, StringBuilder is O(N) while + concatenation in a loop is O(N²) due to repeated allocations and copies.


Q5: Explain nullable reference types.

With <Nullable>enable</Nullable> in the project file, the compiler tracks whether reference types can be null. string means non-null; string? means nullable. The compiler emits warnings when a nullable value is dereferenced without a null check. This shifts null reference bugs from runtime crashes to compile-time warnings. The null-forgiving operator ! suppresses a warning when you know the value cannot be null despite what the compiler thinks.


Object-Oriented Programming

OOP questions test whether you understand the principles behind C#‘s class system — not just syntax, but when to use each feature and why.

Q6: What is the difference between abstract class and interface?

An abstract class can have state, constructors, concrete methods, and protected members. A class can only inherit one abstract class. An interface (pre-C# 8) defines a contract with no implementation. A class can implement many interfaces. Use an abstract class when you have shared implementation in a type hierarchy; use an interface to define a contract that unrelated types can fulfill.


Q7: What is the difference between override and new on a method?

override replaces the virtual method in the polymorphic dispatch table — a base-typed reference calling the method will invoke the derived version. new hides the base method at compile time without replacing it in the dispatch table — a base-typed reference still calls the base version. Almost always use override; new leads to confusing behavior.

Base b = new Derived();
b.Overridden();
b.Hidden();
((Derived)b).Hidden();

class Base
{
    public virtual void Overridden() => Console.WriteLine("Base.Overridden");
    public void Hidden() => Console.WriteLine("Base.Hidden");
}

class Derived : Base
{
    public override void Overridden() => Console.WriteLine("Derived.Overridden");
    public new void Hidden() => Console.WriteLine("Derived.Hidden");
}
$ dotnet run
Derived.Overridden
Base.Hidden
Derived.Hidden

One object, two behaviours: the overridden method follows the object’s runtime type, while the hidden one follows the variable’s compile-time type — Base.Hidden through a Base reference, Derived.Hidden after a cast.


Q8: What does sealed do?

On a class, sealed prevents inheritance. On a virtual method, it prevents further overriding in subclasses. The JIT compiler can de-virtualize calls to sealed methods — it knows there is no subclass to dispatch to, so it can inline the call and eliminate the virtual dispatch overhead entirely.


Q9: Explain the Liskov Substitution Principle.

A subtype must be substitutable for its base type without altering the correctness of the program. In practice: a derived class should honor the contracts of its base class — it should not strengthen preconditions (demand more from callers), weaken postconditions (deliver less), or throw exceptions the base contract does not allow. Violating LSP means your inheritance hierarchy is modeling something other than an “is-a” relationship.


Q10: What is the difference between method overloading and method overriding?

Overloading defines multiple methods with the same name but different parameter signatures in the same class — the compiler resolves which one to call at compile time based on argument types. Overriding provides a new implementation for a virtual method declared in a base class — the runtime resolves which one to call at runtime based on the object’s actual type (polymorphism).


Generics and Collections

Q11: Why are generics better than object?

Generics provide compile-time type safety without casting, which catches type errors at compile time rather than runtime. For value types, they also eliminate boxing — List<int> stores ints directly in a contiguous array, while ArrayList boxes every int into a separate heap-allocated object, producing GC pressure and slower access.


Q12: What are generic constraints?

Constraints restrict which types are valid for a type parameter, enabling you to call methods or use operators on the type parameter. Common ones:

  • where T : class — reference types only
  • where T : struct — value types only
  • where T : new() — must have a parameterless constructor (enables new T())
  • where T : IComparable<T> — must implement the interface (enables .CompareTo)

Q13: What is covariance and contravariance?

Covariance (out T) allows a more-derived type to be used where a base type is expected — IEnumerable<Dog> can be assigned to IEnumerable<Animal> because you only read from it. Contravariance (in T) is the reverse — Action<Animal> can be assigned to Action<Dog> because you only write to it. Both only apply to interfaces and delegates, and only to reference types.


Q14: When would you use Dictionary vs List for lookup?

Dictionary<K,V> has O(1) average lookup by key using a hash table. List<T>.Contains and List<T>.Find are O(N) linear scans. Use Dictionary when you need to look up elements by a key repeatedly. Use List when you need ordered, index-based access, or when the collection is small enough that linear scan is acceptable.


LINQ and Functional Patterns

Q15: What is deferred execution in LINQ?

Most LINQ operators (Where, Select, OrderBy) do not execute when called — they build a query description (a chain of delegates). Execution happens when you enumerate the result with foreach, ToList, Count, First, etc. This means the query reflects the current state of the data at the time of iteration, not when it was defined. It also means a query over an IQueryable<T> (like EF Core) is not sent to the database until you materialize it.


Q16: What is the difference between IEnumerable<T> and IQueryable<T>?

IEnumerable<T> processes data in memory using delegates — it pulls all data from the source first and filters/projects in C#. IQueryable<T> holds an expression tree that a query provider (like EF Core) translates into a server-side query (SQL). Always use IQueryable<T> with EF Core so filters, projections, and ordering are applied at the database, not after loading all rows into memory.


Async and Concurrency

Q17: How does async/await work internally?

The C# compiler transforms an async method into a state machine struct. Each await becomes a suspension point with a state number. When the awaited operation completes, the runtime calls MoveNext() on the state machine, which resumes execution from the correct point — potentially on a different thread depending on the SynchronizationContext. No thread is blocked while I/O is in flight.


Q18: What is the difference between Task.Run and async/await?

Task.Run offloads CPU-bound work to a thread pool thread — use it when you have computation that would block the calling thread. async/await is for I/O-bound work — it releases the current thread while waiting for a network response, file read, or database query. Using Task.Run for I/O wastes a thread; using async/await for pure CPU computation just adds state machine overhead without freeing anything.


Q19: What causes a deadlock with async code?

Calling .Result or .Wait() on a Task in an environment that has a SynchronizationContext (WinForms, ASP.NET classic) causes a deadlock: the calling thread holds the context and blocks waiting for the Task to finish; the Task’s continuation needs the same context to resume but cannot acquire it because the calling thread is blocked. The fix is to use await all the way up the call chain.

using System.Collections.Concurrent;

var ctx = new SingleThreadContext();
Console.WriteLine($"blocking with .Wait(): completed = {ctx.Run(() => GetDataAsync(false).Wait(1000))}");
Console.WriteLine($"ConfigureAwait(false): completed = {ctx.Run(() => GetDataAsync(true).Wait(1000))}");

static async Task<string> GetDataAsync(bool configureAwait)
{
    await Task.Delay(100).ConfigureAwait(!configureAwait);
    return "data";
}

class SingleThreadContext : SynchronizationContext
{
    private readonly BlockingCollection<(SendOrPostCallback, object?)> _queue = new();

    public SingleThreadContext() =>
        new Thread(() => { SetSynchronizationContext(this); foreach (var (cb, s) in _queue.GetConsumingEnumerable()) cb(s); })
        { IsBackground = true }.Start();

    public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));

    public T Run<T>(Func<T> f)
    {
        var tcs = new TaskCompletionSource<T>();
        Post(_ => tcs.SetResult(f()), null);
        return tcs.Task.Result;
    }
}
$ dotnet run
blocking with .Wait(): completed = False
ConfigureAwait(false): completed = True

SingleThreadContext imitates a UI thread or classic ASP.NET: one thread runs every continuation. In the first run that thread blocks in .Wait (.Result behaves the same), while the await inside GetDataAsync has queued its continuation to that same thread — neither can proceed, so the one-second timeout expires (without a timeout it would hang forever). With ConfigureAwait(false) the continuation runs on the thread pool instead, and the task completes. The real fix is to await all the way up rather than block.


Q20: What is ConfigureAwait(false)?

It tells the awaiter not to capture the current SynchronizationContext when scheduling the continuation. In library code, this prevents deadlocks when callers block on async code (as above) and avoids a small overhead from context marshaling. In ASP.NET Core, there is no SynchronizationContext, so it makes no functional difference — but it remains good practice in libraries to be safe when consumed from other frameworks.


Q21: What is CancellationToken used for?

It enables cooperative cancellation of async operations. The caller creates a CancellationTokenSource, passes the Token to async methods, and calls Cancel() to signal that the result is no longer needed. The operation checks token.IsCancellationRequested or calls token.ThrowIfCancellationRequested() at safe points. Well-designed async APIs accept a CancellationToken parameter (defaulting to default) so callers can always opt in to cancellation.


Memory and Performance

Q22: How does the .NET garbage collector work?

The GC is a generational, tracing collector. New objects start in Gen 0. Objects that survive a Gen 0 collection are promoted to Gen 1, and from there to Gen 2. Gen 0 is collected frequently and quickly; Gen 2 is collected rarely but is expensive. Large objects (>85KB) go directly to the Large Object Heap. The GC uses mark-and-sweep: it traces all reachable objects from GC roots (stack variables, statics, handles), marks them as live, then reclaims the rest.


Q23: What is IDisposable and when should I implement it?

IDisposable.Dispose() is the standard pattern for deterministic cleanup of resources that should not wait for the GC — file handles, database connections, network sockets, and any object that holds another IDisposable. Implement it when your class owns a resource that needs to be released promptly. Always call Dispose via using so it runs even if an exception is thrown. For classes with unmanaged resources, also implement a finalizer as a safety net.


Q24: What is Span<T> and when should I use it?

Span<T> is a stack-only ref struct that represents a contiguous slice of memory — a string, an array, or a stack-allocated buffer — without allocating. Use it to slice strings or arrays without copying, to parse data in tight loops, or to work with stack-allocated buffers. Its stack-only restriction means it cannot be stored in a field or used across await boundaries — use Memory<T> for those cases.


Design and Patterns

Q25: What is dependency injection and why use it?

Dependency injection is a pattern where a class receives its dependencies from an external source (constructor parameters) rather than creating them itself. This makes classes loosely coupled — they depend on abstractions (interfaces), not concrete implementations. The benefits are testability (swap real implementations for fakes in tests), configurability (change implementations in one place), and lifetime management (the DI container controls when objects are created and disposed). The .NET DI container in Microsoft.Extensions.DependencyInjection resolves and injects dependencies automatically.


Q26: What is the Repository pattern?

Repository abstracts the data access layer behind an interface so the rest of the application never depends on EF Core, Dapper, or any specific database technology. A service depends on IOrderRepository; the concrete implementation (EfOrderRepository) lives in an infrastructure layer. This makes business logic independently testable with an in-memory fake, and makes it possible to swap the database without changing any service code.


Q27: What is the difference between eager loading, lazy loading, and explicit loading in EF Core?

  • Eager loading (Include): loads related data in a single query using a SQL JOIN — the safest default.
  • Lazy loading: loads related data automatically on first property access, using proxies. Convenient but dangerous in loops — each access fires a separate query, causing the N+1 problem.
  • Explicit loading: you manually call Entry(entity).Collection(...).LoadAsync() to load specific navigation properties when you decide you need them.

Prefer eager loading for known navigation requirements and avoid lazy loading in APIs or any code path that iterates collections.


C# Language Features

Q28: What are records?

Records (C# 9) are compiler-generated classes or structs with value-based equality, a generated ToString, non-destructive mutation via with expressions, and deconstruction. Two records with the same property values are equal regardless of reference. They are ideal for DTOs, command/query objects, domain events, and any immutable data structure where equality should be based on content rather than identity.


Q29: What are expression-bodied members?

Syntactic shorthand using => for members whose body is a single expression: methods, properties, constructors, finalizers, and operators. public int Square(int n) => n * n; compiles to exactly the same IL as the block-bodied version. They improve readability for simple members but can obscure intent in complex ones — use them when the expression is self-explanatory.


Q30: What is pattern matching?

A set of C# language features for testing values and destructuring them in a single expression. Key patterns include: type patterns (is string s), relational patterns (> 0), property patterns ({ Name: "Alice" }), positional patterns for records and tuples, list patterns ([1, 2, ..]), and switch expressions. They replace cascading null checks, if/else if type chains, and manual property extraction with concise, readable code.


Q31: What are local functions?

Methods defined inside another method, visible only within the enclosing scope. They can close over the outer method’s variables and are useful for recursive helpers (where a lambda cannot be directly recursive) or for naming a multi-step operation that only makes sense within one method. Mark them static when they don’t need to capture outer variables — this prevents accidental captures and can enable inlining.


Q32: What is the difference between Task<T> and ValueTask<T>?

Task<T> always allocates a new object on the heap, even when the result is already available synchronously. ValueTask<T> is a struct that avoids this allocation when the result is available synchronously — it stores either the result value directly or a reference to the underlying Task<T>. Use ValueTask<T> in hot-path code where the synchronous path is common (cache lookups, already-completed operations). Do not await a ValueTask<T> more than once.


Q33: What are source generators?

Source generators are Roslyn-based compile-time tools that inspect your code’s syntax and semantic model and emit additional C# source files as part of compilation. They enable zero-overhead, reflection-free implementations of features that would otherwise use runtime reflection: System.Text.Json source generation for serialization, [GeneratedRegex] for compiled regexes, and incremental DI registration. They produce code that is compiled directly into your assembly.


Q34: What is the difference between == and Equals?

For value types, both compare content by default. For reference types, == compares identity (same reference) unless overloaded; Equals can be overridden to compare content — which string and record both do. When you override Equals, you must also override GetHashCode to maintain the contract that equal objects have equal hash codes. Consider also overloading == for value-like types.


string s1 = "hello";
string s2 = new string("hello".ToCharArray());
object o1 = s1, o2 = s2;

Console.WriteLine(s1 == s2);
Console.WriteLine(o1 == o2);
Console.WriteLine(o1.Equals(o2));
Console.WriteLine(ReferenceEquals(s1, s2));
$ dotnet run
True
False
True
False

This is the classic trick question. string overloads == to compare contents, so the first line is True. But operators are chosen at compile time from the static type: typed as object, the same two strings are compared by reference and == says False. Equals is a virtual method, so it still dispatches to string.Equals and compares contents.


Q35: How would you implement a thread-safe counter?

const int N = 1_000_000;
int unsafeCount = 0, atomicCount = 0, lockedCount = 0;
var gate = new object();

Parallel.For(0, N, _ => unsafeCount++);
Parallel.For(0, N, _ => Interlocked.Increment(ref atomicCount));
Parallel.For(0, N, _ => { lock (gate) lockedCount++; });

Console.WriteLine($"plain ++:    {unsafeCount:N0}");
Console.WriteLine($"Interlocked: {atomicCount:N0}");
Console.WriteLine($"lock:        {lockedCount:N0}");
$ dotnet run
plain ++:    557,952
Interlocked: 1,000,000
lock:        1,000,000

count++ is three steps — read, add, write — so two threads can read the same value and one increment is lost. The plain counter came up short, and by a different amount on every run. Interlocked.Increment makes the read-add-write a single atomic CPU instruction and is the fastest correct option for one variable; lock is slower but is what you need when several fields must change together. For a value computed once, Lazy<T> is the thread-safe tool.

Practice

  1. Predict, then run. What does this print? Answer before running it.
var nums = new List<int> { 1, 2, 3 };
var query = nums.Where(n => n > 1);
nums.Add(4);
Console.WriteLine(query.Count());

var captured = new List<Func<int>>();
for (int i = 0; i < 3; i++) captured.Add(() => i);
Console.WriteLine(string.Join(",", captured.Select(f => f())));
Solution
$ dotnet run
3
3,3,3

3, because the Where is deferred and sees the 4 (Q15). Then 3,3,3: all three lambdas share the one loop variable i, whose final value is 3.

  1. Explain the output. Why does the second line differ from the first?
decimal d = 0.1m + 0.2m;
double f = 0.1 + 0.2;
Console.WriteLine(d == 0.3m);
Console.WriteLine(f == 0.3);
Console.WriteLine(f.ToString("R"));
Solution
$ dotnet run
True
False
0.30000000000000004

decimal stores base-10 digits, so 0.1 and 0.2 are exact. double is binary floating point, where 0.1 has no exact representation; the sum is 0.30000000000000004. Use decimal for money, and compare doubles with a tolerance.

  1. Fix the race without a lock. Rewrite unsafeCount++ from Q35 so it stays correct but each thread only touches shared state once.
Solution
const int N = 1_000_000;
int total = 0;

Parallel.For(0, N,
    () => 0,
    (_, _, local) => local + 1,
    local => Interlocked.Add(ref total, local));

Console.WriteLine(total.ToString("N0"));
$ dotnet run
1,000,000

The thread-local overload of Parallel.For gives each worker its own counter and merges them once at the end, so contention drops from a million atomic operations to one per worker.

Frequently Asked Questions

What topics are most commonly asked in C# interviews?
Expect questions on value vs reference types, boxing/unboxing, async/await, LINQ, generics, interfaces vs abstract classes, memory management (GC, Dispose pattern), and OOP principles. Senior roles also ask about performance optimization and design patterns.
How deep should I know .NET internals for an interview?
Most roles expect a solid understanding of the CLR, GC generations, and JIT compilation at a conceptual level — not at the implementation level. Know enough to explain why something behaves a certain way and what the performance implications are.
How should I practise for a C# coding interview?
Run the small programs behind the classic questions yourself instead of memorising answers: watch a value-type copy, a boxed value, a lost update in a racing counter, or an async deadlock happen. Interviewers probe with follow-ups such as 'what would print if...', and having seen the output is what lets you answer those confidently.