C# · Interview

C# interview questions

Thirteen C# questions of the kind asked in .NET developer interviews, from junior to senior: value versus reference semantics, integer arithmetic traps, LINQ deferred execution, exception flow, async ordering and design trade-offs. Read the question, commit to an answer out loud or on paper, then compare with the explanation. Every code snippet was compiled and run on .NET 8, so the printed output is real.

Every example verified13 questions · 4 concept · 4 predict the output · 2 debugging · 1 coding · 2 scenario
ConceptJunior

A struct Point and a class Point both have an X field. You write var b = a; b.X = 99;. In which case does a.X change, and why?

Answer

Only with the class. A struct is a value type: assignment copies the whole value, so b is an independent copy and changing b.X leaves a untouched. A class is a reference type: a and b are two references to the same object on the heap, so b.X = 99 is visible through a as well. The same rule explains why passing a struct to a method cannot modify the caller's copy (unless you use ref), and why comparing two class instances with == compares references rather than contents unless the type overrides equality. Interviewers ask this because the difference decides how you design small data carriers (a struct or record struct for a coordinate, a class for an entity with identity) and it is the root cause of many "my change disappeared" bugs.

Predict the outputJunior

What does this program print?

 C# · what does this print?
int a = 7, b = 2;
double ratio = a / b;
Console.WriteLine(ratio);
Console.WriteLine((double)a / b);
Console.WriteLine($"{a % b} {-a / b} {-a % b}");

Answer

The first line is 3, not 3.5: both operands of a / b are int, so the division is integer division and the result 3 is only converted to double afterwards, when it is stored in ratio. Casting one operand first, (double)a / b, makes the compiler pick floating-point division, giving 3.5. The last line shows that C# integer division truncates towards zero and that the remainder takes the sign of the dividend: -7 / 2 is -3 and -7 % 2 is -1. This matters when computing averages or percentages from integer counts; the fix is a cast or a double literal such as 2.0, applied before the division, not after.

It prints

3
3.5
1 -3 -1
ConceptJunior

Does var total = 0; make total a dynamically typed variable? What happens if you later write total = "none";?

Answer

No. var asks the compiler to infer the static type from the initialiser, so total is an int exactly as if you had written int total = 0;. The assignment total = "none" is a compile-time error because a string cannot be stored in an int. var is purely a convenience for readability when the type is obvious or long, for example var lookup = new Dictionary<string, List<int>>();. It is different from dynamic, which defers member binding to run time and does allow a value of another type to be assigned. Knowing the difference shows you understand that C# type checking happens at compile time and that var costs nothing at run time.

DebuggingJunior

The average of 8, 7, 9 and 7 is 7.75, but this prints Average: 7.00. Why, and what is the smallest fix?

The code with the bug

 C# · has a bug
int[] scores = { 8, 7, 9, 7 };
int sum = scores.Sum();
double average = sum / scores.Length;
Console.WriteLine($"Average: {average:F2}");

Answer

sum and scores.Length are both int, so sum / scores.Length is integer division: 31 / 4 gives 7, and only then is 7 converted to the double 7.0 because of the variable's declared type. The declared type of the target never changes how an expression is evaluated. Casting either operand before dividing, (double)sum / scores.Length, turns it into floating-point division and gives 7.75. scores.Average() from LINQ does the same conversion internally and is the idiomatic choice. The lesson interviewers want to hear: look at the types of the operands, not the type of the variable you store into.

 C# · fixed
int[] scores = { 8, 7, 9, 7 };
int sum = scores.Sum();
double average = (double)sum / scores.Length;
Console.WriteLine($"Average: {average:F2}");

Output

Average: 7.75
DebuggingMid-level

This should drop every parcel whose label starts with "b" and print lamp, kettle, but it throws at run time. What is wrong, and how would you fix it?

The code with the bug

 C# · has a bug
var parcels = new List<string> { "lamp", "books", "kettle", "bulbs" };
foreach (string item in parcels)
{
    if (item.StartsWith("b"))
    {
        parcels.Remove(item);
    }
}
Console.WriteLine(string.Join(", ", parcels));

Answer

List<T> refuses to be modified while it is being enumerated: its enumerator records a version number, and the next MoveNext() after a Remove throws InvalidOperationException ("Collection was modified"). The guard exists because removing an element shifts the rest down and the enumerator would otherwise skip or repeat items silently. The clean fix is RemoveAll with a predicate, which does the filtering in one pass. Alternatives are a backwards for loop with an index (removing at index i does not disturb the elements before it) or building a new list with Where(...).ToList(). A senior candidate also notes that Remove inside a loop is O(n) per call, so RemoveAll is faster as well as safer.

 C# · fixed
var parcels = new List<string> { "lamp", "books", "kettle", "bulbs" };
parcels.RemoveAll(item => item.StartsWith("b"));
Console.WriteLine(string.Join(", ", parcels));

Output

lamp, kettle
Predict the outputMid-level

What does this print, and what does it tell you about Where?

 C# · what does this print?
var prices = new List<int> { 5, 12, 8 };
var expensive = prices.Where(p => p > 6);
Console.WriteLine(expensive.Count());

prices.Add(20);
Console.WriteLine(expensive.Count());

var snapshot = expensive.ToList();
prices.Add(30);
Console.WriteLine(snapshot.Count);

Answer

Where does not filter anything when it is called; it returns a query object that remembers the source list and the predicate. Each Count() re-runs the query against the current contents of prices, so the first count sees 12 and 8 (2), and after adding 20 the same query sees three matches. ToList() executes the query once and copies the results into a new List<int>, so snapshot.Count stays 3 even though 30 was added later. This deferred execution is what lets LINQ chains compose without building intermediate lists, but it also means a query captured in a variable can be surprisingly expensive if enumerated repeatedly, and can change its answer if the source changes. Materialise with ToList() or ToArray() when you need a stable result or will enumerate more than once.

It prints

2
3
3
CodingMid-level

Write a method that takes a list of strings and returns the value that occurs most often, together with its count. When two values tie, return the one that appeared first in the list. Demonstrate it on a list of tree species where two species tie.

Answer

Two passes do the job. The first builds a Dictionary<string, int> of counts; GetValueOrDefault returns 0 for a key not yet present, so there is no separate "first time seen" branch. The second pass walks the original list in order and only replaces best when it finds a strictly larger count, which is exactly what "earliest wins ties" means: the first of two tied values is reached first and a later equal count does not displace it. Walking the list rather than the dictionary is deliberate, because a Dictionary does not promise any enumeration order. The method returns a tuple, deconstructed at the call site. An interviewer listens for the tie rule being handled on purpose, for the choice of a hash-based counter (O(n) overall) over nested loops, and for what should happen on an empty list, which this version does not guard against and a production version would.

 C# · reference answer
var species = new List<string> { "oak", "ash", "oak", "elm", "ash", "fir" };
(string label, int count) = MostFrequent(species);
Console.WriteLine($"{label} {count}");

Console.WriteLine(MostFrequent(new List<string> { "yew" }));

static (string, int) MostFrequent(List<string> items)
{
    var counts = new Dictionary<string, int>();
    foreach (string item in items)
    {
        counts[item] = counts.GetValueOrDefault(item) + 1;
    }

    string best = items[0];
    foreach (string item in items)
    {
        if (counts[item] > counts[best])
        {
            best = item;
        }
    }
    return (best, counts[best]);
}

Output

oak 2
(yew, 1)
ConceptMid-level

Strings in C# are immutable. What does that mean in practice, and when should you reach for StringBuilder?

Answer

Immutable means a string object never changes after it is created: s.ToUpper(), s.Replace(...), s + "x" and s.Trim() all return new strings and leave the original alone, so forgetting to use the return value is a common bug. Immutability makes strings safe to share between threads and to use as dictionary keys, and lets the runtime intern literals. The cost is that building a large string by repeated concatenation copies the growing text every time, which is quadratic work in a loop. StringBuilder keeps a resizable buffer that you Append to and converts with ToString() once, so assembling thousands of lines is linear. For a handful of pieces, interpolation or string.Join is clearer and fast enough; reach for StringBuilder when the number of appends is large or unknown, typically inside a loop.

Predict the outputMid-level

In what order do these lines print, and why does called appear where it does?

 C# · what does this print?
Console.WriteLine("start");
Task<string> pending = FetchAsync();
Console.WriteLine("called");
string result = await pending;
Console.WriteLine(result);
Console.WriteLine("end");

static async Task<string> FetchAsync()
{
    Console.WriteLine("fetching");
    await Task.Delay(20);
    return "data";
}

Answer

An async method runs synchronously on the caller's thread until it hits the first await on something that is not yet complete. So calling FetchAsync() prints fetching immediately, then await Task.Delay(20) cannot finish yet, the method returns an incomplete Task<string> to the caller, and the caller carries on to print called. The await pending line then suspends the top-level code until the delay finishes and the method completes with "data", after which data and end print. This is the core mental model of async in C#: async does not start a new thread, and the split point is the first incomplete await. It also explains why doing heavy CPU work before the first await still blocks the caller.

It prints

start
fetching
called
data
end
Predict the outputSenior

What does this print? Pay attention to the order of finally and the returned value.

 C# · what does this print?
Console.WriteLine(Run());

static string Run()
{
    try
    {
        Console.WriteLine("try");
        throw new InvalidOperationException("boom");
    }
    catch (InvalidOperationException e) when (e.Message == "boom")
    {
        Console.WriteLine("caught " + e.Message);
        return "from catch";
    }
    finally
    {
        Console.WriteLine("finally");
    }
}

Answer

The try block prints try and throws. The catch has an exception filter (when), which is evaluated before the stack unwinds; it matches, so the handler prints caught boom and executes return "from catch". A return inside try or catch does not skip finally: the return value is computed and set aside, finally runs and prints finally, and only then does control leave Run, so the caller prints from catch last. Senior candidates should also know that a filter that returns false lets the exception continue to outer handlers without ever entering the catch, that a throw inside finally replaces the original exception, and that finally cannot contain return. This ordering is why finally is the right place to release resources: it runs whether the block exits by a normal path, a return, or an exception.

It prints

try
caught boom
finally
from catch
ConceptSenior

When would you define an abstract base class rather than an interface, and how did C# 8 default interface methods change that choice?

Answer

Choose an abstract class when the types share implementation and state: fields, constructors, protected helpers, and a template method that calls abstract steps. A class can inherit from only one base, so an abstract class claims that single slot and expresses an "is a" relationship in one hierarchy. Choose an interface when you are describing a capability that unrelated types can provide (IComparable<T>, IDisposable, your own INotifier), because a type can implement many interfaces and callers depend only on the contract, which keeps code testable through fakes. Since C# 8 an interface member may carry a default body, which lets library authors add a member to a published interface without breaking existing implementers; but interfaces still cannot declare instance fields or constructors, and default members are only reachable through the interface type, so they do not replace base classes for shared state. A practical rule: start with an interface for the public contract, and add an abstract class alongside it only when several implementers would otherwise duplicate code.

ScenarioMid-level

A service loads 200,000 product codes at start-up and then, for every incoming request, must check whether a code exists and, if so, fetch its price. A colleague stores them in a List<(string Code, decimal Price)> and searches with FirstOrDefault. What would you change and why?

Answer

FirstOrDefault on a list is a linear scan, so each request does up to 200,000 string comparisons; under load that dominates the request time. A Dictionary<string, decimal> keyed by code gives amortised constant-time lookup with TryGetValue, which answers both questions (exists, and the price) in one call without exceptions. If only existence mattered a HashSet<string> would do. Two details to settle: the comparer (pass StringComparer.OrdinalIgnoreCase if codes are case-insensitive, rather than lower-casing on every request) and thread safety, since the dictionary is built once and read concurrently, which is safe as long as nobody writes to it afterwards; use FrozenDictionary (.NET 8) or ConcurrentDictionary if it will be updated at run time. Keep the list only if you need ordered iteration, and even then build the dictionary next to it.

ScenarioSenior

You are reviewing an ASP.NET Core endpoint that calls three independent downstream HTTP services one after another using .Result, then combines the responses. Under load it is slow and occasionally hangs. What do you change?

Answer

Two separate problems. First, .Result and .Wait() block a thread pool thread while the HTTP call is in flight; under load that starves the pool, and in environments with a synchronisation context it can deadlock outright when the continuation needs the very thread that is blocked. Make the endpoint async Task<IActionResult> and await the calls so the thread returns to the pool while waiting: async has to go all the way down. Second, the three calls are independent, so start all three and await Task.WhenAll(...); total latency drops from the sum to the maximum of the three. While there, pass the request's CancellationToken into every call so abandoned requests stop work, set timeouts on the HttpClient (obtained from IHttpClientFactory, not created per request), and decide what a partial failure means: WhenAll throws the first exception but keeps the others on the tasks, so inspect each task if you need per-service fallbacks. Finish by load-testing to confirm the thread pool no longer saturates.

How this page was checked. Every program on it was run with .NET SDK 8.0 at build time by the publishing checks, and the output shown is what it printed. Running C# inside the browser is not available yet, so the Run button is absent rather than pretending; copy the code and run it with .NET SDK 8.0 locally.