Mert Özen Further With Every Line
Backend C# Fundamentals — #12

Value Types and Reference Types in C#: Assignment, Copying and the Special Case of string

Mert Özen Nov 7, 2026 9 min 6 views
Value Types and Reference Types in C#: Assignment, Copying and the Special Case of string

The difference between value types and reference types in C#: what gets copied on assignment and when passing to a method, why string behaves like a value type, what is right and what is missing in the stack and heap story, equality, boxing, and struct pitfalls in collections.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

Below are two small types: a struct that holds a price tag and a class that holds a book. We do the same thing with both: assign one variable to another, then change a value through the second variable. Which two numbers get printed?

var tagA = new PriceTag { Amount = 10m };
var tagB = tagA;
tagB.Amount = 99m;

var bookA = new Book { Title = "Dune", Stock = 3 };
var bookB = bookA;
bookB.Stock = 0;

Console.WriteLine(tagA.Amount);
Console.WriteLine(bookA.Stock);

struct PriceTag
{
    public decimal Amount;
}

class Book
{
    public string Title = "";
    public int Stock;
}
10
0

The tag stayed at 10, while the book's stock became 0. The code is almost identical, the result completely different. The reason is that PriceTag is a value type and Book is a reference type. The line tagB = tagA copied the entire content of the tag; we now have two independent tags. bookB = bookA did not copy the book; it created a second reference that points to the same book. This is the general rule behind the behavior we saw in the arrays post. We made the fields public in the examples for simplicity; we will see properties and proper design in the OOP series.

Two Kinds of Types

Every type in C# falls into one of these two groups. You can also ask the runtime which one:

Console.WriteLine(typeof(int).IsValueType);
Console.WriteLine(typeof(decimal).IsValueType);
Console.WriteLine(typeof(DateTime).IsValueType);
Console.WriteLine(typeof(bool).IsValueType);
Console.WriteLine(typeof(string).IsValueType);
Console.WriteLine(typeof(int[]).IsValueType);
Console.WriteLine(typeof(List<int>).IsValueType);
True
True
True
True
False
False
False

Value types: all the number types (int, long, double, decimal…), bool, char, BCL structs like DateTime, enums, and everything you declare with struct. Reference types: everything declared with class, string, arrays, collections like List<T>, interfaces and delegates.

The definition in Microsoft's documentation gets to the heart of it: a variable of a value type contains the value itself; a variable of a reference type contains a reference to where the value is. An analogy: a value type is like handing over a photocopy of a document; whatever the other person writes on it, your document does not change. A reference type is like handing over the address of a house; if the person who gets the address goes there and paints the wall, you find the wall painted when you get home.

Assignment: Copy or Reference?

12-value-vs-reference-en.png

The assignment operator always copies what is inside the variable. The difference lies in the answer to "what is inside the variable?". Inside a PriceTag variable is the tag itself, and that is what gets copied. Inside a Book variable there is only a reference to the book, and that reference is what gets copied. That is why bookA and bookB look at the same object, and a change made through one is visible through the other.

This point of view reduces many behaviors you will see later to a single rule. Instead of saying "reference types are assigned by reference", it is more accurate and less confusing to say "everything is assigned by value, and the value of a reference type is a reference".

Passing to Methods

The same rule applies to method parameters. In the previous post we saw that parameters are passed by value; now let's look at what that means for reference types:

var book = new Book { Title = "Dune", Stock = 3 };

SellOne(book);
Console.WriteLine(book.Stock);

Replace(book);
Console.WriteLine(book.Title);

ReplaceRef(ref book);
Console.WriteLine(book.Title);

static void SellOne(Book b)
{
    b.Stock--;
}

static void Replace(Book b)
{
    b = new Book { Title = "Emma", Stock = 10 };
}

static void ReplaceRef(ref Book b)
{
    b = new Book { Title = "Emma", Stock = 10 };
}

class Book
{
    public string Title = "";
    public int Stock;
}
2
Dune
Emma

Three calls, three different results. SellOne got a copy of the reference, but since the copy points to the same book, b.Stock-- changed the original book: the stock became 2. Replace, on the other hand, pointed its own copy of the reference at a brand-new book; the caller's book variable was not affected, and the title stayed "Dune". ReplaceRef took the variable itself with ref, so it could switch the caller's variable to the new book.

When we run the same experiment with a struct, the picture changes:

var tag = new PriceTag { Amount = 10m };

Increase(tag);
Console.WriteLine(tag.Amount);

IncreaseRef(ref tag);
Console.WriteLine(tag.Amount);

static void Increase(PriceTag t)
{
    t.Amount += 5m;
}

static void IncreaseRef(ref PriceTag t)
{
    t.Amount += 5m;
}

struct PriceTag
{
    public decimal Amount;
}
10
15

Increase worked on a copy of the tag, and the original tag stayed at 10. IncreaseRef reached the original variable thanks to ref. Put simply, changing the contents of a reference type inside a method is visible to the caller, assigning a new object to the parameter is not; with a value type, no change is visible without ref.

The Special Case of string

string is a reference type, but most of the time it behaves like a value type:

string title = "Dune";
string other = title;
other += " Messiah";

Console.WriteLine(title);
Console.WriteLine(other);

string a = "Dune";
string b = string.Concat("Du", "ne");
Console.WriteLine(a == b);
Console.WriteLine(ReferenceEquals(a, b));

string upper = title.ToUpperInvariant();
Console.WriteLine(title);
Console.WriteLine(upper);
Dune
Dune Messiah
True
False
Dune
DUNE

The line other += " Messiah" did not affect title. That is because string is immutable: once a string object is created, it never changes. += did not change the existing string; it created a new string and pointed other at it. ToUpperInvariant likewise returns a new string, and title stays as it is.

Comparison is special for string too. a and b are two separate objects in memory (ReferenceEquals shows that with False), but == returned True because it compares their contents. String's == operator is defined specifically for this. Being immutable and compared by content makes string as safe as a value type in everyday use. We saw the performance side of this immutability when we talked about StringBuilder in the strings post.

The Stack and the Heap: Accurately and Without Exaggeration

When this topic is explained, people usually say "value types live on the stack and reference types live on the heap". Half of that sentence is right, and half of it is incomplete.

The right half: a reference type object is created on the managed heap that the garbage collector manages. What the variable holds is a reference to that object. When no references to the object remain, the garbage collector takes its memory back some time later.

The incomplete half: where a value type sits depends on what you declare it in. An int variable inside a method usually sits on the stack (or directly in a CPU register). But an int field of a class sits on the heap together with that class's object. The elements of a PriceTag[] array are inside the array, so on the heap. A local variable captured by a lambda is also moved to the heap.

So think of the difference between value types and reference types not in terms of "where they live" but in terms of "what gets copied when they are assigned and passed to a method". That is what determines whether your code is correct; where things are placed in memory is an implementation detail of the runtime, and the C# language rules promise nothing about it. When we care about performance, we will come back to allocations in the C# Advanced series.

Equality

The == operator also works differently in the two worlds:

var x = new Book { Title = "Dune", Stock = 3 };
var y = new Book { Title = "Dune", Stock = 3 };
var z = x;

Console.WriteLine(x == y);
Console.WriteLine(x == z);

var p = new PriceTag { Amount = 10m };
var q = new PriceTag { Amount = 10m };
Console.WriteLine(p.Equals(q));

struct PriceTag
{
    public decimal Amount;
}

class Book
{
    public string Title = "";
    public int Stock;
}
False
True
True

Even though all the fields of the two Books are the same, x == y returned False. For classes, == by default asks "do these two references point to the same object?"; that is why x == z is True. For structs, Equals compares the fields one by one and returns True. So what if we use == on a struct?

var p = new PriceTag { Amount = 10m };
var q = new PriceTag { Amount = 10m };
Console.WriteLine(p == q);

struct PriceTag
{
    public decimal Amount;
}
Program.cs(3,19): error CS0019: Operator '==' cannot be applied to operands of type 'PriceTag' and 'PriceTag'

You cannot use the == operator on structs unless you define it yourself. The default Equals is also slow, because in some cases it uses reflection. We will see how to write your own equality rules, and record from C# 9, which brings value equality out of the box, in the OOP series.

A Quick Return to Boxing

We took a first look at boxing in the type conversions post. When you assign a value type to a reference type like object, the runtime copies the value into a new box on the heap:

int stock = 3;
object boxed = stock;
stock = 10;

Console.WriteLine(boxed);
Console.WriteLine(stock);

int unboxed = (int)boxed;
Console.WriteLine(unboxed);
3
10
3

Even after setting stock to 10, the value in the box is 3. The box carries a copy of the value at the moment of assignment; it has no link to the original variable. Unboxing copies the value in the box back into a value type. Every boxing means a heap allocation; code that boxes often inside a loop creates needless work for the garbage collector. Generics exist to solve exactly this problem, as we will see in the first post of the Collections and Generics series.

Common Pitfalls

Trying to change a struct inside a collection in place. The indexer of List<T> returns a copy of the element; changing that copy would be pointless, so the compiler does not allow it:

var tags = new List<PriceTag> { new PriceTag { Amount = 10m } };
tags[0].Amount = 20m;

struct PriceTag
{
    public decimal Amount;
}
Program.cs(2,1): error CS1612: Cannot modify the return value of 'List<PriceTag>.this[int]' because it is not a variable

With an array, the same line works, because an array element can be accessed directly like a variable. With a List, what you need to do is take the copy, change it and write it back:

PriceTag[] array = { new PriceTag { Amount = 10m } };
array[0].Amount = 20m;
Console.WriteLine(array[0].Amount);

var list = new List<PriceTag> { new PriceTag { Amount = 10m } };
PriceTag copy = list[0];
copy.Amount = 20m;
Console.WriteLine(list[0].Amount);
list[0] = copy;
Console.WriteLine(list[0].Amount);

struct PriceTag
{
    public decimal Amount;
}
20
10
20

The real fix for this confusion is to design structs as immutable. Mutable structs are the main source of surprises like these; we will see readonly struct in the OOP series.

Thinking you made a copy. Assigning an object's fields one by one to a new object does not copy the reference types inside; the two keep sharing the same list:

var original = new Order { Customer = "Ada", Items = new List<string> { "Dune" } };
var copy = new Order { Customer = original.Customer, Items = original.Items };

copy.Items.Add("Emma");
Console.WriteLine(string.Join(", ", original.Items));

var deepCopy = new Order { Customer = original.Customer, Items = new List<string>(original.Items) };
deepCopy.Items.Add("Ulysses");
Console.WriteLine(string.Join(", ", original.Items));

class Order
{
    public string Customer = "";
    public List<string> Items = new();
}
Dune, Emma
Dune, Emma

The first copy (a shallow copy) points to the same Items list, so "Emma" was added to the original too. In the second one we also copied the list with new List<string>(original.Items) (a deep copy), and the original was not affected.

Forgetting about null. A reference type variable can hold a reference that goes to no object at all: null. With a value type this is not possible; an int is always a number. The consequences of this difference are the topic of the next post.

A Small Experiment

★☆☆ In the example from the start of the post, turn PriceTag into a class and Book into a struct, predict how the output changes, then try it.

★★☆ Add a method called Copy() to the Book class that returns a new Book with the same values. Add a field that holds a List<string>, as in the Order example, and write code that tests whether Copy() makes a shallow or a deep copy.

★★★ Write a method that raises the price of every tag in a List<PriceTag> by 10 percent. First try it with foreach and see what the compiler says; then solve it with for and writing back.

Reference types have one more possible value: nothing. In the next post we take on null: Nullable<T> and int?, the nullable reference types that came with C# 8, the !, ?. and ?? operators, and managing null by design instead of just checking for it.