Nullable reference types shipped in C# 8, and by now most senior candidates have used string? in anger, but far fewer can explain precisely what the compiler is doing, where the feature's guarantees stop, or how to roll it out across a codebase that predates it by a decade. Interviewers lean on nullable reference types because it is a feature with real edges: it is compile-time only, it interacts awkwardly with generics, it can lie to you at serialization and ORM boundaries, and the null-forgiving operator gives every developer a trivial way to defeat it. A strong answer at the senior level distinguishes "the compiler warned me" from "the runtime is safe," explains the annotation-versus-warning distinction precisely, and has a real opinion on migrating a large, older codebase incrementally rather than flipping one switch and drowning in warnings.

Q1 What exactly happens at compile time when you enable nullable reference types, and what changes in the compiled assembly?#

Short answer: Enabling the feature turns on two independent things — whether ? on a reference type means something (the annotation context) and whether the compiler reports nullability warnings (the warning context) — and the compiler records your annotations as [Nullable] and [NullableContext] metadata attributes on the compiled members; it emits no runtime checks and changes no IL behavior at all.

This is the fact candidates most often get wrong: they describe NRT as if it changes how the CLR treats references, when in reality string and string? are both System.String at the IL level, indistinguishable by typeof or by a caller compiled without the feature. The <Nullable> MSBuild property controls both flags at once (enable, disable, warnings, annotations), and the #nullable directive can override either flag per file or per region, which is exactly the lever used during migration to bring files under the feature one at a time.

C#
#nullable enable
public sealed class CatalogItem
{
    public required string Sku { get; init; }
    public string? Description { get; init; }   // metadata: [Nullable(2)] roughly speaking
}
#nullable restore

Because the metadata is just attributes, any tool that wants to honor it — the compiler, an analyzer, a serializer — has to explicitly read NullabilityInfoContext or the underlying attributes; nothing does so automatically unless it was built to. That is the root cause behind most of the "but I thought NRT protected me" surprises this page's other questions cover.

What interviewers look for: the annotation/warning distinction stated precisely, and confidence that this is purely a compile-time, metadata-only feature with zero IL behavior change.

Common mistakes: claiming the compiler inserts null checks, or that string? and string are different runtime types.

Q2 How does the compiler's null-state flow analysis actually track nullability through a method, and where does it stop?#

Short answer: Inside a single method body, the compiler assigns every reference-typed expression a null-state — not-null or maybe-null — at each point in the code, updating it through assignments, if/is/?? checks, early returns and loops; the analysis is strictly local, meaning it never looks inside the body of a method you call, only at that method's declared signature and nullable attributes.

That locality is the single most important mental model for this feature: it explains both why annotating a method's signature correctly matters so much (every caller trusts it blindly) and why flow analysis can "forget" what it knew after certain operations, such as passing a variable into a lambda that captures it, or awaiting a call in between two checks of the same field, since another thread could have changed shared state in between.

C#
static void Process(Order? order)
{
    if (order is null)
    {
        return;
    }

    // order is not-null here...
    Task.Delay(10).Wait();
    // ...but the compiler still trusts it, even though nothing re-verified it.
    Console.WriteLine(order.Total); // no warning, by design: locals aren't reset across awaits
}

public sealed class Order
{
    public decimal Total { get; init; }
}

A second edge worth naming: nullability of a field is only tracked reliably within the same method for simple cases; across method boundaries on the same object, the compiler assumes a field keeps whatever state it had, so a private field that another method could set to null between calls is a common source of a spurious "this is fine" read that later throws.

What interviewers look for: the phrase "the analysis is local to the method, it reasons only from signatures" said explicitly, plus at least one concrete example of where that locality produces a gap.

Follow-up questions:

  • Why doesn't narrowing a field's null-state inside one method persist into a method it calls?
  • What tells the compiler that a helper method guarantees a field is non-null afterward?

Q3 How would you plan an incremental migration of a large, pre-NRT codebase to nullable reference types?#

Short answer: Treat it as sequenced work, not a single flag flip: pick a default per project (enable and suppress unmigrated files with #nullable disable, or the reverse), migrate bottom-up starting from leaf types with no internal dependents, and lock in progress by promoting nullable to WarningsAsErrors once a project is clean, so regressions fail the build instead of silently reappearing.

The ordering choice inside each project matters as much as the overall plan. "Warnings first" (<Nullable>warnings</Nullable>) surfaces likely null-dereference bugs in running code before any type signatures change, which derisks the migration but produces noise unrelated to nullability itself. "Annotations first" (<Nullable>annotations</Nullable>) settles the public contract — what is nullable, what isn't — before you clean up internals, which suits a library whose consumers need a stable annotated surface sooner rather than later. Bottom-up ordering (domain models and utilities before the code that calls them) avoids the thrash of annotating a type only to immediately create warnings in every caller, which then get re-touched again once the caller itself is migrated.

XML
<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <!-- New and touched files are nullable-aware immediately. -->
  </PropertyGroup>
</Project>
C#
#nullable disable
// Legacy file, not yet migrated; remove this line as part of migrating it.
public sealed class LegacyCustomerRepository { /* ... */ }

Track null!/default! occurrence counts as a real migration metric — every one is a place where the type system was told to trust something it could not verify, and a shrinking count is a better progress signal than warning count alone, since warning count can drop simply because someone added #nullable disable to a file rather than actually fixing it. Run integration tests after each project goes clean, not just the compiler, because annotation changes ripple into EF Core migrations and ASP.NET Core model validation, covered in a later question.

What interviewers look for: a real sequencing strategy with a stated ordering rationale, not "just turn it on and fix everything," and awareness that warning count is a proxy metric that can be gamed.

Common mistakes: enabling NRT solution-wide in one commit on a large legacy codebase, which either blocks the build indefinitely or gets immediately overridden with a blanket NoWarn, defeating the entire migration.

Q4 What do the nullable attributes like [NotNullWhen] and [MemberNotNull] add that a plain ? cannot express?#

Short answer: A ? states one unconditional fact about a type; real APIs have conditional contracts — "this out parameter is non-null exactly when the method returns true," "this field is non-null after this helper method runs" — and the attributes in System.Diagnostics.CodeAnalysis let you describe those conditions so the flow analysis can trust them across a method call.

Three categories cover almost all real usage. Conditional attributes ([NotNullWhen(bool)], [MaybeNullWhen(bool)]) describe Try-pattern methods, where nullability of an out parameter depends on the return value. Member attributes ([MemberNotNull(...)], [MemberNotNullWhen(bool, ...)]) describe helper methods that guarantee a field or property's null-state, which matters because the compiler does not otherwise trust that a constructor calling a private Init() method actually initialized everything. Flow attributes ([DoesNotReturn], [DoesNotReturnIf(bool)]) tell the analysis that control never continues past a call, which is how a custom ThrowIfInvalid helper can narrow a variable's nullability for the rest of the method, exactly like an inline throw.

C#
using System.Diagnostics.CodeAnalysis;

public sealed class SessionStore
{
    private string? _sessionToken;

    [MemberNotNullWhen(true, nameof(_sessionToken))]
    public bool IsAuthenticated => _sessionToken is not null;

    [MemberNotNull(nameof(_sessionToken))]
    public void SignIn(string token) => _sessionToken = token;

    public string Describe()
    {
        if (!IsAuthenticated)
        {
            return "anonymous";
        }

        return _sessionToken; // no warning: IsAuthenticated proved _sessionToken is not null
    }
}

Without [MemberNotNullWhen] on IsAuthenticated, the last line would warn even though the logic is obviously correct to a human reader; the attribute is what lets the compiler agree with you. These attributes cost nothing at runtime — they are read only by the compiler's analysis and, optionally, by reflection-based tools via NullabilityInfoContext.

What interviewers look for: recognizing the Try-pattern and guard-clause shapes as the two situations that come up constantly, and being able to write [NotNullWhen] or [MemberNotNull] correctly from memory, not just recognize them.

Q5 When is the null-forgiving operator legitimate, and how do you stop it from becoming a warning-suppression habit?#

Short answer: ! is legitimate when you know something true that the compiler structurally cannot see — a query provider's translated expression, an invariant guaranteed by a framework, test code immediately after an assertion — and illegitimate whenever it is used to make a warning go away without actually establishing why the value is safe; the fix is process, not tooling: treat every ! as a code-review item, not a routine keystroke.

The danger is that ! compiles to nothing at all; it is a pure compiler-warning suppression with zero runtime effect, so every ! is a claim that, if wrong, produces exactly the NullReferenceException the whole feature exists to prevent, except now it is one the compiler was explicitly told to stay silent about. = null! and = default! as field initializers are the most common structural abuse: they exist as a legitimate migration crutch for types initialized outside the constructor (dependency injection, object initializers with required not yet available), but teams that never revisit them accumulate silent landmines.

C#
// Weak: silences the warning without proving anything; throws deep inside unrelated code later.
string apiKey = configuration["ApiKey"]!;

// Better: turns a missing value into a clear failure at the point it matters.
string apiKey = configuration["ApiKey"]
    ?? throw new InvalidOperationException("Configuration key 'ApiKey' is required.");

// Legitimate: EF Core translates this expression to SQL, where a null check cannot throw.
var overdue = await db.Invoices
    .Where(i => i.PaymentInfo!.DueDate < DateTime.UtcNow)
    .ToListAsync(cancellationToken);

A practical control is a repository-wide grep for !;, !), and !. tracked over time as a dashboard metric, plus a lightweight review rule: a ! in a pull request needs either a preceding comment explaining why the value is guaranteed non-null, or should be replaced by a real check, required, or a nullable attribute. Some teams go further and ban ! outright via an analyzer rule, accepting the occasional awkward workaround in exchange for zero silent suppressions.

What interviewers look for: distinguishing "I know something the compiler can't see" (legitimate) from "I want this warning gone" (not legitimate), plus a concrete review or tooling practice, not just "use it sparingly."

Common mistakes: treating ! as interchangeable with a null check — it proves nothing at runtime, whereas ?? throw and is not null both actually verify the value.

Q6 How do required members interact with nullable reference types, and what does SetsRequiredMembers actually guarantee?#

Short answer: required (C# 11) and nullability are orthogonal: required means "the caller's object initializer must assign this," and ? means "this may legitimately be null," so a required string? MiddleName is completely valid — required to be set, but null is an acceptable value to set it to. [SetsRequiredMembers] on a constructor tells the compiler to skip the required-member check for callers of that specific constructor; it is a trust declaration, not something the compiler verifies against the constructor's actual body.

Before required, the standard fix for a non-nullable, uninitialized member (CS8618) was a constructor parameter or a = null! crutch. required gives a third, more honest option: the member stays validly non-nullable, and the obligation to supply a value moves to every call site's object initializer, enforced as a compile error, not a warning, if omitted.

C#
public sealed class ShippingLabel
{
    public required string RecipientName { get; init; }
    public required string PostalCode { get; init; }
    public string? Apartment { get; init; }   // optional and nullable: both facts are independent

    public ShippingLabel() { }

    // The compiler cannot verify this constructor really sets every required member;
    // it trusts the attribute, so use it only where that trust is actually earned.
    [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
    public ShippingLabel(string recipientName, string postalCode)
    {
        RecipientName = recipientName;
        PostalCode = postalCode;
    }
}

var label = new ShippingLabel { RecipientName = "A. Ng", PostalCode = "0175" }; // Apartment omitted, fine

Omitting PostalCode from the object initializer is a build error, which is a stronger guarantee than any nullable warning gives you, and it is why required is now the preferred replacement for null! initializers on data objects: it documents intent and the compiler actually checks it at every call site, rather than only where you remembered to look.

What interviewers look for: stating clearly that required and nullability are independent axes, and correctly describing [SetsRequiredMembers] as an unverified promise rather than something the compiler cross-checks against the constructor body.

Follow-up questions:

  • What warning would you expect if you add required to a member but no constructor and no object initializer sets it?
  • Why might a team ban [SetsRequiredMembers] on internal types but allow it at a library's public constructors?

Q7 How does T? behave differently for an unconstrained generic parameter versus one constrained to class or struct, and why does that surprise people?#

Short answer: For an unconstrained T, T? means "T, or its default value," which is string? when T is a reference type but plain int when T is int — value types have no separate nullable annotation unless T is constrained with where T : struct, at which point T? becomes the real Nullable<T> wrapper type.

This surprises people because the syntax looks identical in both cases but compiles to something fundamentally different. A generic method returning T? and falling back to default cannot distinguish "found nothing, here is a legitimate default value" from "found a value that happens to equal default" when T is a value type — FindFirst<int>(numbers, predicate) returning 0 could mean either "the first match was zero" or "nothing matched." This is exactly why Dictionary<TKey, TValue>.TryGetValue uses the Try-pattern with [MaybeNullWhen(false)] out TValue value instead of returning TValue?, because the bool return carries the "found or not" signal that a bare nullable return cannot for value types.

C#
static T? FirstOrDefaultMatch<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (var item in source)
    {
        if (predicate(item)) return item;
    }

    return default; // null for a reference T, but 0/false/etc. for a value type T
}

int firstNegative = FirstOrDefaultMatch([1, 2, 3], x => x < 0); // 0 — but was anything found?

The where T : notnull constraint, used by Dictionary<TKey, TValue> for its key parameter, is the other direction of this concern: it rules out both null reference values and prevents T? from being interpreted as "may be default" for that parameter, which is required because a dictionary key must have a stable, comparable identity.

What interviewers look for: the precise statement that unconstrained T? means "may be default," which is only equivalent to "may be null" for reference types, plus the TryGetValue example as the idiomatic fix.

Q8 What extra nullability concerns apply when you are authoring a public library rather than an application?#

Short answer: In a library, your nullable annotations are part of the public contract the moment a consumer enables NRT, so you must keep runtime ArgumentNullException checks even on non-nullable parameters (not every caller compiles with the feature on, and some are in other .NET languages), and you must treat any annotation change — a return type going from non-nullable to nullable, or a parameter going the other way — as a breaking API change for nullable-aware consumers.

This "keep the belt and the suspenders" rule trips people up: once a parameter is annotated non-nullable, it feels redundant to also call ArgumentNullException.ThrowIfNull, but the annotation is compiler-only and non-nullable-looking callers can still pass null — from an unannotated caller, from reflection, or from a consumer who suppressed the warning with !. Runtime validation and compile-time annotation solve different problems and a library needs both. netstandard2.0 targets add a wrinkle: the nullable attributes live in System.Diagnostics.CodeAnalysis, which that target framework does not define, but the compiler recognizes them purely by name and namespace, so libraries commonly ship small internal polyfill copies of the attribute types to get full annotation support on older targets.

C#
namespace Contoso.Text;

public static class Slug
{
    public static string Create(string title)
    {
        ArgumentNullException.ThrowIfNull(title); // kept even though 'title' is non-nullable
        return title.Trim().ToLowerInvariant().Replace(' ', '-');
    }
}

The .NET runtime team's own published guidance for annotating the BCL is a good model to cite here: annotate intent (what the method is documented to accept or return), not just what happens to compile today, and when evidence conflicts about whether something can be null, prefer the nullable annotation, because a spurious warning is far cheaper for consumers than an unexpected null.

What interviewers look for: the "annotation changes are breaking changes" framing specifically, since it is the detail that separates someone who has shipped a library from someone who has only used NRT inside one application.

Common mistakes: removing ArgumentNullException guards from public methods after enabling NRT, on the theory that "the compiler already checks this" — it does not, at runtime, for any caller.

Q9 How does nullability interact with JSON serialization and with EF Core, and where does the compile-time contract actually get enforced?#

Short answer: By default, neither System.Text.Json nor EF Core enforces nullable annotations automatically at their respective boundaries: System.Text.Json only started reading them when you explicitly opt in with JsonSerializerOptions.RespectNullableAnnotations and RespectRequiredConstructorParameters, both since .NET 9, and EF Core instead uses nullability to decide column and property requiredness when building your model, which can silently change generated migrations rather than throwing at runtime.

For JSON, the opt-in options close a real gap: without them, a non-nullable string Name property happily deserializes a JSON null into it with no error, because the serializer's default behavior predates NRT and does not consult the annotation metadata. With RespectNullableAnnotations on, an explicit null for a non-nullable member throws a JsonException; a missing property, though, is a different condition entirely and still needs required or [JsonRequired] to be rejected, plus RespectRequiredConstructorParameters if the type binds through a constructor.

C#
using System.Text.Json;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    RespectNullableAnnotations = true,           // .NET 9+: explicit null now throws
    RespectRequiredConstructorParameters = true, // .NET 9+: missing required args now throw
};

// Throws JsonException: 'Sku' does not allow null values.
JsonSerializer.Deserialize<Product>("""{"sku":null,"name":"Desk"}""", options);

public sealed record Product(string Sku, string Name);

For EF Core, the enforcement point is model building, not query execution: a non-nullable string property becomes a NOT NULL column, and turning on NRT for an existing entity model can generate a migration that alters column nullability underneath data that may already contain nulls. ASP.NET Core MVC model binding sits at a third boundary: it treats non-nullable bound properties as implicitly [Required(AllowEmptyStrings = true)], which is usually desirable but can be switched off with MvcOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes when it is not.

What interviewers look for: naming that these are three separate, independently-behaving boundaries (serializer, ORM, model binder) rather than assuming "NRT" is one consistent runtime guarantee everywhere it touches data.

Follow-up questions:

  • What would you check before enabling NRT on an EF Core model that already has production data?
  • Why is a missing JSON property a different failure mode than an explicit JSON null?

Q10 NRT is "compile-time only, no runtime enforcement" — so concretely, how does a NullReferenceException still happen in fully NRT-enabled code?#

Short answer: Every boundary where an object is created or populated without going through your annotated code path can still hand you a null: reflection-based deserializers that ignore annotations by default, default(T) on a struct containing reference fields, arrays initialized with new string[10], oblivious callers compiled without the feature, and any ! in the codebase, yours or a dependency's.

Walking through the realistic list is what separates a senior answer from a recited definition. new string[10] produces ten null references with no warning, because array element nullability tracking has documented limits even with the feature fully enabled. A generic collection filtered with Where(x => x is not null) still has the static element type string?, not string, because LINQ's Where overload does not special-case a null-check predicate — only OfType<string>() actually changes the compile-time element type. A third-party NuGet package built before NRT existed, or one that annotated its API incorrectly, can hand you a null through a signature that claims non-nullable.

C#
string?[] rawNames = ["Ada", null, "Grace"];

var stillNullable = rawNames.Where(n => n is not null).ToList(); // type: List<string?>
foreach (var name in stillNullable)
{
    Console.WriteLine(name.Length); // CS8602 remains: the compiler is right to warn here
}

var actuallyNonNullable = rawNames.OfType<string>().ToList(); // type: List<string>, correctly narrowed

The unifying lesson is that NRT eliminates an entire class of bugs in code the compiler can see and that correctly describes its own contracts, but it is not a runtime firewall: deserialization, reflection, unsafe casts, oblivious dependencies and ! are all still live paths to NullReferenceException, and a senior engineer should be able to name several of them without prompting, not just recite "it's compile-time only."

What interviewers look for: at least three concrete, distinct failure modes (not just "someone used !"), showing the candidate has actually hit these in practice rather than memorized the one-line caveat.

Quick-Fire Round#

QuestionAnswer
Does NRT change the IL the compiler emits for a method body?No, it only adds metadata attributes; behavior is unchanged.
What are the two independent flags controlled by <Nullable>?The annotation context and the warning context.
What warning fires when a non-nullable member is uninitialized after a constructor?CS8618.
Does flow analysis look inside the bodies of methods you call?No, only at their declared signature and nullable attributes.
What attribute documents a Try-pattern out parameter's conditional nullability?[NotNullWhen(bool)].
Is required about nullability or about initialization?Initialization; it is independent of whether the member is nullable.
What does unconstrained T? mean for a value type T?"May be its default value," not Nullable<T>.
Since which .NET version can JsonSerializerOptions.RespectNullableAnnotations reject a null?.NET 9.
Does Where(x => x is not null) change a sequence's static element type?No; use OfType<T>() to actually narrow it.
Should public library methods keep ArgumentNullException checks after enabling NRT?Yes, always, for every caller that might not honor annotations.

How to Prepare#

  • Be ready to state, precisely, which two flags <Nullable>enable</Nullable> turns on and what each one does independently.
  • Practice writing [NotNullWhen] and [MemberNotNull] on a Try-pattern and a guard-clause example without looking them up.
  • Have a real, sequenced migration plan ready for "how would you roll this out on a 10-year-old codebase," including how you would measure progress.
  • Rehearse at least three concrete ways a NullReferenceException still happens in NRT-enabled code, beyond "someone used !."
  • Know the difference between required and ?, and what [SetsRequiredMembers] does and does not verify.
  • Review how System.Text.Json, EF Core and ASP.NET Core model binding each treat nullability differently at their own boundary.