Articles
C# Record Interview Questions: Value Equality, with Expressions, and Mutable Members
Practice C# record interview questions with equality traces, shallow with copies, mutable-member pitfalls, and safer dictionary-key design decisions.
Two records print the same tags but compare unequal. A with expression creates a new record, yet changing a list through the copy also changes the original. These are not contradictory results. They expose two separate contracts: how members compare and what copying those members preserves.
Official language behavior: For C# record interview questions, start with this answer: a record supplies data-oriented equality, but a record class remains a reference type. Its members retain their own equality rules. A normal with copy is shallow, and init does not freeze an object graph. Explain those boundaries before recommending records as cache keys or immutable snapshots. Microsoft documents both record equality and with copying.
The exercises below are original examples grounded in Microsoft documentation, not reported employer prompts. No candidate reports establish question frequency; modeling recommendations are original inference.
Is a record a class, a struct, or an equality policy?
A record is a C# declaration form that synthesizes data-oriented operations, including equality and copying support. record and record class declare reference types. record struct declares a value type. The declaration form does not determine whether everything reachable through an instance is immutable.
Microsoft's record reference distinguishes these forms. In an interview, draw three columns mentally: storage category, generated operations, and mutation policy. A record class can have setters. A positional record struct normally has writable properties; a positional readonly record struct has a different contract.
Do not say “records are structs with nicer syntax” or “records are immutable classes.” Either shortcut misses a counterexample before the discussion reaches equality. Ask whether the domain represents a value, an identity-bearing entity, or a mutable working object. Concise syntax is useful only after that decision.
What does value equality compare?
Consider this original model:
public sealed record Shipment(string Code, List<string> Tags);
var a = new Shipment("S1", new List<string> { "fragile" });
var b = new Shipment("S1", new List<string> { "fragile" });
var c = a with { };
The expected comparisons are a == b false, a == c true, and ReferenceEquals(a, c) false. The code describes three record instances but only two tag lists. Record equality compares the list members using their equality behavior, not by recursively comparing each tag.
Microsoft's equality guide explicitly identifies this mutable-collection trap. Two independent lists can contain the same strings while remaining different objects. Conversely, two records sharing one list can continue comparing equal after that list changes.
Trace the object graph rather than only printing ToString(). Printed content is a debugging aid, not a complete equality specification. A useful test distinguishes equal scalar members, identical collection references, different collections with equal contents, and different contents. Those cases reveal what the type actually promises.
Before any tag mutation, the following documentation-derived prediction matrix separates the two contracts. It is not a transcript of a local .NET run.
Every comparison below uses a different outer object. The result depends on the scalar values and the list reference.
| Compare with a | Equality and reason |
|---|---|
| b: same code, equal-content list built separately | False: different list references. |
| c: empty with copy | True: same code and same list reference. |
| a with a newly allocated Tags list | False: different list references. |
| a with Code changed to S2 | False: different code, despite the shared list. |
Now mutate the shared list and repeat the observations. The empty copy still shares the same list reference; the version with a detached container does not observe that container mutation. Neither observation proves the desired business rule for tag equality. A test that expects the detached version to compare equal is asking for sequence semantics the generated record never supplied.
Why does with not detach mutable members?
A shallow copy creates a new outer object while copying its member values. When a member value is a reference, the new object points to the same referenced object. Microsoft documents this behavior in the with expression reference.
Extend the shipment fixture:
var copy = a with { Code = "S2" };
copy.Tags.Add("insured");
Console.WriteLine(a.Tags.Count); // expected: 2
Console.WriteLine(ReferenceEquals(a.Tags, copy.Tags)); // expected: True
The original shipment still has code S1, so the outer update did not mutate that property. Its tags changed because both objects refer to one list. “Nondestructive mutation” describes the outer copying operation; it does not guarantee an isolated snapshot of every nested value.
For detached list storage, use a with { Tags = new List<string>(a.Tags) }. That copies the list container, but it would still share mutable element objects if the element type were not an immutable string. Say exactly which level is detached. A claim of “deep copy” needs a policy for every reachable reference, including cycles and intentionally shared objects.
Does init mean deeply immutable?
An init accessor limits when a property can be assigned. It does not disable methods on the object referenced by that property. Microsoft's init documentation describes assignment during construction and initialization, rather than recursive freezing.
The positional Shipment.Tags property cannot simply be reassigned in ordinary later code under the generated record-class contract. Calling Tags.Add, however, changes the referenced list. A read-only collection interface also limits the operations visible through that interface; another owner may still hold a writable alias.
An interview answer should separate read-only access, immutable storage, and exclusive ownership. These are different protections. A defensive copy at construction prevents the caller's original list from remaining an alias, but exposing the copied list publicly reintroduces mutation access. Protect both the input boundary and the output boundary.
If the domain requires an immutable value with sequence equality, choose a representation and comparer that supply both. An immutable collection does not automatically imply the desired element-by-element equality for the containing record. Verify its equality contract instead of transferring assumptions from the word “immutable.”
How can a record become an unsafe dictionary key?
A hash-based collection relies on a key's equality and hash behavior remaining coherent while it is stored. Microsoft's GetHashCode contract warns about changing objects used in hash-based collections. Equal values must produce equal hash codes, but unequal values may collide.
This original declaration makes the danger explicit:
public sealed record MutableKey
{
public int Revision { get; set; }
}
var key = new MutableKey { Revision = 1 };
var cache = new Dictionary<MutableKey, string> { [key] = "result" };
key.Revision = 2;
Changing a field involved in synthesized equality can change the key's hash after insertion. The dictionary's previous placement does not become a reliable new placement because the object changed. Do not promise one exact lookup result across every hash implementation and collision pattern; the engineering defect is violating stable-key assumptions.
Also avoid the opposite overstatement: mutating the contents of a standard list member does not necessarily change a record's synthesized hash, because that member uses reference-based equality and hashing. That can preserve lookup placement while making the cached result semantically stale. Stable hashing and correct cache identity are separate questions.
For a shipment cache, decide which attributes determine the result. If tag content matters, an identity-based list member is a poor accidental substitute for a sequence key. Create an explicit immutable cache-key projection and document ordering, duplicates, case sensitivity, and normalization. Do not hide those domain decisions inside a generic “make it a record” recommendation.
What happens to derived records and computed values?
Record-class equality accounts for the runtime record type. A base record and a derived record are not interchangeable equality values merely because their shared fields match. This avoids silently treating extra derived state as irrelevant. Keep the example's declaration hierarchy visible when predicting equality.
A with operation also preserves the operand's runtime type. If a variable is statically typed as a base record, its copy can still be an instance of the derived record. The statically available initializer members are a separate issue. This is a useful distinction between object identity, runtime representation, and compile-time access.
Computed members introduce another trap. Suppose a stored DisplayCode is initialized from Code once, and a later copy changes Code. The stored value can remain based on the original input. A computed getter such as DisplayCode => Code.ToUpperInvariant() evaluates against the copied object's current code instead.
That is a policy choice, not a blanket ban on caching. If computing a derived value is expensive, design when it is rebuilt and which updates invalidate it. Verify that equality does not unintentionally include stale backing state. A performance optimization should not quietly change the domain's value definition.
How would you repair the shipment model?
Begin by writing the contract in ordinary language. For this exercise, shipment code and the ordered tag sequence determine value equality; updating a shipment must not mutate an earlier snapshot. This is an original design requirement, not a universal logistics model. Another application may consider code alone the identity.
Then choose the smallest representation satisfying that contract. A dedicated immutable tag value can own defensive storage and provide sequence equality. A custom equality implementation can work too, but its hash must follow the same sequence, ordering, null, and normalization rules. Test equality and hashing as one feature.
Do not “fix” equality by sorting tags unless the domain says order is irrelevant. Sorting changes the meaning of [urgent, fragile] versus [fragile, urgent], and deduplicating changes repeated tags. A cleaner representation is not automatically a correct representation. State those assumptions before implementing them.
For copying, distinguish a policy owned by the record from a call-site workaround. A record-class copy constructor can customize copying, but that introduces maintenance responsibilities as members are added. Record-struct copying has a different mechanism; do not transfer a custom record-class copy constructor recipe unchanged.
The final review question is whether records are the best fit at all. An identity-bearing entity with a mutable lifecycle may be clearer as a normal class with an explicit value projection for comparison. A record is useful when its generated behavior matches the domain, not because it eliminates all modeling work.
Practice the ownership explanation
| Practice question | Record-specific follow-up |
|---|---|
| Design and Implement a Thread-Safe LRU Cache | Define a stable value key separately from cached mutable results. |
| Shopping Cart with Undo and Redo for Add and Remove Operations | Check that earlier snapshots do not share writable collection storage. |
| Design a KV store with transactions | Separate entity identity from versioned value equality. |
| Validate Unit-Test Coverage and Identify Missing Scenarios | Add distinct-list, shared-list, and nested mutation cases. |
| Implement buffered file writer with concurrency support | State whether copying an operation also copies its buffer. |
Build a falsifiable answer, not a slogan
For each comparison, record outer identity, scalar values, nested identity, nested content, and runtime type. Predict the result before running it. Then change one dimension at a time. Add a test that modifies a nested element after the copy and checks both snapshots. That approach makes a failed prediction useful: it identifies the exact equality or copying rule you misunderstood.
The C# snippets here are documentation-reviewed expectations, not claims of a local .NET execution. Run them in the target SDK and language configuration before presenting compiler output as evidence. Keep custom comparers out of the baseline fixture; add them only after the generated behavior is understood.
For C# record interview questions, the strongest conclusion is precise: generated equality does not imply deep equality, a new outer record does not imply independent nested storage, and an init-only property does not imply a frozen graph.
Sources and Further Reading
- Microsoft: Record types
- Microsoft: The with expression
- Microsoft: C# equality comparisons
- Microsoft: Object.GetHashCode
- Microsoft: The init accessor
Research note: Official documentation rechecked October 1, 2026. Examples and modeling recommendations are original; target SDK, language version, member types, and custom equality implementations can change the applicable behavior.