DEV_NET_CORE
GET_STARTED
.NETC# Language Foundations

Value Types vs Reference Types

Overview

Value types and reference types are one of the most important foundations of C# and the .NET type system. Every C# type belongs to one of these broad categories, and the category affects assignment, method calls, equality, nullability, memory allocation, mutation, performance, and API design.

A value type variable directly contains its value. Common examples include int, bool, decimal, DateTime, Guid, enum, struct, record struct, and nullable value types such as int?.

A reference type variable contains a reference to an object. Common examples include class, record class, interface, delegate, object, string, arrays, collections such as List<T>, and most services or domain entities in application code.

This topic matters because many C# bugs come from misunderstanding whether a value is copied or shared. For example, changing a property on a class object through one variable can be visible through another variable that references the same object. Changing a copied struct usually changes only that copy. Boxing a value type can allocate memory. Passing large structs by value can create unnecessary copying. Nullable reference types are compile-time annotations, while nullable value types are real wrapper types.

In interviews, value types vs reference types is commonly used to test whether a developer understands C# beyond syntax. Interviewers often ask about copying, mutation, boxing, heap vs stack, struct design, class design, record behavior, ref/out/in, nullable types, and equality semantics. A strong answer should avoid oversimplified statements like "value types are always on the stack and reference types are always on the heap." The more accurate answer is that value types have value semantics and reference types have reference semantics; their physical storage location depends on context and runtime implementation details.

Core Concepts

Value Types

A value type variable contains the actual value. When a value type is assigned to another variable, passed as an argument, or returned from a method, the value is copied by default.

Common value types include:

  • numeric types such as int, long, double, decimal
  • bool
  • char
  • DateTime
  • TimeSpan
  • Guid
  • enum
  • struct
  • record struct
  • nullable value types such as int?

Example:

Code
int a = 10;
int b = a;

b = 20;

Console.WriteLine(a); // 10
Console.WriteLine(b); // 20

Changing b does not change a because b received a copy of the value.

For custom structs:

Code
public struct Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

Point p1 = new Point { X = 1, Y = 2 };
Point p2 = p1;

p2.X = 100;

Console.WriteLine(p1.X); // 1
Console.WriteLine(p2.X); // 100

The struct instance was copied. p1 and p2 are independent values.

Reference Types

A reference type variable contains a reference to an object. When a reference type variable is assigned to another variable, the reference is copied, not the object itself. Both variables can point to the same object.

Common reference types include:

  • class
  • record class
  • interface
  • delegate
  • object
  • string
  • arrays
  • most collection types such as List<T> and Dictionary<TKey, TValue>

Example:

Code
public class Customer
{
    public string Name { get; set; } = "";
}

Customer c1 = new Customer { Name = "Alice" };
Customer c2 = c1;

c2.Name = "Bob";

Console.WriteLine(c1.Name); // Bob
Console.WriteLine(c2.Name); // Bob

c1 and c2 both refer to the same object. Mutating the object through c2 is visible through c1.

Value Semantics vs Reference Semantics

The most important difference is semantic, not physical.

Value semantics means the variable represents a self-contained value. Copying the variable copies the value.

Reference semantics means the variable represents a reference to an object. Copying the variable copies the reference.

Code
public struct Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }
}

public class Account
{
    public decimal Balance { get; set; }
}

Money is naturally a value. Two Money values with the same amount and currency can reasonably be considered equal.

Account is naturally an entity. Two accounts with the same balance are not necessarily the same account.

This distinction is useful in domain modeling:

  • Use value types for small, self-contained values.
  • Use reference types for entities, services, aggregates, and objects with identity or shared mutable state.

Assignment Behavior

Assignment behaves differently depending on the type category.

Value type assignment copies the value:

Code
var first = new Coordinates(10, 20);
var second = first;

second.X = 99;

// first is unchanged if Coordinates is a mutable struct.

Reference type assignment copies the reference:

Code
var first = new Order { Status = "Pending" };
var second = first;

second.Status = "Completed";

// first.Status is also "Completed" because both variables refer to the same object.

For interviews, explain that both assignments copy something. The difference is what gets copied:

  • Value type: the data is copied.
  • Reference type: the reference is copied.

Method Parameter Passing

By default, C# passes arguments by value. This means the parameter receives a copy of the argument.

For value types, the method receives a copy of the value:

Code
static void Increment(int number)
{
    number++;
}

int value = 10;
Increment(value);

Console.WriteLine(value); // 10

For reference types, the method receives a copy of the reference:

Code
static void Rename(Customer customer)
{
    customer.Name = "Updated";
}

var customer = new Customer { Name = "Original" };
Rename(customer);

Console.WriteLine(customer.Name); // Updated

The method cannot replace the caller's variable unless the parameter is passed with ref:

Code
static void Replace(Customer customer)
{
    customer = new Customer { Name = "New" };
}

var customer = new Customer { Name = "Original" };
Replace(customer);

Console.WriteLine(customer.Name); // Original

The local parameter was reassigned, but the caller's variable still points to the original object.

To allow the method to replace the caller's variable:

Code
static void Replace(ref Customer customer)
{
    customer = new Customer { Name = "New" };
}

var customer = new Customer { Name = "Original" };
Replace(ref customer);

Console.WriteLine(customer.Name); // New

ref, out, and in

C# provides parameter modifiers that change how arguments are passed.

ref passes a variable by reference. The method can read and assign a new value to the caller's variable.

Code
static void AddOne(ref int number)
{
    number++;
}

int value = 10;
AddOne(ref value);

Console.WriteLine(value); // 11

out is used when the method must assign a value before returning. It is commonly used in TryParse-style APIs.

Code
if (int.TryParse("123", out int number))
{
    Console.WriteLine(number);
}

in passes by readonly reference. It can avoid copying large structs while preventing the method from reassigning the parameter.

Code
public readonly struct LargeValue
{
    public decimal A { get; init; }
    public decimal B { get; init; }
    public decimal C { get; init; }
}

static decimal Calculate(in LargeValue value)
{
    return value.A + value.B + value.C;
}

in is most useful for large immutable structs. It is usually unnecessary for small types like int, bool, or DateTime.

Default Values

Every C# type has a default value.

For value types, the default value is usually the zero-equivalent value:

Code
default(int)       // 0
default(bool)      // false
default(DateTime)  // 0001-01-01 00:00:00
default(Guid)      // 00000000-0000-0000-0000-000000000000

For reference types, the default value is null:

Code
string text = default!; // null at runtime
Customer customer = default!; // null at runtime

For nullable value types:

Code
int? number = default;

Console.WriteLine(number.HasValue); // false

For structs, default initialization sets fields to their own default values:

Code
public struct ProductCode
{
    public int Number;
    public string Prefix;
}

var code = default(ProductCode);

Console.WriteLine(code.Number); // 0
Console.WriteLine(code.Prefix is null); // True

A common mistake is assuming a struct constructor always runs. Default struct values can exist even if you define constructors. Therefore, struct types should handle their default state safely.

Nullability

Non-nullable value types cannot be assigned null:

Code
int number = null; // Compile-time error

Nullable value types use Nullable<T> syntax, commonly written as T?:

Code
int? number = null;

if (number.HasValue)
{
    Console.WriteLine(number.Value);
}

Reference types can be null at runtime. Nullable reference types, introduced as a compiler feature, help express intent:

Code
string name = "Alice";     // Intended to be non-null
string? middleName = null; // Intended to allow null

Important interview distinction:

  • int? is a real nullable value type: Nullable<int>.
  • string? is still a string reference at runtime; the ? mainly enables compiler nullability analysis and warnings.

Boxing and Unboxing

Boxing occurs when a value type is converted to object or to an interface type it implements. The runtime wraps the value in an object.

Code
int number = 42;

object boxed = number; // Boxing
int unboxed = (int)boxed; // Unboxing

Boxing creates a copy of the value:

Code
int number = 42;
object boxed = number;

number = 100;

Console.WriteLine(boxed);  // 42
Console.WriteLine(number); // 100

Boxing can also happen when a struct is used through an interface:

Code
public struct Counter : IComparable<Counter>
{
    public int Value { get; init; }

    public int CompareTo(Counter other) => Value.CompareTo(other.Value);
}

Counter counter = new Counter { Value = 10 };

// Potential boxing when converted to a non-generic interface:
IComparable comparable = counter;

Why boxing matters:

  • it can allocate memory
  • it can add CPU overhead
  • it can hide copy behavior
  • it can cause performance issues in tight loops
  • it can surprise developers when mutating boxed structs

Use generics where possible to avoid unnecessary boxing:

Code
List<int> numbers = new List<int>(); // No boxing for int elements
ArrayList oldList = new ArrayList(); // Boxes int values

Equality

Value types and reference types have different default equality behavior.

For reference types, default equality is usually reference equality unless the type overrides equality members.

Code
var a = new Customer { Name = "Alice" };
var b = new Customer { Name = "Alice" };

Console.WriteLine(a == b);      // False by default for classes
Console.WriteLine(a.Equals(b)); // False unless overridden

For many value types, equality compares values:

Code
int a = 10;
int b = 10;

Console.WriteLine(a == b); // True

Structs inherit from ValueType, which provides value-based equality by default, but reflection-based default equality can be slower than a custom implementation. For performance-sensitive structs, implement IEquatable<T>.

Code
public readonly struct Money : IEquatable<Money>
{
    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public decimal Amount { get; }
    public string Currency { get; }

    public bool Equals(Money other)
    {
        return Amount == other.Amount &&
               Currency == other.Currency;
    }

    public override bool Equals(object? obj)
    {
        return obj is Money other && Equals(other);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Amount, Currency);
    }
}

Records provide generated value-based equality:

Code
public record CustomerDto(int Id, string Name);      // Reference type by default
public readonly record struct Point(int X, int Y);   // Value type

Important distinction:

  • record class is still a reference type, but it has value-based equality by default.
  • record struct is a value type and also has generated value-based equality.

Mutability

Mutability means whether an object or value can be changed after creation.

Mutable reference types are common:

Code
public class Customer
{
    public string Name { get; set; } = "";
}

Mutable structs are usually discouraged because copy behavior can be confusing:

Code
public struct MutablePoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

Example of confusing behavior:

Code
var points = new List<MutablePoint>
{
    new MutablePoint { X = 1, Y = 2 }
};

var point = points[0];
point.X = 100;

Console.WriteLine(points[0].X); // 1

points[0] returned a copy. Mutating the copy did not update the value in the list.

Prefer immutable structs:

Code
public readonly struct Point
{
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public int X { get; }
    public int Y { get; }
}

Or use a readonly record struct:

Code
public readonly record struct Point(int X, int Y);

Classes vs Structs

A class is a reference type. A struct is a value type.

Use a class when:

  • the type has identity
  • the object is large
  • the object is mutable
  • shared state is expected
  • inheritance is needed
  • the type represents a service, entity, aggregate, or long-lived object

Use a struct when:

  • the type is small
  • the type is immutable or logically immutable
  • value semantics are natural
  • copying is cheap
  • identity is not important
  • the type represents a simple value like a coordinate, range, money amount, or measurement

Example of a good struct candidate:

Code
public readonly record struct Percentage(decimal Value)
{
    public override string ToString() => $"{Value:P}";
}

Example of a good class candidate:

Code
public class BankAccount
{
    public Guid Id { get; init; }
    public decimal Balance { get; private set; }

    public void Deposit(decimal amount)
    {
        Balance += amount;
    }
}

Even if two bank accounts have the same balance, they are not the same account. That makes identity important, so a class is usually better.

Records, Record Classes, and Record Structs

Records are not a separate category from value/reference types. A record can be either:

  • record or record class: reference type
  • record struct: value type
  • readonly record struct: immutable-oriented value type
Code
public record Person(string FirstName, string LastName);

public record struct Coordinate(int X, int Y);

public readonly record struct Money(decimal Amount, string Currency);

Records are useful when you want concise data-focused types with generated equality, deconstruction, ToString, and with support.

Code
var original = new Person("Alice", "Nguyen");
var updated = original with { LastName = "Tran" };

Console.WriteLine(original); // Person { FirstName = Alice, LastName = Nguyen }
Console.WriteLine(updated);  // Person { FirstName = Alice, LastName = Tran }

For a record class, the with expression creates a shallow copy. Reference-type properties are copied as references.

Code
public record OrderDto(List<string> Items);

var order1 = new OrderDto(new List<string> { "Book" });
var order2 = order1 with { };

order2.Items.Add("Pen");

Console.WriteLine(order1.Items.Count); // 2

This is a common interview trap. Records make equality and copying easier, but they do not automatically make nested objects deeply immutable.

Arrays and Collections

Arrays are reference types, even if their elements are value types.

Code
int[] first = [1, 2, 3];
int[] second = first;

second[0] = 99;

Console.WriteLine(first[0]); // 99

The array variable is a reference to an array object. Both variables point to the same array.

For collections, the collection itself is usually a reference type:

Code
List<int> numbers1 = [1, 2, 3];
List<int> numbers2 = numbers1;

numbers2.Add(4);

Console.WriteLine(numbers1.Count); // 4

But the elements inside may be value types or reference types. This affects whether retrieving an element gives you a copy or a reference.

Code
List<Customer> customers = [new Customer { Name = "Alice" }];

Customer customer = customers[0];
customer.Name = "Bob";

Console.WriteLine(customers[0].Name); // Bob

For a list of class objects, the element is a reference to the same object.

string as a Special Reference Type

string is a reference type, but it behaves like a value in many common scenarios because strings are immutable and == compares text content.

Code
string a = "hello";
string b = "he" + "llo";

Console.WriteLine(a == b); // True

This can confuse beginners because string is not a value type. It is a reference type with value-like behavior.

Important points:

  • string variables hold references.
  • string instances are immutable.
  • operations like concatenation create new strings.
  • == compares string contents, not object references.
  • object.ReferenceEquals(a, b) checks whether two variables reference the same string object.
Code
string a = "hello";
string b = new string("hello".ToCharArray());

Console.WriteLine(a == b);                    // True
Console.WriteLine(object.ReferenceEquals(a,b)); // False

Heap vs Stack: Avoiding the Oversimplification

A common incorrect explanation is:

Value types are stored on the stack and reference types are stored on the heap.

A better explanation is:

Value type variables directly contain their data, while reference type variables contain references to objects. Storage location depends on context and runtime implementation.

Examples:

A value type local variable may be stored on the stack, in a CPU register, or optimized away.

A value type field inside a class is stored inline as part of the class object, which is on the managed heap:

Code
public class OrderLine
{
    public decimal Price { get; set; } // decimal is a value type stored inside the OrderLine object
}

An array of value types stores the values inline inside the array object, and the array object is on the heap:

Code
int[] numbers = new int[100];

A boxed value type is stored inside an object on the managed heap:

Code
object boxed = 123;

For interviews, focus on semantics first: copy behavior, identity, mutation, nullability, and equality. Discuss stack vs heap carefully and avoid absolute statements.

Garbage Collection and Lifetime

Reference type objects are managed by the garbage collector. When no live references point to an object, the runtime can reclaim it.

Code
Customer customer = new Customer();
customer = null;

After this assignment, the object may be eligible for garbage collection if nothing else references it.

Value types do not have independent object identity unless boxed. Their lifetime depends on where they are stored:

  • local variable
  • field inside a class
  • element inside an array
  • field inside another struct
  • boxed object

This affects performance and memory behavior, but application code should normally focus on clear design first. Prematurely converting classes to structs for performance can introduce bugs and often does not improve performance.

ref struct, Span<T>, and Stack-Only Types

Some value types have special restrictions. A ref struct is a stack-only type that cannot escape to the managed heap.

Common examples include:

  • Span<T>
  • ReadOnlySpan<T>
Code
Span<int> numbers = stackalloc int[3];
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;

Span<T> is designed for high-performance memory access without unnecessary allocations. Because it can point to stack memory or unmanaged memory, the compiler enforces restrictions.

For example, a Span<T> cannot be stored in a normal class field:

Code
public class InvalidHolder
{
    // This is not allowed:
    // public Span<int> Data;
}

This is an advanced topic, but interviewers may ask about it when discussing performance, memory, or modern .NET APIs.

Generics and Constraints

Generics help write code that works with value types and reference types without unnecessary boxing.

Code
public static bool AreEqual<T>(T first, T second)
{
    return EqualityComparer<T>.Default.Equals(first, second);
}

Generic constraints can limit type parameters:

Code
public class Repository<TEntity>
    where TEntity : class
{
}

class means the type argument must be a reference type.

Code
public readonly struct ResultCode
{
    public int Value { get; init; }
}

public static T CreateDefault<T>()
    where T : struct
{
    return default;
}

struct means the type argument must be a non-nullable value type.

Nullable-aware constraints also exist:

Code
public class NullableAwareRepository<TEntity>
    where TEntity : class?
{
}

Generics are important because they preserve type information and avoid many runtime casts and boxing operations.

Common Mistakes

Common mistakes include:

  • saying value types are always on the stack
  • saying reference types are always on the heap without explaining the reference variable itself
  • using mutable structs for complex state
  • using structs for large objects
  • assuming record always means value type
  • assuming string is a value type
  • forgetting that arrays are reference types
  • boxing value types accidentally through object or non-generic interfaces
  • using == without understanding whether it checks value equality or reference equality
  • assuming nullable reference types prevent null at runtime
  • returning or passing large structs by value without considering copy cost
  • making structs with invalid or unsafe default states

Best Practices

Prefer classes for most business entities, services, and mutable objects.

Use structs for small, immutable, self-contained values where value semantics are natural.

Prefer readonly struct or readonly record struct when designing value types.

Avoid mutable structs unless there is a strong reason and you fully understand the copy behavior.

Implement IEquatable<T> for custom structs used in collections or performance-sensitive paths.

Use generics instead of object when working with value types to avoid boxing.

Do not optimize around stack vs heap based only on assumptions. Measure performance when it matters.

Use nullable reference types to express intent and catch possible null bugs at compile time.

Be careful with shallow copying in records and reference-type properties.

Design structs so their default value is valid or at least safe to handle.

Interview Practice

PreviousObject-Oriented ProgrammingNext UpDeferred Execution in C#