C# Records — Record Class, Record Struct, Positional Syntax, and With Expressions
In this tutorial, you will learn about C# Records. We cover key concepts, practical examples, and best practices to help you master this topic.
C# records are reference types designed for immutable data modeling, providing built-in value-based equality, concise positional construction syntax, and non-destructive mutation through with expressions.
What You'll Learn
You will master records in C#: record class syntax for immutable data models, record structs for value-type immutability, positional records for concise construction, with expressions for non-destructive mutation, value-based equality semantics, and the integration of records with pattern matching in .NET.
Why It Matters
Records solve a common problem in C#: creating simple data containers that are immutable, comparable by value, and easy to clone with modifications. Before records, developers wrote dozens of lines of boilerplate for Equals, GetHashCode, ToString, and copy constructors. Records eliminate this boilerplate and are now the standard way to model data in modern C#.
Real-World Use
DTOs (Data Transfer Objects) in ASP.NET Core APIs use records for request/response models. Domain events in event-driven architectures use records for immutable event data. Configuration objects use records for immutable settings. Functional Programming patterns in C# use records for discriminated unions. Entity Framework Core query results are often projected to records.
Learning Path
graph LR
A["14: Interfaces"] --> B["15: Records"]
B --> C["16: Structs"]
C --> D["17: Strings"]
D --> E["18: Arrays & Collections"]
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
style C fill:#4a90d9,stroke:#2c5f8a,color:#fff
style D fill:#4a90d9,stroke:#2c5f8a,color:#fff
style E fill:#4a90d9,stroke:#2c5f8a,color:#fff
Record Class (Reference Type)
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public int Age { get; init; }
}
// Object initializer syntax
var person1 = new Person
{
FirstName = "Alice",
LastName = "Smith",
Age = 30
};
Positional Records
The most concise syntax for records:
public record Person(string FirstName, string LastName, int Age);
// Construction
var person1 = new Person("Alice", "Smith", 30);
var person2 = new Person("Bob", "Jones", 25);
// Deconstruction
var (first, last, age) = person1;
Console.WriteLine($"{first} {last}, {age}"); // Alice Smith, 30
// Accessing properties
Console.WriteLine(person1.FirstName); // Alice
Positional records automatically generate:
- Init-only properties for each parameter
- A primary constructor
- Deconstruct method
- Value-based
Equals,GetHashCode ToStringimplementationIEquatable<T>implementation
With Expressions (Non-Destructive Mutation)
Since records are immutable, with creates a new record with modified properties:
var alice = new Person("Alice", "Smith", 30);
var olderAlice = alice with { Age = 31 };
Console.WriteLine(alice); // Person { FirstName = Alice, LastName = Smith, Age = 30 }
Console.WriteLine(olderAlice); // Person { FirstName = Alice, LastName = Smith, Age = 31 }
Console.WriteLine(alice == olderAlice); // False (different age)
// For positional records, you can also use named arguments
var bob = alice with { FirstName = "Bob", Age = 25 };
Console.WriteLine(bob); // Person { FirstName = Bob, LastName = Smith, Age = 25 }
Value-Based Equality
Records compare by value, not by reference:
var p1 = new Person("Alice", "Smith", 30);
var p2 = new Person("Alice", "Smith", 30);
Console.WriteLine(p1 == p2); // True (value equality)
Console.WriteLine(ReferenceEquals(p1, p2)); // False (different objects)
// Records implement IEquatable<T>
Console.WriteLine(p1.Equals(p2)); // True
// Records work with dictionaries/hashsets
var set = new HashSet<Person> { p1 };
Console.WriteLine(set.Contains(p2)); // True
Record Struct (C# 10)
Value-type records for performance-sensitive scenarios:
public readonly record struct Point(double X, double Y);
// Mutable record struct (rarely needed)
public record struct Vector2(double X, double Y)
{
public double Magnitude => Math.Sqrt(X * X + Y * Y);
}
// Usage
var p1 = new Point(3, 4);
var p2 = p1 with { X = 5 };
Console.WriteLine(p1); // Point { X = 3, Y = 4 }
Console.WriteLine(p2); // Point { X = 5, Y = 4 }
Adding Methods and Properties
public record Circle(double Radius)
{
// Computed property
public double Area => Math.PI * Radius * Radius;
public double Circumference => 2 * Math.PI * Radius;
// Method
public bool Contains(Point point) =>
Math.Sqrt(point.X * point.X + point.Y * point.Y) <= Radius;
}
var circle = new Circle(5);
Console.WriteLine($"Area: {circle.Area:F2}"); // 78.54
Console.WriteLine(circle.Contains(new Point(3, 4))); // True
Inheritance with Records
Records support inheritance hierarchies:
public abstract record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
public record Cat(string Name, bool IsIndoor) : Animal(Name);
var pets = new Animal[]
{
new Dog("Rex", "German Shepherd"),
new Cat("Whiskers", true)
};
foreach (var pet in pets)
{
Console.WriteLine(pet);
}
Expected output:
Dog { Name = Rex, Breed = German Shepherd }
Cat { Name = Whiskers, IsIndoor = True }
Records vs Classes vs Structs
// Class: mutable, reference type, reference equality
public class PersonClass
{
public string Name { get; set; }
}
// Record: immutable, reference type, value equality
public record PersonRecord(string Name);
// Struct: mutable/immutable, value type, value equality
public struct PersonStruct
{
public string Name { get; set; }
}
Customizing Records
public record Employee
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public decimal Salary { get; init; }
// Custom constructor
public Employee(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
Salary = 50000m;
}
// Override auto-generated ToString
public override string ToString()
{
return $"Employee: {FirstName} {LastName} (${Salary:N0})";
}
}
Records with Validation
public record Temperature
{
private double _celsius;
public required double Celsius
{
get => _celsius;
init => _celsius = value < -273.15
? throw new ArgumentException("Below absolute zero")
: value;
}
public double Fahrenheit => Celsius * 9 / 5 + 32;
}
Common Mistakes
Mistake 1: Treating Records as Mutable
Records are designed for immutability. Using { get; set; } on record properties defeats their purpose. Use { get; init; } or positional syntax.
Mistake 2: Forgetting That Records Are Reference Types
Record class instances are stored on the heap and passed by reference. Use record struct for stack-allocated value types when performance matters.
Mistake 3: Assuming Positional Records Are Tuples
Records generate full types with names, methods, and metadata. Tuples are lightweight anonymous types. Choose records for formal data models, tuples for quick grouping.
Mistake 4: Modifying Record Properties After Construction
Init-only setters prevent modification after construction. Use with expressions to create modified copies instead.
Mistake 5: Not Overriding ToString When Needed
The auto-generated ToString includes all properties. For sensitive data (passwords, PII), override ToString to exclude or mask such properties.
Mistake 6: Deep Inheritance with Records
Records support inheritance but deep hierarchies violate the principle of simple data modeling. Keep record hierarchies shallow (1-2 levels max).
Practice Questions
- What are the key differences between a record class and a regular class?
- How does a
withexpression work? What does it create? - What is the difference between positional record syntax and property-based record syntax?
- When would you choose a record struct over a record class?
- Write a record
Order(int Id, string Customer, decimal Total, DateTime OrderDate)and demonstrate with expressions.
Challenge
Design a record hierarchy for a geometric shapes library with Shape, Circle(double Radius), Rectangle(double Width, double Height), and Triangle(double Base, double Height). Add computed Area properties using the init accessor pattern or expression-bodied members.
FAQ
Mini Project
Create an e-commerce order system using records:
public record Address(string Street, string City, string State, string ZipCode);
public record Product(string Sku, string Name, decimal UnitPrice);
public record OrderItem(Product Product, int Quantity)
{
public decimal Total => Product.UnitPrice * Quantity;
}
public record Order
{
public required int Id { get; init; }
public required string CustomerName { get; init; }
public required Address ShippingAddress { get; init; }
public List<OrderItem> Items { get; init; } = new();
public DateTime OrderDate { get; init; } = DateTime.UtcNow;
public string Status { get; init; } = "Pending";
public decimal Subtotal => Items.Sum(i => i.Total);
public decimal Tax => Subtotal * 0.08m;
public decimal Total => Subtotal + Tax;
}
// Creating an order
var address = new Address("123 Main St", "Portland", "OR", "97201");
var product1 = new Product("WIDG-001", "Widget", 19.99m);
var product2 = new Product("GADG-001", "Gadget", 49.99m);
var order = new Order
{
Id = 1001,
CustomerName = "Alice Johnson",
ShippingAddress = address,
Items = new List<OrderItem>
{
new(product1, 3),
new(product2, 1)
}
};
Console.WriteLine($"Order #{order.Id}");
Console.WriteLine($"Customer: {order.CustomerName}");
Console.WriteLine($"Status: {order.Status}");
Console.WriteLine($"Items: {order.Items.Count}");
Console.WriteLine($"Subtotal: {order.Subtotal:C}");
Console.WriteLine($"Tax: {order.Tax:C}");
Console.WriteLine($"Total: {order.Total:C}");
// Non-destructive mutation: update status
var shippedOrder = order with { Status = "Shipped" };
Console.WriteLine($"\nUpdated status: {shippedOrder.Status}");
Console.WriteLine($"Original status still: {order.Status}");
// Value equality
var sameOrder = order with { };
Console.WriteLine($"\nEqual to original: {order == sameOrder}");
// Deconstruction
var (id, customer, _, _, _, _) = order;
Console.WriteLine($"Deconstructed: #{id}, {customer}");
Expected output:
Order #1001
Customer: Alice Johnson
Status: Pending
Items: 2
Subtotal: $109.96
Tax: $8.80
Total: $118.76
Updated status: Shipped
Original status still: Pending
Equal to original: True
Deconstructed: #1001, Alice Johnson
What's Next
You have mastered records in C#. The next lesson covers structs: value types, readonly struct, ref struct, and comparison with record structs.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro