Every C# codebase eventually forces this decision: should this type be a class, a struct, or a record? The question looks trivial until you've shipped a domain model with the wrong choice and spent an afternoon chasing a bug caused by unexpected reference equality, or a performance regression caused by boxing a struct somewhere nobody noticed.
Short answer for the C# records vs classes vs structs question:
use a record class for immutable data that crosses a boundary (API payloads,
DTOs), a readonly record struct for small
immutable values created very often, and a plain class for anything with
identity, changing state, inheritance, or service-style behavior. Plain
structs are rarely the right first choice.
This comparison is for developers who know basic C# syntax and want a working mental model that holds up in real production code, not just a syntax table. We'll cover value semantics (where a copy of the data is passed around) versus reference semantics (where a pointer to shared data is passed around), memory allocation, equality rules, and where each type causes problems in ASP.NET Core, EF Core (Entity Framework Core, Microsoft's object-relational mapper), and general application code. Two complete examples near the end let you run everything yourself.
A note on versions: records arrived in C# 9, record structs in C# 10, and primary constructors for classes and structs in C# 12. The examples here are written for .NET 10 (C# 14), which is a long-term support (LTS) release supported through November 2028. .NET 8 and .NET 9 both reach end of support on November 10, 2026, so new projects should target .NET 10 or later. The type-choice advice itself applies to any version from C# 10 on. C# 15 and .NET 11 are due in November 2026, so recheck the version details after they ship.
What you need before you start:
- The .NET 10 SDK or later (on .NET 8 or 9, change the target framework and use the matching EF Core package version)
- An editor such as Visual Studio, Rider, or VS Code with the C# extension
-
Basic C# knowledge: classes, properties, methods, and
var
C# Records vs Classes vs Structs: What Each One Is
A class is a reference type. Variables of a class type hold a reference (essentially a pointer) to an object that is normally allocated on the managed heap (the memory region the .NET garbage collector tracks and reclaims). Assigning a class instance to another variable copies the reference, not the data, so both variables point at the same object.
A struct is a value type. Variables of a struct type hold the actual data directly, wherever the variable lives: on the stack for a typical local variable, inline inside an object when it's a field of a class, or on the heap when boxed. The stack (the fast, short-lived memory region used for method calls and local variables) is an implementation detail, so think "the data lives inside the variable" rather than "structs live on the stack". Assigning a struct to another variable copies the entire value.
A record is not a third storage mechanism. It's a compiler
feature that adds value-based equality, a readable
ToString() override, support for the
with expression, and (for positional records)
a concise declaration syntax on top of an underlying type. Critically,
record class (or just
record) is still a reference type, while
record struct is a value type. Records don't
replace classes and structs; they sit on top of them.
// record class (reference type, value-based equality)
public record Customer(int Id, string Name, string Email);
// record struct (value type, value-based equality)
public readonly record struct Money(decimal Amount, string Currency);
// ordinary class (reference type, reference equality by default)
public class OrderProcessor
{
public void Process(Customer customer, Money total) { /* ... */ }
}
// ordinary struct (value type, no built-in value equality unless you add it)
public struct Point2D
{
public double X { get; init; }
public double Y { get; init; }
}
The Customer record above compiles into a
class with generated Equals,
GetHashCode,
== and
!= operators,
ToString, a
Deconstruct method, and a copy constructor
used by the with expression.
Money compiles into a struct with the same
generated members, but creating one needs no heap allocation.
Point2D, a plain struct, gets none of that,
so you'd compare fields manually or implement
IEquatable<T> and the operators
yourself.
One terminology trap: in a record, positional parameters become public properties. In a class or struct that uses a primary constructor (C# 12), the parameters are not properties. They're only captured values you can use inside the type.
Choosing "record" doesn't answer the value-vs-reference question by itself.
You still have to decide between
record class and
record struct, and that decision follows the
same rules as choosing between class and
struct.
A caution about Money: a
decimal is 16 bytes and a
string reference is 8 bytes on a 64-bit
runtime, so it's roughly 24 bytes. That's above the roughly 16-byte guideline
covered in the performance section. It's still a sensible way to model money,
but don't assume it saves allocations on a hot path without measuring.
Try It: A Complete Console Example
This example runs every behavior discussed so far: equality,
with, record struct equality, and the two
most common mutable-struct surprises. Create a project and replace the
contents of Program.cs:
-
Run
dotnet new console -n TypeChoiceDemoand thencd TypeChoiceDemo. -
Replace
Program.cswith the code below. Top-level statements must come first, and the type declarations go after them. - Run
dotnet run.
// 1. Equality: records compare by value, classes by reference
var a = new Customer(1, "Asha", "asha@example.com");
var b = new Customer(1, "Asha", "asha@example.com");
Console.WriteLine($"Records equal: {a == b}");
Console.WriteLine($"Same object: {object.ReferenceEquals(a, b)}");
var east1 = new Warehouse { Name = "East" };
var east2 = new Warehouse { Name = "East" };
Console.WriteLine($"Classes equal: {east1 == east2}");
// 2. The with expression builds a modified copy
var original = new ProductDto(1, "Wireless Mouse", 24.99m);
var discounted = original with { Price = 19.99m };
Console.WriteLine(original);
Console.WriteLine(discounted);
// 3. A record struct has value equality too
var m1 = new Money(5m, "USD");
var m2 = new Money(5m, "USD");
Console.WriteLine($"Money equal: {m1 == m2}");
Console.WriteLine(m1);
// 4. Mutable struct pitfalls: both calls change a copy, not the original
var counter = new MutableCounter();
IncrementCopy(counter);
Console.WriteLine($"After passing by value: {counter.Count}");
var counters = new List<MutableCounter> { new(), new() };
foreach (var c in counters)
{
c.Increment();
}
Console.WriteLine($"After foreach: {counters[0].Count}");
// 5. A record holding a List shares that list after a with expression
var teamA = new Team("Backend", new List<string> { "Asha" });
var teamB = teamA with { Name = "Backend v2" };
teamB.Members.Add("Priya");
Console.WriteLine(string.Join(", ", teamA.Members));
static void IncrementCopy(MutableCounter c) => c.Increment();
public record Customer(int Id, string Name, string Email);
public class Warehouse
{
public string Name { get; init; } = "";
}
public record ProductDto(int Id, string Name, decimal Price);
public readonly record struct Money(decimal Amount, string Currency);
public struct MutableCounter
{
public int Count;
public void Increment() => Count++;
}
public record Team(string Name, List<string> Members);
Expected output (the decimal separator follows your culture settings, so a
non-US culture may print 24,99):
Records equal: True
Same object: False
Classes equal: False
ProductDto { Id = 1, Name = Wireless Mouse, Price = 24.99 }
ProductDto { Id = 1, Name = Wireless Mouse, Price = 19.99 }
Money equal: True
Money { Amount = 5, Currency = USD }
After passing by value: 0
After foreach: 0
Asha, Priya
Read the output line by line. Two separate
Customer objects with the same data are
equal, but they are still two objects. Two
Warehouse objects with the same data are not
equal. Both mutable-struct calls changed a copy, which is why the counters
stay at 0. And the last line shows Priya in
teamA, because the
with expression copied the reference to the
list, not the list.
Key Differences
The table below is the fast reference. The nuance underneath it is where the real decisions happen.
| Aspect | Class | Struct | Record (class) | Record struct |
|---|---|---|---|---|
| Storage | Reference type, normally heap-allocated | Value type, stored inline where declared | Reference type, normally heap-allocated | Value type, stored inline where declared |
| Default equality | Reference equality |
Field-by-field via ValueType.Equals,
which can fall back to slower reflection; no
== operator unless you define one
|
Value equality (generated, includes ==)
|
Value equality (generated, includes ==)
|
| Mutability default | Mutable | Mutable (not recommended) |
Positional properties are init-only; properties you declare yourself are
mutable unless you use init
|
Positional properties are mutable by default; use
readonly record struct for immutability
|
with expression |
Not supported | Supported (C# 10+) | Supported | Supported |
| Inheritance | Full support | Not supported (structs are implicitly sealed) | Can inherit from records only; can implement interfaces | No inheritance; can implement interfaces |
ToString() |
Default prints type name | Default prints type name | Generated, prints property values | Generated, prints property values |
| Typical use | Entities, services, aggregates with identity | Small, short-lived, performance-sensitive values | DTOs, immutable domain values, API payloads | Small immutable value types needing structural equality |
A few things in that table are exactly where developers get surprised in production.
Struct equality by default is field-by-field, using the implementation
inherited from System.ValueType. Depending on
the struct's fields, that implementation may need reflection, and it's
generally slower than the equality members a record struct generates.
Comparing two plain struct instances with
== is a compile error unless you overload
operator == and
operator !=. Implementing
IEquatable<T> or overriding
Equals alone does not add
==.
Record inheritance only works between records. You cannot inherit a
record from a plain
class, or a class from a record. If your
domain model needs a class hierarchy with shared behavior and polymorphism,
plain classes are still the right tool. Records are not a general-purpose OOP
(object-oriented programming) replacement. Unless you're deliberately building
a record hierarchy, mark records sealed so
nobody accidentally derives from them and runs into the equality rules covered
later.
Performance — Allocation, Boxing, and What Actually Costs You
This article doesn't include benchmark numbers. Any specific nanosecond figure only means something if you measured it with BenchmarkDotNet on your own framework version and hardware. What follows is the qualitative behavior that is well established in the language and runtime, so you can verify it yourself.
Reference types (classes, record classes) are normally allocated on the heap. Each allocation adds pressure to the garbage collector, and in high-throughput paths, such as a hot loop processing millions of small objects, that pressure can dominate the cost. Recent .NET versions can sometimes avoid the heap for objects that never escape a method, but you shouldn't rely on that. Value types (structs, record structs) avoid a separate heap allocation when used as local variables, method parameters, or array elements, which is why they're attractive for small, frequently created values like coordinates or IDs.
But "struct avoids heap allocation" has a well-known exception: boxing. Boxing
happens when a value type is converted to a reference type, most commonly when
it's assigned to an object variable,
converted to an interface type, or added to a non-generic collection. Boxing
allocates a new object on the heap and copies the struct's data into it, which
defeats the point of using a struct to avoid allocations.
public readonly record struct OrderId(Guid Value);
// No boxing: generic collections are type-specialized
var ids = new List<OrderId>();
ids.Add(new OrderId(Guid.NewGuid())); // stored inline, no heap allocation per item
// Boxing occurs: converting a struct to object or to any interface type
object boxed = new OrderId(Guid.NewGuid()); // heap allocation happens here
IFormattable asInterface = DateTime.Now; // also boxes: DateTime is a struct, IFormattable is an interface
A List<T> of a struct type stores the
structs inline in its backing array, with no per-item boxing. But the moment
you store that same struct in an ArrayList,
pass it where an object parameter is
expected, or assign it to an interface-typed variable, you trigger boxing.
Generic methods with an interface constraint (where T : IFormattable, for example) can call interface members without boxing. Record structs
don't change any of this.
Dictionary keys are a related trap. A plain struct that doesn't implement
IEquatable<T> can force boxing when
used as a Dictionary key, while a record
struct generates IEquatable<T> for you.
There's also a cost on the other side: copying. A large struct passed by value
gets copied every time it's passed to a method, returned, or stored in a field
of another struct. That's why Microsoft's design guidelines suggest a struct
should represent a single value, be immutable, be rarely boxed, and stay
small. About 16 bytes is the usual rule of thumb. For larger structs,
in parameters can avoid the copy.
// Large struct — a case where "just use a struct for performance" backfires
public readonly struct LargeVector
{
public readonly double X, Y, Z, W, A, B, C, D;
// 8 doubles = 64 bytes, copied on every pass-by-value call
}
public static class VectorMath
{
// Passing by reference avoids the copy without giving up value semantics
public static double Magnitude(in LargeVector v)
=> Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z + v.W * v.W
+ v.A * v.A + v.B * v.B + v.C * v.C + v.D * v.D);
}
Marking the struct readonly, as above,
matters here. It lets the compiler skip defensive copies when you pass the
struct with in.
The practical takeaway: avoiding allocations with structs is real and worth using for small, hot-path values, but it's not a universal performance win. If you're unsure whether a type should be a struct, measure it with BenchmarkDotNet against your actual access pattern rather than assuming "value type equals faster."
Developer Experience — Syntax, Tooling, and Learning Curve
Records are pleasant to write once the syntax clicks, which is why they're a common choice for DTOs (data transfer objects: simple objects used to move data between layers or across a network boundary) in newer codebases.
// Positional record — concise, generates a primary constructor,
// init-only properties, equality, ToString, and deconstruction
public record ProductDto(int Id, string Name, decimal Price);
// Equivalent longhand, for comparison
public class ProductDtoLonghand
{
public int Id { get; init; }
public string Name { get; init; }
public decimal Price { get; init; }
public ProductDtoLonghand(int id, string name, decimal price)
{
Id = id;
Name = name;
Price = price;
}
// Plus hand-written Equals, GetHashCode, ToString, and some way to build
// a modified copy — none of this comes free.
}
The with expression is the feature that sells
records to most teams once they try it. It produces a new instance with one or
more properties changed, leaving the original untouched, which is exactly the
pattern you want for immutable domain values. You saw it run in the console
example above.
Pattern matching is where records earn their keep in business logic.
Positional records deconstruct naturally into
switch expressions, which reads far better
than a chain of if statements checking
individual properties.
public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public static double Area(Shape shape) => shape switch
{
Circle(var r) => Math.PI * r * r,
Rectangle(var w, var h) => w * h,
_ => throw new ArgumentException("Unknown shape", nameof(shape))
};
This Shape hierarchy is a reasonable use of
record inheritance: a small, closed set of related immutable variants. The
pattern is often called a discriminated union. In the released C# versions
this article targets there is no built-in union type, so the hierarchy plus a
switch expression is the usual workaround. C#
15, due with .NET 11 in November 2026, adds a
union keyword that is still a preview
feature, so check the C# language reference before relying on it.
On the tooling side, Visual Studio, Rider, and the C# extension for VS Code
all understand records fully. IntelliSense, refactoring, and the debugger's
data tips display generated properties and the
with expression correctly. The learning curve
is mostly about unlearning the assumption that
== means reference equality, since that
default flips for records and record structs.
Three records caveats cause real production bugs.
1. Records aren't deeply immutable. If a record has a
property of type List<string>, the
with expression copies the
reference to that list, not its contents, as the
Team example showed. Equality has the same
blind spot. Generated equality compares each property with that property's own
equality, so two records holding different
List<string> objects with identical
contents are not equal.
2. ImmutableArray<T> fixes mutation
but not equality.
It prevents changes, but two
ImmutableArray<T> values are equal only
if they wrap the same underlying array.
IReadOnlyList<T> only hides the
mutating methods, and whoever created the list can still change it. If you
want records with collection properties to compare by content, write the
equality yourself:
using System.Collections.Immutable;
public sealed record Basket(string Owner, ImmutableArray<string> Items)
{
public bool Equals(Basket? other) =>
other is not null
&& Owner == other.Owner
&& Enumerable.SequenceEqual(Items, other.Items);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(Owner);
foreach (var item in Items)
{
hash.Add(item);
}
return hash.ToHashCode();
}
}
Note that a default
ImmutableArray<T> is uninitialized and
throws when enumerated, so make sure it's always assigned.
3. Mutable records and printed members. A record with
settable properties used as a dictionary key or in a
HashSet breaks the collection if a property
changes after insertion, because its hash code changes. Prefer init-only
properties for anything used as a key. Also, the generated
ToString() prints every property. If you log
a record containing emails, tokens, or passwords, they end up in your logs.
Override ToString() to hide sensitive
members:
public sealed record SignupRequest(string Email, string Password)
{
public override string ToString() => $"SignupRequest {{ Email = {Email}, Password = *** }}";
}
Use Cases — Which to Pick and When
It's easier to decide by the object's role than by a generic rule. The
flowchart below shows the decision path used in the rest of this section, with
a typical ASP.NET Core and EF Core application in mind. It first asks whether
the type has identity, changing state, or inheritance, and if so the answer is
a class. Otherwise it asks whether the type is small and created very often,
which points to a readonly record struct.
Everything else becomes a record class.
For API request and response models, meaning the shapes crossing an HTTP
boundary, a positional record is usually a good default. They're immutable by
nature, which avoids a whole category of bugs where code accidentally mutates
a shared model. In ASP.NET Core,
System.Text.Json deserializes them through
the primary constructor, matching JSON property names to constructor parameter
names case-insensitively (that's the ASP.NET Core default; a bare
JsonSerializer is case-sensitive unless
configured). Where names don't match, put the attribute on the generated
property with
[property: JsonPropertyName("name")].
One thing to remember: if a client leaves a property out of the JSON, the
matching constructor parameter receives its default value (0
or null), even when the type isn't declared
nullable. Validate request records explicitly, as the example in the next
section does.
For EF Core entities, be deliberate. EF Core's change tracker identifies
entities by reference, and a record's value-based equality works against that:
two different tracked rows with identical values would compare as equal, and a
with expression creates a new instance the
change tracker doesn't know about. For that reason, records are generally a
poor fit as entity types. Use plain classes with private setters or
init accessors for entities, and use records
for the DTOs at the API boundary, including query projections. EF Core 8 and
later can also map records and structs as complex types (value objects without
their own identity), which suits records better than entities. Check
Microsoft's EF Core documentation for current limitations before using them.
For small value objects used heavily in calculations (coordinates, quantities
with units, identifiers wrapping a Guid or
int),
readonly record struct is usually the better
fit, provided the type stays small. You get value equality, a readable
ToString(), and no separate heap allocation
for each instance. Values that combine a
decimal with a currency, like
Money earlier, exceed the 16-byte guideline.
Start with a record class there unless a benchmark says otherwise.
public readonly record struct Temperature(double Celsius)
{
public double Fahrenheit => Celsius * 9 / 5 + 32;
}
var boiling = new Temperature(100);
Console.WriteLine(boiling.Fahrenheit); // 212
(In a real project, put the
Temperature declaration below any top-level
statements, or in its own file.)
For services, repositories, handlers, and anything with identity and behavior
rather than just data (think OrderProcessor,
EmailSender,
UserRepository), a plain class is still
correct. Records exist to model data. They're a poor fit for types whose job
is to perform actions, hold dependencies injected via a constructor, or
participate in a dependency injection container's lifetime management.
Registering a record in
IServiceCollection isn't technically wrong,
but immutability and value equality add nothing to a stateless service class
and can confuse a teammate reading the code later.
Production-Style Example: An Orders API with EF Core
This example puts the advice together: a class entity that protects its own state, record DTOs at the boundary, explicit request validation, and an EF Core query that projects straight into a record. It's a small ASP.NET Core Minimal API with SQLite.
-
Run
dotnet new web -n OrdersApiand thencd OrdersApi. -
Run
dotnet add package Microsoft.EntityFrameworkCore.Sqlite. The package's major version must match your target framework (10.x fornet10.0). -
Replace
Program.cswith the code below, then rundotnet run.
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopContext>(o => o.UseSqlite("Data Source=shop.db"));
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var app = builder.Build();
// Demo only: use EF Core migrations for real projects
using (var scope = app.Services.CreateScope())
{
scope.ServiceProvider.GetRequiredService<ShopContext>().Database.EnsureCreated();
}
app.MapPost("/api/orders",
async Task<Results<Created<OrderSummaryDto>, ValidationProblem>>
(CreateOrderRequest request, ShopContext db, CancellationToken ct) =>
{
var errors = request.Validate();
if (errors.Count > 0)
{
return TypedResults.ValidationProblem(errors);
}
var order = new Order(request.CustomerId);
foreach (var line in request.Lines!)
{
order.AddLine(line.ProductId, line.Quantity);
}
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
var summary = new OrderSummaryDto(order.Id, order.CustomerId, order.Status, order.Lines.Count);
return TypedResults.Created($"/api/orders/{order.Id}", summary);
});
app.MapGet("/api/orders/{id:int}",
async Task<Results<Ok<OrderSummaryDto>, NotFound>>
(int id, ShopContext db, CancellationToken ct) =>
{
// Project straight into the record: no tracking, only the columns we need
var summary = await db.Orders
.AsNoTracking()
.Where(o => o.Id == id)
.Select(o => new OrderSummaryDto(o.Id, o.CustomerId, o.Status, o.Lines.Count()))
.FirstOrDefaultAsync(ct);
if (summary is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(summary);
});
app.MapPost("/api/orders/{id:int}/ship",
async Task<Results<NoContent, NotFound, Conflict<string>>>
(int id, ShopContext db, CancellationToken ct) =>
{
// The entity is a class so EF Core can track it and detect the change
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
if (order is null)
{
return TypedResults.NotFound();
}
if (order.Status != OrderStatus.Pending)
{
return TypedResults.Conflict("Only pending orders can be shipped.");
}
order.MarkShipped();
await db.SaveChangesAsync(ct);
return TypedResults.NoContent();
});
app.Run();
// ---- Request and response records (the API boundary) ----
public sealed record OrderLineDto(int ProductId, int Quantity);
public sealed record CreateOrderRequest(int CustomerId, List<OrderLineDto>? Lines)
{
public Dictionary<string, string[]> Validate()
{
var errors = new Dictionary<string, string[]>();
if (CustomerId <= 0)
{
errors["customerId"] = ["CustomerId must be a positive number."];
}
if (Lines is null || Lines.Count == 0)
{
errors["lines"] = ["Order must contain at least one line."];
}
else if (Lines.Any(l => l.ProductId <= 0 || l.Quantity <= 0))
{
errors["lines"] = ["Every line needs a positive ProductId and Quantity."];
}
return errors;
}
}
public sealed record OrderSummaryDto(int Id, int CustomerId, OrderStatus Status, int LineCount);
// ---- Entities (classes: identity and changing state) ----
public enum OrderStatus { Pending, Shipped }
public class Order
{
private readonly List<OrderLine> _lines = [];
public int Id { get; private set; }
public int CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines;
private Order() { } // lets EF Core materialize the entity without calling the public constructor
public Order(int customerId)
{
CustomerId = customerId;
Status = OrderStatus.Pending;
}
public void AddLine(int productId, int quantity) => _lines.Add(new OrderLine(productId, quantity));
public void MarkShipped() => Status = OrderStatus.Shipped;
}
public class OrderLine
{
public int Id { get; private set; }
public int ProductId { get; private set; }
public int Quantity { get; private set; }
private OrderLine() { }
public OrderLine(int productId, int quantity)
{
ProductId = productId;
Quantity = quantity;
}
}
public class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(order =>
{
order.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
order.Navigation(o => o.Lines)
.HasField("_lines")
.UsePropertyAccessMode(PropertyAccessMode.Field);
});
}
}
Why each type was chosen: the DTOs are sealed records because they're
immutable data crossing the HTTP boundary.
Order and
OrderLine are classes because EF Core tracks
them by identity and Order changes state over
time. ShopContext is a class because it's a
service with behavior. The Validate method
treats Lines as nullable because a missing
JSON property arrives as null.
To check it, call the API from a second terminal, replacing the URL with the
one printed by dotnet run:
curl -i -X POST http://localhost:5000/api/orders -H "Content-Type: application/json" -d '{"customerId":42,"lines":[{"productId":7,"quantity":2},{"productId":9,"quantity":1}]}'
Expected result: a 201 Created response with
a Location header of
/api/orders/1 and a body like this (the
id grows with each new order):
{"id":1,"customerId":42,"status":"Pending","lineCount":2}
Sending {"customerId":42,"lines":[]} should
return 400 Bad Request with an
errors object containing a
lines entry. Calling
POST /api/orders/1/ship returns
204 No Content the first time and
409 Conflict the second. If you change the
entities later, delete shop.db so
EnsureCreated rebuilds the schema.
Limitations — What Each One Doesn't Do Well
Classes carry the overhead of heap allocation, and they surprise developers
who forget that comparing two class instances with
== checks identity by default, not content,
unless Equals and the operators are
overridden. They're also verbose for simple data-holding types compared to
records.
Structs get risky once they stop being small. Passing a large struct by value copies all its fields on every call, and a struct stored in a field of a reference type lives inside that object on the heap. Mutable structs are a well-documented source of bugs because modifying a copy silently doesn't affect the original, as the console example showed. Structs also can't participate in inheritance, which rules them out for any type that needs polymorphic behavior.
Records inherit the limitations of whichever underlying type they wrap. A
record class still allocates on the heap, and
a record struct has the same copy-cost and
boxing risks as a plain struct. Record inheritance is more restrictive than
class inheritance, and equality between records only succeeds when both
instances are the exact same runtime type. A
Customer and a subclassed
PremiumCustomer with identical property
values are never equal to each other.
Positional records combined with inheritance get confusing fast. Generated
equality compares every member, including those added by derived types, and a
derived record's ToString() prints members
from both the base and derived types. Keep record hierarchies shallow, like
the Shape example, and write a quick test for
equality behavior before relying on it in a deeper hierarchy.
Common Beginner Mistakes and How to Fix Them
-
Expecting
==on two class instances to compare their data. Classes compare by reference. Use a record, or overrideEquals,GetHashCode, and the operators. -
Changing a struct and finding the original unchanged. You
modified a copy. Make the struct
readonlyso mutation isn't possible. -
Losing the allocation savings of a struct to boxing. Avoid
converting structs to
objector interface types in hot paths. Use generic constraints instead. -
Assuming a record is deeply immutable. A
List<T>property is still shared and changeable.ImmutableArray<T>prevents changes but needs custom equality if you want content comparison. - Using a record as an EF Core entity. Value equality conflicts with change tracking. Use a class for the entity and a record for the DTO.
-
Logging a record that holds secrets. The generated
ToString()prints every property. Override it for sensitive types.
Frequently Asked Questions
Is a C# record a class or a struct?
Either. A plain record is a reference type (a
class). A record struct is a value type. Both
get generated value equality, ToString(), and
with support.
Are records immutable?
Only partly. Positional properties on a record class are init-only, but
properties you declare yourself can be mutable, positional properties on a
record struct are mutable unless you use
readonly record struct, and any mutable
object a record holds (such as a
List<T>) can still change.
Are structs faster than classes?
Sometimes. Small structs can avoid heap allocations, but large structs are costly to copy and boxing brings the allocations back. Measure with BenchmarkDotNet on your own workload.
Can a record inherit from a class?
No. A record can only inherit from another record, and a class can't inherit from a record. Records can implement interfaces.
Should I use records for EF Core entities?
Generally no. EF Core's change tracking relies on reference identity, which conflicts with value equality. Use classes for entities and records for DTOs and projections.
Conclusion
The short version of C# records vs classes vs structs: reach for a record
class when you're modeling immutable data that crosses a boundary, such as API
contracts, DTOs, and event payloads, and you want value equality and
with-expression updates for free. Reach for a
readonly record struct when that same kind of
immutable data is small and created often enough that heap allocation would
actually show up in a profiler. Keep plain classes for anything with identity,
mutable state over time, inheritance-based polymorphism, or service-style
behavior. Keep plain structs mostly for cases where you need value semantics
but don't want the generated equality and
ToString() that records add.
Key takeaways:
- A record is a class or struct with extra compiler-generated members. The value-versus-reference question still has to be answered.
- Records compare by value. Classes compare by reference unless you override equality.
- Structs avoid allocations only while they stay small and unboxed.
- Records don't give you deep immutability or content-based equality for collections.
- Use classes for EF Core entities and records for DTOs, and seal records you don't intend to inherit from.
Before defaulting to a record struct for performance reasons, profile it with BenchmarkDotNet against your real access pattern, because copying and boxing can erase the gains. If you want to go deeper next, look at how EF Core maps records as complex types, which is one of the sharper edges in this comparison and worth its own read.
