C# Interview Questions: Practice with Real Questions

Reviewed by Mark Dickie · Last updated

12 questions on this page you can answer and see marked on the spot. Go to the first one

C# is a statically typed, object-oriented programming language that runs on the .NET runtime and supports garbage collection, generics, and async programming. For a C# interview, you need a firm grasp of the type system (value vs. reference types, boxing), collection interfaces, LINQ query and method syntax, async/await mechanics, and common design patterns. Most interviews also test delegates and events, plus exception handling and garbage collection semantics.

What topics does a C# interview typically cover?

AreaWhat gets tested
Type systemValue vs. reference types, boxing/unboxing, nullable value types
GenericsConstraints, covariance/contravariance, type inference
Async/awaitTask, async/await, ConfigureAwait, cancellation tokens
LINQDeferred execution, IEnumerable vs. IQueryable, method vs. query syntax
Delegates & eventsMulticast delegates, Action/Func, the event pattern
CollectionsList<T>, Dictionary<TKey, TValue>, HashSet<T>, ICollection<T>
Exception handlingCustom exceptions, try/catch/finally, exception filters
Memory & GCManaged heap, generations, IDisposable, using statements

How should you prepare for C# interview questions?

  1. Review the difference between value types and reference types, including how each behaves when passed to a method.
  2. Practice writing LINQ queries in both method syntax and query syntax, and understand when execution is deferred.
  3. Study async/await internals: how Task works, what ConfigureAwait(false) does, and how to handle cancellation tokens.
  4. Brush up on generics, especially the distinction between in (contravariant) and out (covariant) generic type parameters.
  5. Work through delegate and event examples, including multicast delegates and the INotifyPropertyChanged pattern.
  6. Read up on the garbage collector: how generations work, when finalizers run, and why IDisposable exists alongside GC-managed memory.

What mistakes do candidates make in C# interviews?

Common missteps include confusing String and string (they are the same type), mixing up async void and async Task (use async Task except in event handlers), and assuming var makes the code dynamically typed. It does not; var is compile-time inferred and fully statically typed. Another frequent error is misunderstanding deferred execution in LINQ, where a query re-evaluates each time you enumerate it unless you materialize the results with ToList() or ToArray().

Key facts

  • Tarmac has 124 C# interview questions on this topic, 25 of them on this page, at difficulty 1–5 of 5.
  • Tarmac tracked 721 job postings asking for C# in September 2026.
  • Roles asking for C# advertise a median base salary of £70,000, across 305 job postings as of September 2026.
  • Tarmac last reviewed these C# interview questions on 20 October 2026.

At a glance

Questions25 shown · 124 in the bank
Difficulty1–5 of 5
FormatsTrue / false, Multiple choice, Code output, Ordering, Find the bug, Short answer, Fill in the blank, Multiple answer, Flashcard

What you'll review

  1. cancellation tokens
  2. async await fundamentals
  3. arrays and lists
  4. task based asynchrony
  5. thread safety in csharp
  6. dictionaries and sets

Practice questions

Try one before you open the answer. Pick an option and press Check; it's marked on the spot.

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

Show answer

CancellationToken.None is a static readonly token whose IsCancellationRequested and CanBeCanceled properties both always return false, and it is safe to pass to any async method that accepts a CancellationToken. A CancellationToken only reports cancellation state; cancellation itself is triggered through its associated CancellationTokenSource, never through the token. Since CancellationToken.None has no backing source that can ever be canceled, it remains permanently inactive.

Why:

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.

In C#, what does the await keyword do when applied to an awaited Task?#

Options

Show answer

await suspends the async method at that point and yields control back to the caller, without blocking the current thread. When the awaited Task completes, execution resumes where it left off. It does not block, spawn a new thread, or auto-cancel the task on error.

Why:

await suspends the enclosing async method at that point and returns control to the caller, allowing the thread to do other work. When the awaited Task completes, the method resumes. It does not block the calling thread, does not create a new thread, and does not auto-cancel on exception.

In C#, which keyword must a method declaration include if the method body uses the await keyword?#

Options

Show answer

A method must be declared with the async modifier to use the await keyword inside its body. Without async, the compiler produces an error when await is encountered. The other keywords (yield, lock, parallel) do not enable await.

Why:

The await keyword can only be used inside a method marked async. Without the async modifier on the method declaration, the compiler will raise an error (CS4033) when it encounters await. yield, lock, and parallel are unrelated to enabling await.

What is the exact output of the following C# code?#

int[] numbers = { 1, 2, 3 };
List<int> list = new List<int>(numbers);
list.Add(4);
Console.WriteLine(list.Count + " " + numbers.Length);
Show answer
4 3
Why:

The List<int> is constructed from a copy of the array's elements, so adding 4 to the list does not change the original array. After Add, list.Count is 4 (three copied elements plus the added one), while numbers.Length remains 3 because arrays have a fixed length and the array itself was never modified. The concatenated string printed is therefore "4 3".

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

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.

Why:

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.

This code launches 1,000 tasks that each call Increment() on a shared Counter, then prints the final value. The expected output is 1000, but under concurrent execution the result is often less. Find the line that contains the thread-safety bug.#

using System;
using System.Threading.Tasks;

class Counter
{
    private int _count = 0;

    public void Increment()
    {
        _count++;
    }

    public int Value => _count;
}

class Program
{
    static void Main()
    {
        var counter = new Counter();
        var tasks = new Task[1000];
        for (int i = 0; i < 1000; i++)
            tasks[i] = Task.Run(() => counter.Increment());
        Task.WaitAll(tasks);
        Console.WriteLine(counter.Value);
    }
}
Show answer

The bug is on line 10.

Why:

Line 10 (_count++;) is not an atomic operation — it reads _count, adds 1, and writes it back. When multiple tasks execute concurrently, their read-modify-write sequences interleave and lost updates occur, so the final value is often less than 1000. The fix is Interlocked.Increment(ref _count);, which performs the increment as a single atomic hardware-level operation.

Given the following C# console application, order the lines exactly as they appear in standard output. Assume a console app with no SynchronizationContext.#

Put these in order

Show answer

The output order is A, C, D, B. Because Task.CompletedTask is already finished, awaiting it does not suspend execution — MyMethodAsync runs synchronously to completion. Main prints A first, then MyMethodAsync prints C and D, and finally Main prints B after its await returns.

Why:

Main prints "A" first. It then calls MyMethodAsync(), which prints "C" synchronously. The await Task.CompletedTask does not suspend because the task is already complete — the continuation runs inline, so "D" is printed next and MyMethodAsync returns an already-completed Task. Control returns to Main, whose await also completes synchronously, and "B" is printed last. The full output is: A, C, D, B.

This code runs 1,000 concurrent tasks that each call AddOrUpdate on a shared Cache. Under concurrent load the program throws exceptions or corrupts internal state. Which line contains the bug?#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class Cache
{
    private Dictionary<string, int> _data = new Dictionary<string, int>();

    public void AddOrUpdate(string key, int value)
    {
        if (_data.ContainsKey(key))
            _data[key] = value;
        else
            _data.Add(key, value);
    }
}

class Program
{
    static void Main()
    {
        var cache = new Cache();
        var tasks = new Task[1000];
        for (int i = 0; i < 1000; i++)
            tasks[i] = Task.Run(() => cache.AddOrUpdate("k" + i % 5, i));
        Task.WaitAll(tasks);
    }
}

Options

Show answer

Line 6 — Dictionary<string, int> is not thread-safe for concurrent access; it should be ConcurrentDictionary<string, int>.

Why:

The root bug is on line 6: Dictionary<string, int> provides no thread-safety guarantees. When multiple threads call AddOrUpdate concurrently, the dictionary's internal buckets can be corrupted or it can throw an InvalidOperationException ('Operations that change non-concurrent collections must have exclusive access'). Replacing it with ConcurrentDictionary<string, int> resolves the issue because that type is designed for concurrent reads and writes. Replacing ContainsKey with TryGetValue is wrong because switching to TryGetValue alone does not make the collection thread-safe. The indexer assignment is wrong because atomicity of the indexer assignment is not the core issue — the collection type itself is unsafe for concurrent use. Replacing Add with TryAdd is wrong because the code already guards Add with a ContainsKey check; duplicates are not the primary problem under concurrent access.

In C#, when you call ThrowIfCancellationRequested() on a CancellationToken whose IsCancellationRequested is true, which exact exception type is thrown? (Include the full namespace-qualified type name.)#

Show answer

System.OperationCanceledException

Why:

CancellationToken.ThrowIfCancellationRequested() checks IsCancellationRequested and, if true, throws an OperationCanceledException. The full type name is System.OperationCanceledException. Note that TaskCanceledException (in System.Threading.Tasks) derives from OperationCanceledException, but ThrowIfCancellationRequested itself throws the base OperationCanceledException, not TaskCanceledException.

In C#, which method on a CancellationTokenSource instance do you call to signal that cancellation has been requested, causing every CancellationToken created from that source to transition IsCancellationRequested to true?#

Show answer

You call the Cancel() method on the CancellationTokenSource instance.

Why:

CancellationTokenSource.Cancel() initiates cancellation: it sets the source's internal flag so that every CancellationToken it produced reports IsCancellationRequested == true, invokes any registered callbacks, and completes any linked token sources.

In C#, you have two existing CancellationToken values, tokenA and tokenB. You need a new CancellationToken that is cancelled when EITHER tokenA OR tokenB is cancelled. What static factory method on CancellationTokenSource do you call, and what arguments do you pass?#

Show answer

Call CancellationTokenSource.CreateLinkedTokenSource(tokenA, tokenB), then read its .Token property. Pass tokenA and tokenB as the two arguments.

Why:

CancellationTokenSource.CreateLinkedTokenSource(params CancellationToken[] tokens) creates a source whose token is cancelled when any of the supplied tokens is cancelled. You pass tokenA and tokenB as arguments and then use the .Token property of the returned source.

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.

Why:

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.

The _____ class in the System.Threading namespace provides lightweight atomic operations such as Increment, Decrement, Add, and CompareExchange for thread-safe manipulation of shared numeric fields without an explicit lock.#

Show answer

The **Interlocked** class in the System.Threading namespace provides lightweight atomic operations such as Increment, Decrement, Add, and CompareExchange for thread-safe manipulation of shared numeric fields without an explicit lock.

Why:

System.Threading.Interlocked exposes static methods that perform hardware-level atomic read-modify-write operations. Using Interlocked.Increment(ref counter) is more efficient than wrapping counter++ in a lock because it avoids a full monitor acquisition and works directly on the memory location.

The generic collection type _____<TKey, TValue> in the System.Collections.Concurrent namespace is the thread-safe counterpart to Dictionary<TKey, TValue>, exposing methods such as TryAdd, GetOrAdd, and AddOrUpdate that can be called safely from multiple threads concurrently without external locking.#

Show answer

The generic collection type **ConcurrentDictionary**<TKey, TValue> in the System.Collections.Concurrent namespace is the thread-safe counterpart to Dictionary<TKey, TValue>, exposing methods such as TryAdd, GetOrAdd, and AddOrUpdate that can be called safely from multiple threads concurrently without external locking.

Why:

ConcurrentDictionary<TKey, TValue> uses fine-grained locking (striped locks) internally so that reads are lock-free and writes only lock the relevant bucket. It is the recommended replacement for Dictionary<TKey, TValue> when multiple threads may read and write the collection simultaneously without the caller managing synchronization.

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
Why:

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 exact console output of the following C# code?#

var dict = new Dictionary<string, int>();
dict["apple"] = 3;
dict["apple"] = 5;
dict.Add("banana", 2);

Console.WriteLine(dict["apple"]);
Console.WriteLine(dict.Count);

if (dict.TryGetValue("cherry", out int val))
    Console.WriteLine(val);
else
    Console.WriteLine("not found");
Show answer
5
2
not found
Why:

Using the indexer to assign dict["apple"] = 3 inserts the key with value 3. The second assignment dict["apple"] = 5 overwrites the existing value because the indexer updates an existing key rather than throwing. dict.Add("banana", 2) adds a second entry, so dict.Count is 2. TryGetValue("cherry", out int val) returns false because "cherry" is not a key in the dictionary, so the else branch prints "not found".

Which of the following statements about async void methods in C# are true?#

Options

Pick every one that applies.

Show answer

The true statements are: an async void method's caller cannot await it because it returns void (not Task), and async void is the recommended pattern for event handlers since their delegate signatures require void. Exceptions inside async void methods are not caught at the call site — they propagate to the SynchronizationContext and can crash the process. No Task object is returned for the caller to inspect.

Why:

An async void method returns void, so the caller cannot await it or inspect a returned Task (the caller of an async void method has no way to await its completion because the method returns void, not Task is true, an async void method produces a Task object internally that the caller can inspect to track progress is false). async void is the recommended pattern for event handlers precisely because event delegate signatures require a void return type (async void is the recommended signature for event handlers in C# because event delegate signatures return void is true). Exceptions thrown inside an async void method are posted to the ambient SynchronizationContext (or rethrown on the thread pool if none exists), crashing the process if unhandled — they are NOT caught by a try/catch at the call site because the method has already returned void before the exception surfaces (an exception thrown inside an async void method can be caught by a try/catch block that surrounds the call site is false).

In C# (modern .NET), consider a class library method that will be called from both ASP.NET Core and WPF applications. Which of the following statements about ConfigureAwait(false) are true?#

Options

Pick every one that applies.

Show answer

ConfigureAwait(false) prevents the awaiter from capturing the current SynchronizationContext, so the continuation may run on a thread pool thread instead of being marshaled back. This can prevent deadlocks when a caller blocks with .Result on a single-threaded context like the WPF Dispatcher. It does not change the task's return value or logical outcome — only where the continuation executes.

Why:

ConfigureAwait(false) tells the awaiter to skip capturing the current SynchronizationContext/TaskScheduler, so the continuation is not marshaled back to the original context (the statement that it instructs the awaiter not to capture and post the continuation back to the current SynchronizationContext or TaskScheduler is true). Without the captured context, the continuation runs on whatever thread the task completes on — typically a thread pool thread (the statement that after await task.ConfigureAwait(false), the continuation may execute on a thread pool thread even if a SynchronizationContext was present before the await is true). When a caller on a single-threaded SynchronizationContext (e.g., the WPF UI thread) blocks with .Result, the continuation would need that same thread to run, causing a deadlock; ConfigureAwait(false) breaks this cycle by allowing the continuation to run elsewhere (the statement that it can help prevent deadlocks that arise when a caller blocks on the result while a single-threaded SynchronizationContext is active is true). ConfigureAwait(false) only affects where the continuation runs, not what the task returns — the logical result is unchanged (the claim that it alters the return value or the logical outcome of the awaited Task is false).

Which of the following statements about how the C# compiler and runtime handle async/await are true? Assume modern C# (.NET 8).#

Options

Pick every one that applies.

Show answer

The C# compiler transforms each async method into a state machine that records the current position so execution can resume at the correct point after every await. When an awaited Task is already completed at the moment await is evaluated, the awaiter calls GetResult immediately and runs the continuation synchronously, scheduling nothing on the thread pool. Local variables that must survive across an await boundary—such as response, which is read after the awaited call—are hoisted off the stack into fields on the generated state machine, since the original stack frame no longer exists when the continuation resumes.

Why:

The compiler rewrites each async method into a state machine (a struct or class) that tracks the suspension/resumption point after every await (the statement that the compiler transforms the async method into a state machine that records the current position so execution can resume at the correct point after each await is true). When the awaited task's IsCompleted is already true, the awaiter calls GetResult immediately without scheduling a continuation, so execution proceeds synchronously (the statement that when an awaited Task is already completed at the time await is evaluated, the continuation runs synchronously and no continuation is scheduled on the thread pool is true). Task.Delay(0) returns an already-completed task (Task.CompletedTask), so awaiting it completes synchronously and does NOT yield — Task.Yield() is the construct that always yields (the statement that await Task.Delay(0) always yields control back to the caller before continuing is false). Local variables that must survive across an await (like response, which is used on line 4 after the await on lines 2–3) are lifted from the stack into fields on the state machine object, since the original stack frame is gone when the continuation resumes (the statement that local variables that are read or written after an await boundary (such as response above) are hoisted into fields of the generated state machine is true).

When CancellationTokenSource.Cancel() is invoked, how are callbacks registered via CancellationToken.Register() dispatched — on which thread, in what order, and what happens if a callback throws an exception?#

Show answer

By default Cancel() calls Cancel(throwOnFirstException: false). All registered callbacks run synchronously on the thread that called Cancel, in reverse order of registration (last-registered first). When throwOnFirstException is false, exceptions from individual callbacks are caught and collected; after all callbacks have run, they are re-thrown as an AggregateException (or the single exception if only one was thrown). When throwOnFirstException is true, the first exception propagates immediately and remaining callbacks do not execute.

Why:

This is staff-level internal knowledge: the reverse-order, synchronous, exception-collecting dispatch model of registered callbacks is the reason cancellation can introduce reentrancy and deadlocks if a callback blocks or acquires locks held by the canceling thread. Understanding the throwOnFirstException overload distinguishes surface API familiarity from deep runtime semantics.

When you await a Task that has already completed by the time the awaiter is obtained (i.e., awaiter.IsCompleted is true), the C# compiler-generated state machine calls GetResult() synchronously and the continuation runs inline on the current thread without posting to or capturing any SynchronizationContext or TaskScheduler.#

Options

Show answer

True. When the awaited task is already complete, awaiter.IsCompleted is true, so the generated state machine skips the suspension path entirely — it calls GetResult() inline on the current thread and never captures or posts to any SynchronizationContext or TaskScheduler. Context capture only happens on the suspension path.

Why:

The code the compiler emits for every await begins by checking awaiter.IsCompleted. When the task is already finished, control falls straight through to awaiter.GetResult() on the current thread — no OnCompleted/UnsafeOnCompleted callback is registered, and no SynchronizationContext or TaskScheduler is captured or posted to. Context capture only occurs on the suspension path, which is never entered when IsCompleted is true. This is why awaiting an already-completed task inside a UI thread keeps you on the UI thread even without ConfigureAwait(false).

Starting in .NET 6, what does CancellationTokenSource.TryReset() do, what allocation problem does it solve in high-throughput async loops, and how does it interact with CancelAfter timers?#

Show answer

TryReset() transitions an already-cancelled CancellationTokenSource back to a non-cancelled state, returning true on success so the same CTS instance can be reused for another cancellation cycle without allocating a new one. This eliminates per-iteration CTS allocations in hot paths (e.g., a server loop that cancels and restarts work per request), reducing GC pressure. It returns false if the source has been disposed. Registered callbacks from the prior cancellation are cleared. Any pending CancelAfter timer is stopped; you must call CancelAfter again to re-arm a timer after TryReset.

Why:

Understanding TryReset requires knowing the internal lifecycle of a CancellationTokenSource — that it is reusable, that prior registrations are cleaned up, and that the internal CancelAfter timer is a separate concern that must be explicitly re-armed. This is well beyond surface-level cancellation API usage and sits at the intersection of performance engineering and async internals.

An unhandled exception thrown inside an async void method is posted to the SynchronizationContext that was current when the method was invoked (or rethrown on a ThreadPool thread if no SynchronizationContext was captured), and cannot be caught by any try-catch block in the method's caller.#

Options

Show answer

True. AsyncVoidMethodBuilder.SetException posts the exception to the SynchronizationContext captured at invocation (or rethrows on a ThreadPool thread if none was captured). Because async void returns to the caller before the body completes, the caller's try-catch has already unwound and cannot catch the exception.

Why:

AsyncVoidMethodBuilder.SetException is called whenever an exception propagates out of the state machine (whether before, during, or after an await). That method checks whether a SynchronizationContext was captured at invocation time; if one exists, it calls Post() to marshal the exception back onto that context, otherwise it calls Environment.FailFast/ThreadPool.QueueUserWorkItem-based rethrow via ThrowAsync. Because async void returns to the caller before the body finishes, the caller's try-catch frame has already unwound by the time the exception surfaces, making it uncatchable by the caller. This is the core reason async void is discouraged outside event-handler contexts.

When you create a linked source via CancellationTokenSource.CreateLinkedTokenSource(token1, token2) and then call Cancel() on the linked source, what happens to the original source tokens? And why must you Dispose() the linked source?#

Show answer

Calling Cancel() on the linked source flips its own token to cancelled but does not cancel token1 or token2 — the link is one-directional (original → linked), not the reverse. Internally the linked source registers a cancellation-forwarding callback on each source token. You must Dispose() the linked source to unregister those callbacks; otherwise, if the source tokens are long-lived, those registrations (and the references they hold) persist for the lifetime of the source tokens, creating a silent leak.

Why:

This is a common pitfall at scale: developers assume CreateLinkedTokenSource is a lightweight composition, but it allocates callback registrations on every source token. Forgetting to dispose the linked source when combining a request-scoped token with a process-wide timeout token means the request-scoped CTS is kept alive (and its internal structures retained) long after the request completes. The reverse direction (linked → original) is never wired, so calling Cancel on the linked source is a local-only operation.

Related interview questions

Job market

See csharp salaries and hiring demand from live job postings.

The other 99 questions

This page shows 25 and marks what you pick. That's as far as a page can go. A free account opens the other 99 and keeps every answer. What you miss comes back until it's right: after a day, then at longer gaps.

Start with this topic

Free · the whole bank · 100 marked answers per 30 days · written feedback on the paid plan

Interview booked? Practise the job ad instead

What moved, monthly

One email a month when the bulletin comes out: what moved in the markets we track, and the new question topics we published. Confirm your address to join. Unsubscribe any time.