Overview
Records in C# are data-focused types that provide built-in support for value-based equality, concise declaration syntax, readable ToString() output, deconstruction, and nondestructive mutation through with expressions.
A record can be declared as a reference type or a value type:
public record CustomerDto(Guid Id, string Name); // record class by default
public record class CustomerViewModel(Guid Id, string Name); // explicit reference type
public record struct Money(decimal Amount, string Currency); // value type
public readonly record struct Point(int X, int Y); // immutable value type
Records matter because modern C# applications often use small data-carrier types for API responses, commands, queries, configuration values, domain events, integration messages, and DTOs. Without records, developers often write repetitive code for constructors, equality, GetHashCode(), ToString(), and copy operations. Records reduce that boilerplate while making the intent of the type clearer.
Records are important for interviews because they test whether a developer understands more than syntax. A strong candidate should be able to explain value equality, immutability, reference type versus value type behavior, with expressions, positional records, inheritance limitations, and when records should not be used. Interviewers often ask records together with classes, structs, DTOs, Entity Framework Core entities, immutability, and clean architecture boundaries.
Core Concepts
What a Record Is
A record is a C# type designed primarily to store data. The record modifier tells the compiler to generate members that are useful for data models.
For a typical record, the compiler can generate:
- a primary constructor
- public properties from positional parameters
- value-based equality
EqualsGetHashCode==and!=operatorsToStringDeconstruct- support for
withexpressions
Example:
public record UserDto(int Id, string Email);
var user1 = new UserDto(1, "[email protected]");
var user2 = new UserDto(1, "[email protected]");
Console.WriteLine(user1 == user2); // True
Console.WriteLine(user1); // UserDto { Id = 1, Email = [email protected] }
With a normal class, == usually checks whether two variables refer to the same object unless equality is manually implemented. With a record, equality is value-based by default.
Record Class, Record Struct, and Readonly Record Struct
C# supports several record forms.
public record ProductDto(int Id, string Name);
This is shorthand for a record class, so it is a reference type.
public record class ProductDto(int Id, string Name);
This is the explicit reference-type form.
public record struct Coordinate(double Latitude, double Longitude);
This creates a value-type record.
public readonly record struct Coordinate(double Latitude, double Longitude);
This creates a readonly value-type record, which is useful for small immutable values.
The important distinction is that record does not automatically mean value type. A record class is still a reference type. A record struct is a value type.
Positional Records
A positional record declares its data members directly in the type declaration.
public record OrderSummary(int OrderId, decimal TotalAmount, string Status);
The compiler generates a constructor and public properties.
var summary = new OrderSummary(1001, 250.75m, "Paid");
Console.WriteLine(summary.OrderId);
Console.WriteLine(summary.TotalAmount);
Console.WriteLine(summary.Status);
Positional records are compact and useful for DTOs, read models, messages, and value-like data.
A positional record also supports deconstruction:
var (orderId, totalAmount, status) = summary;
Console.WriteLine(orderId);
Console.WriteLine(totalAmount);
Console.WriteLine(status);
This is convenient when the shape of the data is small and obvious. However, positional records can become hard to read when they contain many fields or fields of the same type.
Less readable:
public record Address(string Line1, string Line2, string City, string State, string Country, string PostalCode);
For larger models, property-based syntax is often clearer.
Property-Based Records
Records can also be declared with normal property syntax.
public record CreateCustomerRequest
{
public required string Name { get; init; }
public required string Email { get; init; }
public string? PhoneNumber { get; init; }
}
This style is useful when:
- the type has many properties
- optional properties are involved
- property names improve readability
- validation attributes are used
- the object is serialized or deserialized
- the model is used in an API request or response
Example:
var request = new CreateCustomerRequest
{
Name = "Alice",
Email = "[email protected]",
PhoneNumber = "123456789"
};
This style is usually more maintainable for ASP.NET Core request and response models than long positional constructors.
Value-Based Equality
The most important feature of records is value-based equality.
public record CustomerDto(int Id, string Name);
var a = new CustomerDto(1, "Alice");
var b = new CustomerDto(1, "Alice");
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
For a normal class:
public class CustomerClass
{
public int Id { get; init; }
public string Name { get; init; } = "";
}
var a = new CustomerClass { Id = 1, Name = "Alice" };
var b = new CustomerClass { Id = 1, Name = "Alice" };
Console.WriteLine(a == b); // False
Console.WriteLine(a.Equals(b)); // False by default
Records compare values instead of object references by default.
This makes records useful for:
- DTOs
- value objects
- messages
- events
- commands
- query results
- test expected values
- configuration snapshots
However, value equality can become surprising when the record contains mutable reference-type properties.
public record Customer(string Name, List<string> Tags);
var tags = new List<string> { "Premium" };
var customer1 = new Customer("Alice", tags);
var customer2 = customer1 with { };
customer2.Tags.Add("Active");
Console.WriteLine(customer1.Tags.Count); // 2
The with expression copied the reference to the same list. It did not deep-clone the list.
Immutability and Init-Only Properties
Records are commonly used with immutable data.
public record CustomerDto
{
public required int Id { get; init; }
public required string Name { get; init; }
}
init means the property can be assigned during object initialization but not changed afterward.
var customer = new CustomerDto
{
Id = 1,
Name = "Alice"
};
// customer.Name = "Bob"; // Compile-time error
This helps prevent accidental mutation and makes code easier to reason about.
However, init only protects the property assignment. It does not make referenced objects immutable.
public record Report(List<string> Lines);
var report = new Report(new List<string> { "Line 1" });
// The property cannot be reassigned if it is init-only,
// but the list itself can still be mutated.
report.Lines.Add("Line 2");
For stronger immutability, prefer immutable collection types or read-only abstractions when appropriate.
public record Report(IReadOnlyList<string> Lines);
With Expressions and Nondestructive Mutation
A with expression creates a copy of a record with selected properties changed.
public record CustomerDto(int Id, string Name, string Status);
var original = new CustomerDto(1, "Alice", "Active");
var updated = original with
{
Status = "Inactive"
};
Console.WriteLine(original.Status); // Active
Console.WriteLine(updated.Status); // Inactive
This is called nondestructive mutation because the original value is not modified.
with expressions are especially useful for:
- immutable update flows
- mapping with small changes
- test data setup
- state transitions
- copy-and-change DTO operations
Example in tests:
var validRequest = new CreateCustomerRequest
{
Name = "Alice",
Email = "[email protected]"
};
var invalidRequest = validRequest with
{
Email = ""
};
A common mistake is assuming that with performs a deep copy. It does not. It performs a shallow copy.
public record OrderDto(int Id, List<string> Items);
var order1 = new OrderDto(1, new List<string> { "Book" });
var order2 = order1 with { };
order2.Items.Add("Pen");
Console.WriteLine(order1.Items.Count); // 2
Both records share the same list instance.
Records and ToString
Records provide a readable ToString() implementation by default.
public record ProductDto(int Id, string Name);
var product = new ProductDto(10, "Keyboard");
Console.WriteLine(product);
// ProductDto { Id = 10, Name = Keyboard }
This is useful for debugging, logging small values, and writing tests.
However, avoid relying on the default ToString() format as a stable contract for APIs, files, or integration messages. Use JSON or another explicit serialization format when the output must be stable.
Records and Deconstruction
Positional records support deconstruction.
public record Money(decimal Amount, string Currency);
var price = new Money(99.99m, "USD");
var (amount, currency) = price;
Console.WriteLine(amount);
Console.WriteLine(currency);
This can make code concise, but excessive deconstruction can reduce readability if property names would be clearer.
Less clear:
var (a, b) = price;
More clear:
decimal amount = price.Amount;
string currency = price.Currency;
Use deconstruction when the meaning is obvious or when pattern matching benefits from positional structure.
Records and Pattern Matching
Records work well with pattern matching.
public record Payment(decimal Amount, string Currency);
static string Classify(Payment payment)
{
return payment switch
{
{ Amount: <= 0 } => "Invalid",
{ Currency: "USD", Amount: > 1000 } => "Large USD payment",
{ Currency: "USD" } => "USD payment",
_ => "Other payment"
};
}
Records also work with positional patterns.
public record Point(int X, int Y);
static string Describe(Point point)
{
return point switch
{
(0, 0) => "Origin",
(> 0, > 0) => "Positive quadrant",
_ => "Other"
};
}
This is helpful when building small value-like models that represent clear data shapes.
Record Inheritance
Record classes can inherit from other record classes.
public record Person(string FirstName, string LastName);
public record Employee(
string FirstName,
string LastName,
int EmployeeId
) : Person(FirstName, LastName);
Records include runtime type information in equality. That means a base record and a derived record are not considered equal just because they share the same base properties.
Person person = new Person("Alice", "Nguyen");
Person employee = new Employee("Alice", "Nguyen", 1001);
Console.WriteLine(person == employee); // False
This behavior helps prevent accidental equality between different logical types.
Record structs do not support inheritance because structs cannot inherit from other structs or classes.
Records vs Classes
Records and classes can both represent reference types, but their defaults are different.
public class CustomerClass
{
public int Id { get; init; }
public string Name { get; init; } = "";
}
public record CustomerRecord(int Id, string Name);
Key differences:
Use a class when identity matters.
public class BankAccount
{
public Guid Id { get; private set; }
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount));
Balance += amount;
}
}
Use a record when data values matter more than identity.
public record BankAccountSummary(Guid Id, decimal Balance);
Records vs Structs
A record struct is a value type with record-generated features.
public record struct Money(decimal Amount, string Currency);
A normal struct is also a value type, but it does not get all record features in the same concise way.
public struct MoneyStruct
{
public decimal Amount { get; init; }
public string Currency { get; init; }
}
Use record struct for small, immutable or value-like data where copying is cheap and value semantics are desired.
Good candidates:
public readonly record struct Money(decimal Amount, string Currency);
public readonly record struct Percentage(decimal Value);
public readonly record struct Coordinate(double Latitude, double Longitude);
Avoid large mutable record structs because copying value types can be expensive and confusing.
Records and Entity Framework Core
Records are usually not the best choice for Entity Framework Core entity types.
Entity types usually need identity and change tracking. EF Core tracks entity instances by reference identity. Records use value equality by default, which can conflict with the mental model of entities.
Usually prefer classes for EF Core entities:
public class Customer
{
public Guid Id { get; private set; }
public string Name { get; private set; } = "";
private Customer()
{
}
public Customer(Guid id, string name)
{
Id = id;
Name = name;
}
public void ChangeName(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name is required.", nameof(name));
Name = name;
}
}
Records are often better for DTOs around the entity:
public record CustomerResponse(Guid Id, string Name);
public record CreateCustomerCommand(string Name);
A common interview answer is: use records for values and data transfer models, but be careful using them for persistent entities where identity and tracking matter.
Records as DTOs, Commands, Queries, and Events
Records are common in ASP.NET Core and CQRS-style applications.
DTO example:
public record ProductResponse(
Guid Id,
string Name,
decimal Price
);
Command example:
public record CreateProductCommand(
string Name,
decimal Price
);
Query example:
public record GetProductByIdQuery(Guid Id);
Event example:
public record ProductCreatedEvent(
Guid ProductId,
string Name,
DateTimeOffset CreatedAt
);
Records fit these cases because the objects primarily carry data and often benefit from value equality in tests.
Required Members and Records
Records can use required members when object initializers are preferred.
public record RegisterUserRequest
{
public required string Email { get; init; }
public required string Password { get; init; }
}
This helps ensure important properties are initialized.
However, required is a compile-time feature. It does not replace runtime validation.
public static void Validate(RegisterUserRequest request)
{
if (string.IsNullOrWhiteSpace(request.Email))
throw new ArgumentException("Email is required.");
if (request.Password.Length < 8)
throw new ArgumentException("Password must be at least 8 characters.");
}
In real applications, still use validation approaches such as model validation, FluentValidation, or explicit business rules.
Customizing Record Behavior
Records allow custom members.
public record Money(decimal Amount, string Currency)
{
public override string ToString()
{
return $"{Amount:0.00} {Currency}";
}
}
You can also add validation in a constructor or factory method.
public record EmailAddress
{
public string Value { get; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Email is required.", nameof(value));
if (!value.Contains('@'))
throw new ArgumentException("Invalid email format.", nameof(value));
Value = value;
}
}
Be careful not to overuse records for behavior-heavy domain objects. If the type has complex lifecycle rules, many methods, state transitions, or identity, a class is often clearer.
Shallow Immutability
Records are often described as immutable, but this can be misleading.
public record UserProfile(string Name, List<string> Roles);
The record property might be init-only, but the list is still mutable.
var profile = new UserProfile("Alice", new List<string> { "Admin" });
profile.Roles.Add("Manager");
For safer designs, use immutable collections or avoid exposing mutable collections directly.
public record UserProfile(string Name, IReadOnlyList<string> Roles);
Even IReadOnlyList<T> only prevents mutation through that interface. If the original list is still referenced elsewhere, it can still be changed. For stronger immutability, copy the input into an immutable collection or a private array.
Common Mistakes
A common mistake is thinking that all records are value types.
public record User(int Id); // reference type
Only record struct is a value type.
Another mistake is using records for everything. Records are excellent for data-centric types, but not every model should be a record.
Avoid using records blindly for:
- EF Core entities
- behavior-heavy domain aggregates
- mutable objects with complex lifecycle
- services
- objects where reference identity is important
Another common mistake is assuming with performs deep cloning. It does not.
public record Cart(List<string> Items);
var cart1 = new Cart(new List<string> { "Book" });
var cart2 = cart1 with { };
cart2.Items.Add("Pen");
Console.WriteLine(cart1.Items.Count); // 2
Another mistake is creating very large record struct types. Large structs can be expensive to copy and can make performance worse.
Best Practices
Use records when the type is primarily a data carrier and value equality is desired.
Prefer record class for most DTOs, API models, commands, queries, and events.
public record OrderResponse(Guid Id, decimal Total, string Status);
Use readonly record struct for small value-like objects.
public readonly record struct ProductId(Guid Value);
Use property-based records for larger request/response models.
public record UpdateProductRequest
{
public required string Name { get; init; }
public required decimal Price { get; init; }
public string? Description { get; init; }
}
Avoid mutable collection properties unless mutation is intentional.
public record TeamDto(string Name, IReadOnlyList<string> Members);
Do not rely on records alone for validation. Records help with data modeling, but they do not automatically enforce business rules.
Use classes for entities and behavior-rich domain models where identity and lifecycle matter.
Use records in tests to simplify expected data comparisons.
var expected = new ProductResponse(product.Id, "Keyboard", 99.99m);
var actual = MapToResponse(product);
Assert.Equal(expected, actual);