The 60 C# questions interviewers actually ask, with direct answers, runnable code, and what the key signal is. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
C# is a statically typed, object-oriented programming language created by Microsoft and first released in 2000, designed to run on the .NET runtime. It's compiled to an intermediate language that a just-in-time compiler turns into machine code, and it ships with garbage collection, a large class library, and features like LINQ, async/await, generics, and pattern matching. According to Microsoft's official tour of the C# language, the language sits in the C family and emphasizes type safety, component-oriented design, and versioning. In interviews, C# questions probe the type system (value versus reference types, boxing, nullability), concurrency (async/await, the thread pool), and how .NET manages memory, rather than memorized trivia. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, Microsoft's C# documentation is the canonical path; increasingly the first C# round runs as a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: C# Tutorial - Full Course for Beginners
Video: C# Tutorial - Full Course for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your C# certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
C# is a statically typed, object-oriented language from Microsoft that runs on the .NET runtime. It's compiled to an intermediate language, garbage collected, and ships with a large class library plus features like LINQ, async/await, generics, and pattern matching that make everyday code concise.
It covers a wide range of work with one toolchain: web APIs and sites with ASP.NET Core, desktop apps, cross-platform mobile with .NET MAUI, cloud services, and games through Unity. That breadth is why teams standardize on it.
Key point: A one-line definition plus two concrete use cases (ASP.NET Core APIs, Unity games) beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: C# Tutorial For Beginners - Learn C# Basics in 1 Hour (Programming with Mosh, YouTube)
Value types (int, double, bool, struct, enum) hold their data directly and are copied on assignment, so each variable owns its own copy. Reference types (class, string, array, delegate) hold a reference to data on the managed heap, so assigning copies the reference, not the object it points at.
The practical effect: changing a copied struct doesn't affect the original, but two variables pointing at the same object see each other's mutations. This one distinction explains a large share of C# bugs and interview follow-ups.
struct Point { public int X; }
class Box { public int X; }
var p1 = new Point { X = 1 };
var p2 = p1; // copy
p2.X = 9;
// p1.X == 1, p2.X == 9
var b1 = new Box { X = 1 };
var b2 = b1; // same object
b2.X = 9;
// b1.X == 9, b2.X == 9| Value type | Reference type | |
|---|---|---|
| Examples | int, struct, enum, bool | class, string, array, delegate |
| Assignment | Copies the value | Copies the reference |
| Default | Zero value (0, false) | null |
| Stored | Stack or inline | Managed heap |
Key point: The follow-up is usually 'where do they live in memory?'. Say value types can live on the stack or inline in a heap object, and don't over-claim that structs are always on the stack.
Watch a deeper explanation
Video: Where are types allocated in .NET and why people get it so wrong (Nick Chapsas, YouTube)
A class is a reference type; a struct is a value type. Structs are copied on assignment, can't inherit from other structs or classes (they only implement interfaces), and are a good fit for small, immutable, data-only values like a coordinate or a money amount.
Use a class for most objects with identity and behavior. Reach for a struct when the type is small, logically a single value, and copying is cheap, because a large struct copied often costs more than a reference.
Key point: the question needs the guideline, not dogma: small, immutable, value-like data leans struct; everything else leans class.
Boxing converts a value type to object (or to an interface it implements), which wraps it and allocates a copy on the managed heap. Unboxing casts that object back to the value type. Both cost an allocation or a copy plus a runtime type check, so they add up in hot loops.
This matters for performance: storing many ints in a non-generic collection boxes each one. Generics (List<int>) exist largely to avoid boxing, which is why generic collections replaced the old ArrayList.
int n = 42;
object boxed = n; // boxing: heap allocation
int back = (int)boxed; // unboxing: cast back
// avoids boxing entirely:
List<int> nums = new() { 1, 2, 3 };Key point: generics were added to eliminate boxing.
Strings are immutable. Any operation that looks like it changes a string, whether concatenation, Replace, or ToUpper, returns a brand new string and leaves the original untouched. Immutability makes strings safe to share across threads without copying, and it makes them reliable to use as dictionary keys.
The practical implication: concatenating in a loop with += allocates a new string every iteration, which is O(n squared) for n pieces. Use StringBuilder to build large strings in one growing buffer.
var sb = new System.Text.StringBuilder();
foreach (var row in rows)
sb.Append(Render(row));
string html = sb.ToString(); // one buffer, the idiomatic wayBuilding a big string: += in a loop vs StringBuilder
Operations to assemble a string of N pieces, N = 1000 (log scale). += copies the whole string each step.
Key point: The StringBuilder idiom is the expected follow-up. Volunteering it answers the next question before it's asked.
Use StringBuilder when you build a string through many appends or modifications, typically inside a loop where the number of pieces is large or unknown. It keeps a resizable internal buffer and mutates it in place, so it avoids the repeated allocations and copies that plain string concatenation causes on every step.
For a handful of concatenations, plain string or interpolation is clearer and the difference is negligible. StringBuilder earns its keep when the count of appends is large or unbounded.
var tells the compiler to infer the variable's static type from the right-hand side of the assignment at compile time. It's still strongly and statically typed: var count = 5 is an int for good, and you can't later assign a string to it. It only shortens the code, it doesn't make the variable dynamic.
It's syntactic convenience, not dynamic typing. Teams use it to cut noise when the type is obvious from the assignment, and spell the type out when it aids readability.
var count = 5; // int
var name = "Asha"; // string
var items = new List<int>(); // List<int>
// count = "x"; // compile error: still typedconst is a compile-time constant: its value is baked into callers at compile time, so it must be a literal known then, and it's implicitly static. readonly is a runtime constant: it can be set in a constructor or field initializer and can differ per instance.
Because const values are inlined into referencing assemblies, changing a public const in a library requires recompiling everything that used it. readonly avoids that versioning trap, so public API constants are often readonly or static readonly.
public const double Pi = 3.14159; // compile-time, inlined
public static readonly DateTime Start = DateTime.UtcNow; // runtime valueA nullable value type (int?, DateTime?) can hold either its normal value or null, which plain value types can't do since they always have a default. Under the hood it's the Nullable<T> struct, exposing HasValue and Value. It's how you represent something like 'no age recorded' cleanly instead of picking a magic number.
The ?? null-coalescing operator supplies a fallback when the left side is null: age ?? 0 gives 0 when age is null. ??= assigns only if the target is currently null.
int? age = null;
int safe = age ?? 0; // 0
int? score = 90;
Console.WriteLine(score.HasValue); // True
Console.WriteLine(score.Value); // 90?. accesses a member only if the left side isn't null; otherwise the whole expression evaluates to null instead of throwing a NullReferenceException. In a chain of accesses, the first null short-circuits everything that follows, so you don't have to write a separate null check before each dot in the path.
It pairs naturally with ?? to supply a default. customer?.Address?.City ?? "Unknown" reads the city if every link exists, and falls back cleanly when any is null.
string city = customer?.Address?.City ?? "Unknown";
// no NullReferenceException even if customer or Address is nullKey point: Interviewers like hearing ?. and ?? together because that's how the operator is used in real code.
An array has a fixed length set at creation and offers the fastest indexed access with the least overhead. A List<T> is a resizable wrapper around an internal array that grows automatically as you add items, exposing convenient methods like Add, Remove, and Insert that an array doesn't have.
Reach for List<T> when the size isn't known ahead of time, which is most of the time. Use an array when the length is fixed and known, or when a hot path needs the leanest possible access.
int[] fixedScores = new int[3]; // fixed length
fixedScores[0] = 91;
var scores = new List<int>(); // grows on demand
scores.Add(91);
scores.Add(84);A field is a variable that stores data directly. A property looks like a field to callers but is backed by get and set accessors, so it can validate, compute, or log on access without changing the calling code. Auto-properties (public int Age { get; set; }) generate the backing field for you.
The reason to default to properties for public data: you can a plain get/set and later add validation or make it computed without breaking a single caller comes first. That's why exposing public fields is discouraged in C#.
public class Account
{
public decimal Balance { get; private set; } // read-only outside
private decimal _rate; // field
public decimal Rate
{
get => _rate;
set => _rate = value < 0 ? 0 : value; // validation
}
}public is visible everywhere. private is visible only inside the same type. protected is visible in the declaring type and any type that derives from it. internal is visible anywhere in the same assembly, which is roughly the same compiled project. The combinations protected internal and private protected mix these two axes.
Default matters: a class member with no modifier is private, and a top-level type with no modifier is internal. Starting private and widening only when needed is the encapsulation habit interviewers look for.
| Modifier | Visible where |
|---|---|
| public | Everywhere |
| private | Same type only |
| protected | Same type and derived types |
| internal | Same assembly |
A static member belongs to the type itself, not to any single instance, so there's exactly one shared copy that you access as ClassName.Member without creating an object. A static class can't be instantiated and holds only static members, which suits stateless helper types like Math and its collection of utility methods.
Watch the shared-state trap: static fields are shared across the whole app and across threads, so mutable static state is a common source of concurrency bugs. Static is safest for constants and pure functions.
By default, for reference types both == and Equals check reference identity: are these the same object? Types can override them to mean value equality instead. string overrides both to compare characters, which is why two separate equal strings are ==.
The rule to state: override Equals and GetHashCode together, and keep == consistent with them. For value-like data, records give you correct value equality for free.
string a = "hi";
string b = new string(new[] { 'h', 'i' });
Console.WriteLine(a == b); // True (value)
Console.WriteLine(ReferenceEquals(a, b)); // False (identity)An enum is a value type that gives readable names to a set of related integer constants, so your code reads Status.Active instead of a bare 3 whose meaning you have to remember. It's more readable, self-documenting, and it catches invalid values and typos at compile time rather than at runtime.
Enums back onto int by default but can use other integral types, and marking one with [Flags] lets you combine members with bitwise operators to represent a set of options in one value.
enum Status { Pending, Active, Closed }
Status s = Status.Active;
if (s == Status.Active) { /* ... */ }for gives you an explicit index counter, so you control the starting point, the step, the direction, and can modify the index inside the loop. foreach iterates any IEnumerable in order without exposing an index, which reads cleaner and avoids the off-by-one mistakes that manual index handling invites.
Use foreach for straightforward reads of a collection. Use for when you need the index, iterate backwards, or skip in steps. Note you can't add or remove items from a collection while foreach is iterating it.
try wraps code that may fail; catch handles specific exception types, listed most specific first so the right handler runs; finally always executes for cleanup whether or not an exception was thrown. You raise an exception yourself with throw new SomeException(...), and you can rethrow inside a catch.
Catch specific types rather than a bare catch that swallows everything, and prefer using or finally for resource cleanup. Inside a catch, throw; re-raises while preserving the original stack trace, unlike throw ex; which resets it.
try
{
var value = int.Parse(input);
}
catch (FormatException ex)
{
Console.WriteLine($"Bad input: {ex.Message}");
}
finally
{
Console.WriteLine("always runs");
}How a thrown exception unwinds
If nothing catches it, the exception reaches the top of the stack and terminates the process.
Key point: Knowing that throw; preserves the stack trace and throw ex; resets it is a classic freshers-to-intermediate gotcha.
The using statement wraps an IDisposable and guarantees its Dispose() runs the moment the scope ends, even if an exception is thrown partway through the block. That releases unmanaged resources like file handles, sockets, and database connections deterministically, right away, instead of waiting on the garbage collector to get around to it.
Don't confuse it with the using directive at the top of a file, which just imports a namespace. Same keyword, different jobs. The newer using declaration (no braces) disposes at the end of the enclosing block.
using (var reader = new StreamReader("data.csv"))
{
string content = reader.ReadToEnd();
} // reader.Dispose() runs here, even on exceptionparams lets a method accept a variable number of arguments as a single array parameter, so callers pass a plain comma-separated list without building an array themselves first. It has to be the last parameter in the signature, and callers can still pass an actual array if they already have one.
String.Format and Console.WriteLine use it. It's convenient, but each call that passes loose arguments allocates an array, so avoid it on hot paths where you'd call it in a tight loop.
int Sum(params int[] nums)
{
int total = 0;
foreach (var n in nums) total += n;
return total;
}
Sum(1, 2, 3); // 6
Sum(); // 0ref passes a variable by reference and requires it to be initialized before the call, so the method can read and modify it. out is for returning extra values: the caller need not initialize it, but the method must assign it before returning. in passes by reference read-only, which avoids copying large structs.
Tuples and return types often read better than out, but out remains common in the TryParse pattern that returns success and the parsed value together.
if (int.TryParse("42", out int value))
Console.WriteLine(value); // 42, no exception on bad input
void Increment(ref int n) => n++;
int x = 5;
Increment(ref x); // x == 6String interpolation, written with a $ prefix, embeds expressions directly inside a string literal, as in $"Hello {name}, you evaluated {score:P0}". It's more readable than string.Format with numbered placeholders because the values sit where they appear, and it supports format specifiers after a colon for things like currency or percentages.
The compiler turns it into efficient formatting code, and you can align and format each value inline. It's the preferred way to build display strings from a few values.
string name = "Asha";
decimal price = 19.5m;
string line = $"{name} pays {price:C}"; // "Asha pays $19.50"An explicit cast like (Customer)obj converts a type and throws an InvalidCastException if the object isn't that type. The as operator tries the same conversion but returns null instead of throwing on failure, so it only works for reference and nullable types. The is operator tests the type and returns a bool, and modern is can bind the result to a variable in one step.
The guidance: use is when you want to check and then use the value, use as when a null result is a fine outcome, and use a direct cast when you're certain and want a loud failure if you're wrong.
if (obj is Customer c) // check and bind in one step
Console.WriteLine(c.Name);
var maybe = obj as Customer; // null if not a Customer
if (maybe is not null) { }Key point: the key signal is 'as returns null, a cast throws'. Pairing is-with-pattern for the safe path is the modern answer.
A namespace groups related types under a shared name to organize code and prevent naming collisions, so your Order type and a library's Order type can coexist peacefully as MyApp.Sales.Order and Vendor.Billing.Order. You bring the names in a namespace into scope with a using directive at the top of a file.
Namespaces map loosely to folders by convention, not by requirement. They're a compile-time organizational tool; an assembly (the compiled DLL) is the separate deployment unit.
For candidates with working experience: language mechanics, the standard library, and the questions that separate users from understanders.
An async method can use await, which pauses the method at an awaited operation and returns control to the caller while that operation (usually I/O) runs. When the operation finishes, execution resumes right after the await, on a thread from the pool.
The point is scalability, not speed: while awaiting a network or disk call, the thread is freed to serve other work, so a server handles far more concurrent requests. It doesn't make CPU-bound math faster, and blocking on the result with .Result or .Wait() can deadlock.
async Task<string> GetPageAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url); // thread freed while waiting
}What await does to the thread
No thread is held during the wait, which is why one server can handle thousands of concurrent requests.
Key point: The trap is saying async makes code run in parallel or run faster. It frees the thread during I/O waits; that's the scalability story the question needs.
Watch a deeper explanation
Video: C# Async / Await - Make your app more responsive and faster with asynchronous programming (IAmTimCorey, YouTube)
A Thread is a low-level OS thread you create and manage directly, which is expensive. A Task is a higher-level abstraction representing an operation that may complete in the future; the Task Parallel Library schedules it, usually onto the shared thread pool.
In modern C# you almost always work with Task and async/await, not raw Threads. Tasks compose (WhenAll, ContinueWith), carry results and exceptions, and reuse pool threads instead of spinning up new ones.
| Thread | Task | |
|---|---|---|
| Level | Low-level OS thread | High-level future/operation |
| Cost | Expensive to create | Reuses thread pool |
| Result / exceptions | Manual | Built in (Task<T>, awaited) |
| Use today | Rare, special cases | The default |
Key point: Saying 'a Task isn't necessarily a thread' is the depth signal: an awaited I/O task may use no thread at all while it waits.
Watch a deeper explanation
Video: C# Async/Await/Task Explained (Deep Dive) (Raw Coding, YouTube)
LINQ (Language Integrated Query) adds query operators like Where, Select, OrderBy, and GroupBy that work over any IEnumerable, letting you filter, project, and aggregate collections in a readable, composable way. The two spellings, query syntax and method syntax, compile to exactly the same underlying method calls.
Most LINQ queries use deferred execution: defining the query builds a pipeline that runs only when you enumerate it (foreach, ToList, Count). That means the query re-runs each time you iterate, and it reflects later changes to the source, which surprises people until they call ToList to snapshot the results.
var adults = people.Where(p => p.Age >= 18) // nothing runs yet
.Select(p => p.Name);
foreach (var name in adults) { } // query executes here
var snapshot = adults.ToList(); // executes now, results cachedKey point: The classic follow-up is 'why does my query run twice?'. Deferred execution plus a second enumeration is the answer.
IEnumerable<T> is the smallest contract: you can iterate it and nothing more, which is exactly why it supports deferred, lazy, and even infinite pipelines. ICollection<T> builds on it by adding Count, Add, and Remove for a finite in-memory set. IList<T> goes further and adds indexed access by position.
Type parameters and return types to the narrowest interface that fits: exposing IEnumerable<T> keeps the door open to lazy sources and streaming, while returning List<T> locks callers into a concrete, in-memory collection.
A delegate is a type-safe reference to a method, so you can store, pass, and invoke methods like data. Func, Action, and Predicate are built-in generic delegates for the common shapes. An event is a delegate wrapped with publish/subscribe access control: subscribers use += and -=, but only the declaring type can raise it.
Delegates power callbacks and LINQ's lambdas; events power the observer pattern (a button click, a status change). The event keyword prevents outside code from clearing subscribers or firing the event, which a raw public delegate field wouldn't.
public event EventHandler? Completed;
void Finish()
{
Completed?.Invoke(this, EventArgs.Empty); // null-safe raise
}
// elsewhere:
worker.Completed += (s, e) => Console.WriteLine("done");A lambda is an inline anonymous function written with =>, like x => x * 2 or (a, b) => a + b. It's converted to a delegate (or an expression tree) and is the everyday way to pass short behavior to LINQ, event handlers, and higher-order methods.
Lambdas can capture variables from the surrounding scope, forming closures. That capture is by reference, which is the source of the classic loop-variable gotcha where captured variables all see the final value.
var names = people
.Where(p => p.Age >= 18)
.Select(p => p.Name)
.OrderBy(name => name);Generics let you write a class or method parameterized by a type, like List<T> or Dictionary<TKey, TValue>, so one implementation works for many types with full compile-time type safety and no casting. Constraints (where T : IComparable) restrict the allowed types.
They replaced the old object-based collections that boxed value types and lost type safety. A generic List<int> stores ints directly with no boxing, catches type errors at compile time, and needs no casts on retrieval.
T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
Max(3, 7); // 7, no boxing
Max("a", "b"); // "b"Choose an interface to define a capability that unrelated types can share, and because a class can implement many interfaces (IDisposable, IComparable, your own). Choose an abstract class when related types share state and implementation and you want a common base with some concrete and some abstract members; a class inherits only one.
A phrase that lands well: interfaces model 'can do', abstract classes model 'is a'. Modern interfaces allow default method bodies, which narrows the gap, but the single-inheritance-versus-many rule still guides the choice.
| Interface | Abstract class | |
|---|---|---|
| Multiple per class | Yes | No (single inheritance) |
| Instance fields / state | No | Yes |
| Constructors | No | Yes |
| Models | A capability (can do) | A base type (is a) |
IDisposable declares a single Dispose() method for releasing unmanaged resources such as file handles, sockets, and native memory deterministically, right when you're done, instead of waiting for the garbage collector, which only reclaims managed memory and runs on its own schedule. The using statement calls Dispose for you automatically.
A class that owns unmanaged resources implements the full dispose pattern (a protected Dispose(bool) and often a finalizer as a safety net), but most classes just chain to the disposable fields they hold. The core rule: if you hold something disposable, expose Dispose and clean it up.
public sealed class Report : IDisposable
{
private readonly StreamWriter _writer = new("out.txt");
public void Dispose() => _writer.Dispose();
}The .NET garbage collector automatically reclaims managed heap memory that's no longer reachable from roots (locals, statics, CPU registers). It's generational: new objects start in generation 0, which is collected often and cheaply, and survivors promote to gen 1 then gen 2, which are collected less often.
The generational design assumes most objects die young, so gen 0 sweeps are fast. Large objects go on a separate large object heap. The GC handles memory, not unmanaged resources, which is exactly why IDisposable and using exist.
An extension method adds a method to an existing type without modifying it or subclassing, by declaring a static method whose first parameter is prefixed with this. Callers invoke it as if it were an instance method. All of LINQ is extension methods on IEnumerable<T>.
They're for adding convenience to types you don't own. The catch: they can only use the type's public surface, so they aren't a way around encapsulation, and an instance method of the same name always wins.
public static class StringExtensions
{
public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(s);
}
" ".IsBlank(); // True, called like an instance methodyield return builds an iterator: the method produces values one at a time, pausing right after each yield and resuming exactly there on the next request, without ever materializing a whole collection in memory. The compiler generates a state machine behind the scenes that implements IEnumerable and tracks that position for you.
It's the tool for lazy sequences: reading a file line by line, generating an infinite stream, or filtering as you go so downstream code can stop early. Combined with LINQ's deferred execution, nothing runs until enumeration.
IEnumerable<int> EvensUpTo(int max)
{
for (int i = 0; i <= max; i += 2)
yield return i; // pauses here, resumes on next MoveNext
}A record is a reference type built for immutable data: the compiler generates value-based equality, a readable ToString, and a with expression for non-destructive copies. Two records with equal property values are equal, unlike a plain class which compares identity.
Reach for records for DTOs, domain values, and messages where the data matters more than identity. Use a class when the object has mutable state and behavior. record struct gives the same conveniences for a value type.
record Person(string Name, int Age);
var a = new Person("Asha", 30);
var b = a with { Age = 31 }; // copy with one change
Console.WriteLine(a == new Person("Asha", 30)); // True (value equality)Pattern matching tests a value's shape and extracts data in one step, in is and switch expressions. Type patterns (obj is Customer c), property patterns ({ Age: >= 18 }), relational patterns (> 100), and list patterns cover most cases, replacing chains of casts and null checks.
The switch expression form returns a value and forces you to handle cases, which reads far cleaner than nested if/else for classification logic.
string Describe(object o) => o switch
{
int n when n < 0 => "negative",
int => "non-negative int",
string { Length: 0 } => "empty string",
null => "null",
_ => "something else"
};Both are hash-based: a Dictionary maps keys to values with average O(1) lookup, insert, and delete; a HashSet stores unique values with O(1) membership tests. Keys and set elements rely on GetHashCode and Equals, so custom key types must implement both correctly.
Use a Dictionary when you need to associate values with keys, and a HashSet for fast 'have I seen this?' checks and de-duplication. Swapping a linear List.Contains for a HashSet is one of the cheapest speedups in C#.
var counts = new Dictionary<string, int>();
counts.TryGetValue("a", out int c);
counts["a"] = c + 1;
var seen = new HashSet<int>();
seen.Add(3); // returns false if already presentOverloading defines several methods with the same name but different parameter lists in the same type; the compiler picks one based on the arguments at the call site. Overriding replaces a virtual (or abstract) method's implementation in a derived class, resolved at runtime based on the object's actual type.
Overloading is compile-time polymorphism; overriding is runtime polymorphism. Overriding needs virtual on the base method and override on the derived one, and new hides rather than overrides, which behaves differently through a base reference.
class Shape { public virtual double Area() => 0; }
class Circle : Shape
{
public double R;
public override double Area() => Math.PI * R * R; // override
}
void Log(int n) { } // overload
void Log(string s) { } // overloadvirtual marks a method that derived classes may override. abstract marks a method with no body that derived classes must override, and forces the class itself to be abstract (not instantiable). sealed on a method stops further overriding down the chain, and sealed on a class stops inheritance entirely.
Sealing a class can help performance and prevent misuse; abstract expresses 'this base can't stand alone'; virtual leaves a customization point open. Naming which you'd choose and why signals design awareness.
A value tuple groups several values without declaring a type, with named elements: (string Name, int Age) or returning (found, value) from a method. They're value types, lightweight, and ideal for returning multiple values or short-lived grouping inside a method.
Use them for internal, throwaway grouping and multi-value returns. When the shape is part of a public API or gains behavior, promote it to a record or class so it carries a real name and can evolve.
(int min, int max) Range(int[] xs) => (xs.Min(), xs.Max());
var (lo, hi) = Range(new[] { 4, 1, 9 }); // lo == 1, hi == 9Start the tasks without awaiting each one immediately, then await Task.WhenAll to wait for all of them together. This overlaps the I/O so total time is roughly the slowest operation, not the sum. Awaiting inside a loop instead runs them one after another.
For the first result that finishes, use Task.WhenAny. Guard shared state when results come back, and remember WhenAll aggregates exceptions, so wrap it in try/catch to handle failures from any task.
var tasks = urls.Select(u => client.GetStringAsync(u));
string[] pages = await Task.WhenAll(tasks); // concurrent, not sequentialKey point: Interviewers love spotting an await inside a foreach that should have been WhenAll. Say why the loop is slower.
Use records for value-based equality and non-destructive copies through the with expression, mark properties as init-only so they're set once at construction and never again, and use readonly fields plus readonly struct for value types. The System.Collections.Immutable package adds copy-on-write lists, dictionaries, and sets for shared state.
Immutability makes objects safe to share across threads without locks and eliminates a whole class of 'who changed this?' bugs. The trade-off is more allocations when you create modified copies, which records' with expression makes cheap enough for most code.
advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
The C# compiler produces Intermediate Language (IL) plus metadata inside an assembly, not native machine code. At runtime the Common Language Runtime loads the assembly and its just-in-time compiler translates IL methods to native code the first time each is called, caching the result.
So C# is compiled twice: once to IL ahead of time, once to native code on demand. Ahead-of-time options (ReadyToRun, Native AOT) precompile to cut startup and remove the JIT, which matters for cold-start-sensitive services and small deployments.
In a context that captures a synchronization context (older ASP.NET, UI apps), calling .Result or .Wait() blocks the current thread while the awaited continuation is scheduled back onto that same captured context. The thread it needs is the one you just blocked, so both wait forever.
The fixes: go async all the way up rather than blocking, and in library code use ConfigureAwait(false) so continuations don't try to resume on the captured context. Modern ASP.NET Core has no synchronization context, which removes the classic case but not the discipline.
// risky in a captured context:
string page = GetPageAsync(url).Result; // can deadlock
// safe: await all the way, and in libraries:
string page = await GetPageAsync(url).ConfigureAwait(false);Key point: 'Why does ConfigureAwait(false) help?' is the follow-up. it maps to not resuming on the captured context.
A small struct avoids a heap allocation and the later GC cost, and it improves cache locality when you store many in an array, which is why hot numeric and graphics code uses structs. readonly struct plus in parameters avoids defensive copies.
It backfires when the struct is large and gets copied often (each pass by value copies every field), or when it's boxed by being used as object or a non-generic interface, which reintroduces the allocation you were avoiding. Measure with a profiler; intuition here is frequently wrong.
Span<T> is a stack-only view over contiguous memory: an array, a slice of one, stackalloc memory, or unmanaged memory, all without copying. Slicing a Span allocates nothing, so parsing and processing buffers avoids the substring and sub-array allocations that used to dominate hot paths.
Because Span is a ref struct it can't live on the heap, so it can't be a field of a class, boxed, or used across an await. Memory<T> is the heap-friendly counterpart for async and storage, and you get a Span from it when you process synchronously.
ReadOnlySpan<char> text = "2026-07-04";
var year = text.Slice(0, 4); // no allocation
int y = int.Parse(year); // 2026Measure first with the GC counters (allocation rate, gen 2 and LOH sizes, pause times) and an allocation profiler to find the sites churning memory. High gen 0 rates usually trace to per-request allocations: LINQ in hot loops, string building, boxing, and short-lived buffers.
Reduce it by pooling buffers (ArrayPool), using Span and StringBuilder, avoiding boxing, and reusing objects on the hottest paths. For servers, enable server GC and consider the large object heap: objects over ~85 KB land there and are collected only in gen 2, so pool or shrink big buffers.
The built-in container registers service implementations against abstractions and resolves them by constructor injection. Three lifetimes control instance reuse: transient creates a new instance every request, scoped creates one per scope (per web request in ASP.NET Core), and singleton creates one for the app's lifetime.
The trap that surfaces in senior interviews is the captive dependency: injecting a scoped service into a singleton captures the scoped instance for the whole app, which breaks per-request state and can share things like a DbContext across requests. Match lifetimes deliberately.
| Lifetime | Instances | Typical use |
|---|---|---|
| Transient | New every resolve | Lightweight, stateless services |
| Scoped | One per scope/request | DbContext, per-request state |
| Singleton | One per application | Config, caches, stateless shared services |
Key point: Bringing up the captive-dependency problem in practice (scoped inside singleton) is a strong production signal.
Covariance (out) lets a generic type be used with a more derived type argument where a less derived one is expected: IEnumerable<string> is assignable to IEnumerable<object> because the type parameter is only produced, never consumed. Contravariance (in) is the reverse for consumed types: Action<object> can be used where Action<string> is expected.
The compiler enforces safety through the in and out variance annotations on interfaces and delegates. Arrays are covariant too, but unsafely: that legacy behavior can throw ArrayTypeMismatchException at runtime, which is worth naming as the counterexample.
An expression tree represents code as data: a lambda typed as Expression<Func<...>> is compiled into a tree of nodes describing the operation rather than into a directly runnable delegate. Other code can then walk that tree to inspect it, transform it, or translate it into an entirely different form such as SQL.
This is how ORMs like Entity Framework turn a C# Where(x => x.Age > 18) into SQL: the provider walks the expression tree and generates a query instead of executing the lambda in memory. Understanding this explains why some LINQ methods work against a database and others force everything into memory.
Guard shared mutable state with a lock on a private, readonly reference object so only one thread enters the critical section at a time. Keep the locked region small, never lock on this or a public object (outside code could lock it and deadlock you), and never do I/O or call unknown code while holding a lock.
Prefer higher-level tools where they fit: Interlocked for simple counters, concurrent collections (ConcurrentDictionary) instead of manual locking, and immutable data to sidestep sharing altogether. Deadlocks come from inconsistent lock ordering across threads, so acquire multiple locks in a fixed order.
private readonly object _gate = new();
private int _count;
void Increment()
{
lock (_gate) { _count++; } // or Interlocked.Increment(ref _count)
}A CancellationToken is a cooperative signal: the caller creates a CancellationTokenSource, passes its token into async methods, and calls Cancel to request a stop. Well-behaved code checks token.ThrowIfCancellationRequested or passes the token into awaited calls, which throw OperationCanceledException when cancellation fires.
It's cooperative, not forceful: nothing kills a running thread, so code that ignores the token can't be cancelled. Flowing tokens through the whole call chain (with timeouts via CancellationTokenSource) is how you keep servers from wasting work on abandoned requests.
async Task WorkAsync(CancellationToken ct)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested();
await ProcessAsync(item, ct);
}
}IAsyncEnumerable<T> is an asynchronous stream: you produce values with yield return inside an async method and consume them with await foreach, awaiting between items. It's for data that arrives over time and is fetched asynchronously, like paged API results or a streaming query.
It combines the laziness of iterators with async I/O, so you process each item as it arrives without buffering the whole set in memory or blocking a thread while waiting. Pass a CancellationToken via WithCancellation to make the stream cancellable.
async IAsyncEnumerable<string> ReadPagesAsync()
{
string? next = "/page/1";
while (next is not null)
{
var page = await FetchAsync(next);
yield return page.Body;
next = page.NextLink;
}
}Nullable reference types are a compile-time feature: with the feature enabled, string means 'not null' and string? means 'may be null', and the compiler warns when you might dereference a null. It doesn't change runtime behavior; it surfaces potential NullReferenceExceptions before they ship.
Adopt it incrementally by enabling nullable per file or project and fixing warnings at boundaries first. The null-forgiving operator (!) silences a warning when you know better, but leaning on it defeats the point, so treat each ! as a claim you'd defend.
Single responsibility keeps a class focused on one reason to change. Open/closed favors extension through interfaces and composition over editing existing code. Liskov substitution means a subtype must honor its base's contract. Interface segregation prefers small, focused interfaces. Dependency inversion means depending on abstractions, which the built-in DI container makes routine.
The production-ready answer resists reciting definitions and instead ties them to trade-offs: over-applying them produces a maze of tiny interfaces and indirection. Good judgment is knowing when a principle earns its cost and when a simpler design is clearer.
An async void method can't be awaited, so callers can't know when it finishes or observe its result. Worse, an exception thrown inside it isn't captured by a Task; it propagates to the synchronization context and typically crashes the process instead of being catchable at the call site.
The only accepted use is an event handler, whose signature forces void. Everywhere else return Task (or Task<T>) so the operation is awaitable and its exceptions are observable.
Key point: Interviewers use this to check whether you understand where exceptions go. 'It can bring down the process' is the answer they want.
Objects that are equal by Equals must return the same GetHashCode, and an object's hash must not change while it's a key in a dictionary or a set. Break the first rule and hash-based lookups miss entries that are logically present; break the second and the entry becomes unreachable.
So override both together, base both on the same immutable fields, and prefer HashCode.Combine to build the hash. Records generate a correct pair for you, which is a strong reason to use them for value objects that become keys.
public override bool Equals(object? o) =>
o is Money m && m.Amount == Amount && m.Currency == Currency;
public override int GetHashCode() =>
HashCode.Combine(Amount, Currency);Clarify requirements first (eviction policy, size bound, per-entry TTL, single process or distributed), then reach for the built-in IMemoryCache, which already handles concurrency, expiration, and size limits, before writing your own. Rolling your own, a ConcurrentDictionary keyed by the cache key gives lock-free reads and atomic GetOrAdd for the common path.
Handle the stampede: when a key expires, many callers can recompute at once, so guard the recompute with a per-key lock or a Lazy<Task> stored in the dictionary so only one caller does the work and the rest await it. For a distributed cache, the state moves to Redis and the same expiry and stampede concerns apply across processes.
private readonly ConcurrentDictionary<string, Lazy<Task<T>>> _entries = new();
Task<T> GetOrAddAsync(string key, Func<Task<T>> factory) =>
_entries.GetOrAdd(key, _ => new Lazy<Task<T>>(factory)).Value;Key point: Naming IMemoryCache before hand-rolling one, then the cache-stampede fix, is exactly the judgment this question screens for.
C# wins when you want a statically typed, garbage-collected language with a mature runtime and one toolchain across web, desktop, mobile, and games. It trades some deployment simplicity and cold-start speed for a deep standard library, first-class async, LINQ, and strong editor tooling. Where it loses is single-binary distribution and tiny-footprint services, where Go fits better, and quick scripting or data work, where Python fits better. Knowing these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Language | Typing | Best at | Watch out for |
|---|---|---|---|
| C# | Static | Web APIs, enterprise apps, Unity games | Cold starts, heavier runtime than Go |
| Java | Static | Large enterprise systems, Android | Verbosity, slower language evolution |
| Go | Static | Concurrent services, single-binary CLIs | Smaller data/ML ecosystem, no generics history |
| Python | Dynamic | Data, ML, scripting, fast prototyping | Runtime speed, weaker compile-time checks |
Prepare in layers, and practice out loud. Most C# rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical C# interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These C# questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works