C# Records vs Classes vs Structs: Which Should You Use?

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.

C# Type Comparison

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.

csharp
// 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:

  1. Run dotnet new console -n TypeChoiceDemo and then cd TypeChoiceDemo.
  2. Replace Program.cs with the code below. Top-level statements must come first, and the type declarations go after them.
  3. Run dotnet run.
csharp
// 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):

text
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.

csharp
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.

csharp
// 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.

csharp
// 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.

csharp
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:

csharp
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:

csharp
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.

Decision flowchart for choosing a class, record class, or readonly record struct Start by choosing a type. If the type needs identity, changing state, or inheritance, use a class. If not, ask whether it is small, about 16 bytes or less, and created very often. If yes, use a readonly record struct. If no, use a record class. Choose a type Needs identity, changing state, or inheritance? Yes Use a class entity, service No Small (16 bytes or less) and created often? Yes Use a readonly record struct No Use a record class DTOs, API payloads
Decision flowchart (conceptual): identity, changing state, or inheritance points to a class; small, frequently created values point to a readonly record struct; everything else points to 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.

csharp
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.

  1. Run dotnet new web -n OrdersApi and then cd OrdersApi.
  2. Run dotnet add package Microsoft.EntityFrameworkCore.Sqlite. The package's major version must match your target framework (10.x for net10.0).
  3. Replace Program.cs with the code below, then run dotnet run.
csharp
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:

text
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):

json
{"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 override Equals, GetHashCode, and the operators.
  • Changing a struct and finding the original unchanged. You modified a copy. Make the struct readonly so mutation isn't possible.
  • Losing the allocation savings of a struct to boxing. Avoid converting structs to object or 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.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com