Last Updated: May 17, 2026
A struct is a value type you define yourself. Where a class gives you a reference to data living on the heap, a struct gives you the data itself, inline, wherever the variable lives. That single design choice ripples into how assignments, parameters, and returns behave, and into when you should reach for a struct instead of a class.
A struct is a user-defined value type. The same family that already includes int, double, bool, and char is the family struct joins. The variable holds the bytes of the value directly, not a pointer to an object on the heap. There is no separate "instance on the heap" for the variable to point at; the bytes live wherever the variable lives, which is usually a stack frame or inline inside the object that contains it.
The shape of a struct looks almost identical to a small class. You get fields, properties, methods, and constructors. The difference is the keyword and everything that follows from it.
This Money is a value type. The variable price is not a reference to a Money somewhere else; the two fields Amount and Currency sit directly inside price. When you assign price to another variable, you copy those bytes. When you pass price into a method, you copy those bytes. There is no shared instance behind two names, which is the single most important property to internalize about structs.
The reason structs exist in a language that already has classes is mostly about cost and intent. A struct avoids the per-instance heap allocation a class would need. It also signals "this thing is a value, like a number," rather than "this thing is an entity with identity."
Use a struct when the type represents a small, immutable-ish value that is naturally compared by content (a point, a color, a price, a date). Use a class when the type represents an entity with identity, mutable state, and a lifetime independent of who is currently holding a reference to it.
This lesson covers the basics: declaration, field and property layout, constructors, value semantics, the default keyword, where structs live in memory, and the cost of boxing. Later lessons in this section build on top of these mechanics: readonly structs make the value immutable, ref structs constrain where the value can live, and record structs add value equality for free. We'll keep those out of this lesson and return to them in order.
A struct declaration looks like a class declaration with the struct keyword. You can declare fields, auto-properties, methods, constructors, and operators inside it. You can implement interfaces. You can mark members public, private, internal, or readonly.
A few things in that declaration are worth noticing. The properties are get-only auto-properties, which means the compiler generates hidden readonly backing fields and the only place you can assign them is inside a constructor. The constructor runs the same validation you would run for a class. The Format method behaves like any instance method, except this is the value itself, not a reference to it.
You can declare any number of constructors. Each one must assign every field the struct declares (with some recent exceptions, covered later in this lesson). Static methods and static fields work the same way they do on a class.
What you cannot do inside a struct is inherit from another struct or class. Structs do not participate in user-defined inheritance. Every struct implicitly derives from System.ValueType, which itself derives from object, and that is the entire chain. A struct can implement interfaces, but it cannot have a base struct or a base class of its own.
Structs are implicitly sealed, which is why the compiler reports the error in terms of sealing. The rule isn't a limitation invented for structs; it falls out of how value types are laid out in memory. Inheritance with virtual dispatch needs a reference and a type tag, and value types are stored inline by their static type, so there's nowhere to put a base portion that the runtime could swap with a derived portion.
A struct can still polymorphically implement an interface. The mechanics there involve boxing, which we'll cover later in this lesson.
Constructor rules for structs have changed over the years, and the version of C# you target matters. The rules below apply to C# 10 and later, which is the realistic baseline for any new code on .NET 6 or newer. For .NET 8, which is the LTS release most modern projects use, all of these features are available.
Until C# 10, a struct could not declare a parameterless constructor of its own. The compiler always provided one implicitly, and it zeroed every field. Field initializers were also not allowed. C# 10 lifted both restrictions, with one important catch: the implicit zero-initialized form is still always available through default(T) and new T() in certain contexts. We'll trace through each piece.
A struct with a parameterized constructor looks familiar:
The constructor runs the validation and assigns each property. As with classes, you can declare more than one constructor and chain them with : this(...). The compiler still requires that, by the time the constructor returns, every field has been assigned, unless a field initializer or chained constructor already assigned it.
C# 10 added field initializers inside structs. You can give a field or auto-property a default value at the point of declaration, and that value is applied when a constructor runs.
Two C# 10 features show up at once. The parameterless constructor public Cart() { } is explicitly declared, which was a compile error in older versions. The field initializers = "Guest", = 0m, = 0 apply when that constructor runs. Calling new Cart() produces a cart with Owner = "Guest", while new Cart { Owner = "Alice" } invokes the same constructor and then sets Owner through the init setter.
There is a subtle wrinkle worth flagging. Field initializers only run when a constructor (parameterless or otherwise) actually runs. They do not run when a struct is created through default(T), when it is created as part of an array, or when it is left uninitialized in certain reflection paths. We'll dig into default later in this lesson. For now, the rule of thumb is: field initializers are a convenience, not a guarantee, and you should not rely on them to enforce invariants.
Cost: Defining a parameterless constructor on a struct can surprise readers, because for years C# guaranteed that new T() on a struct was free and produced the zeroed value. With C# 10 your constructor body actually runs, and it can throw or do work. If your team's code reviewers expect the old behavior, document the constructor or skip it and use a static factory method.
The compiler still synthesizes a parameterless constructor implicitly when you don't write one, but that implicit constructor zero-initializes every field and does not run your field initializers. To get the initializers, you must declare the parameterless constructor yourself. The line public Cart() { } in the example above is what activates them.
If you want every constructor to share the same initializer values, the cleanest approach is to declare them on the fields and accept that callers must invoke a constructor (parameterless or parameterized) to get those values. Direct array allocation, default(Cart), and unassigned struct fields will skip the initializers and give you zeroed state instead.
The defining behavior of a struct is that assignment copies the value. Two variables of struct type are independent storage; they hold their own copies of the fields. Changing one does not change the other.
salePrice = listPrice is a copy. From that moment on the two variables are unrelated; the assignment salePrice.Amount = 39.99m mutates only salePrice. With a class, the same pattern would have produced a different result: both variables would point to the same object and both would see the change.
The same copy happens when you pass a struct into a method. The parameter is a fresh local variable that holds its own copy of the caller's value. Mutations the method makes to the parameter are invisible to the caller.
The method received its own Stock and modified that copy. The caller's inventory is untouched, which surprises learners who are used to reference semantics. If you actually want the method to mutate the caller's value, you have to opt in with ref:
With ref, the parameter aliases the caller's storage, and the mutation is visible after the call. The verbose syntax is intentional: passing a struct by ref is uncommon enough that the language wants both sides to acknowledge it.
Return values copy too. The value the method returns is copied into the caller's storage, and the local that held it inside the method goes away when the stack frame unwinds.
Three copies happen in that program, and none of them are visible in the source. The call copies listed into the parameter input. The return copies the new Price out of the method. The assignment to discounted copies the returned value into the caller's local. Each copy is a handful of bytes, so the cost is tiny for a small struct. For a large struct (say, 64 bytes of fields), three copies per call adds up and is the main reason struct authors prefer to keep their types small.
Cost: Every assignment, parameter pass, and return on a struct copies the whole struct. For a struct with two or three small fields the copy is essentially free. For a struct with many fields (or a few large ones), the copies dominate, and you either want to keep the struct small or pass it by ref/in when you don't intend to mutate.
The mental model that helps most: with a class, the variable is a handle to a thing. With a struct, the variable is the thing. When you assign or pass a class, the handle gets duplicated; the thing stays put. When you assign or pass a struct, the thing itself gets duplicated.
thisInside an instance method on a struct, this refers to the value itself, not to a reference. Mutating a field through this mutates the current value, which is a copy if the method was called on a copy.
This works, and the mutation persists across calls, because cart is a local variable. When you write cart.AddItem(...), the runtime calls the method on cart itself, and this inside the method aliases the same storage. The mutation lands on the caller's variable.
The picture changes the moment the struct lives somewhere that the caller doesn't directly own. Consider a readonly field on a class:
The increment was lost. The field Container.Counter is readonly, which means the compiler will not let Increment mutate the field directly. To preserve readonly, the compiler silently copies the struct into a temporary, calls Increment on the temporary, and discards the temporary when the statement ends. The copy is what gets incremented, not the field.
This is the most famous trap with mutable structs. It tends to be the reason experienced C# developers default to making their structs immutable. We'll spend a full lesson on readonly structs next, which solve this by removing the ability to mutate at all. For this lesson, the takeaway is that mutating methods on a struct are subtle: they work fine on a local but can quietly produce a copy in any context that needs to defend a readonly, in, or similar guarantee.
The same subtlety appears with structs stored as values inside arrays or generic collections, and in foreach loops, where the loop variable is itself a copy. If you want predictable behavior, design your struct so that its methods return a new value instead of mutating this:
Callers then write counter = counter.Increment();, which is explicit about the copy and works the same whether counter is a local, a field, or an element of a collection.
default Keyword and Zero-Initialized StructsEvery value type has a default value: the bit pattern where every byte is zero. For numeric types that's 0; for bool that's false; for char that's '\0'; for reference-typed fields that's null. For a struct, the default value is the struct where every field has its own default.
You can write that value explicitly with default(T) or with the contextual default literal.
All three produce the same value: Amount = 0m and Currency = null. No constructor ran, no field initializer ran, and yet every field has a defined value because the runtime guarantees zeroed memory for value types. This is sometimes called the zero value of the type.
The same default value shows up in a few places automatically:
new Price[10] gives you ten default Price values).default(T) is the safe way to produce a starting value).The array elements and the unassigned field are all zero-initialized. There is no equivalent for classes; a Stock[] of class type would be an array of null references, and you'd get a NullReferenceException if you tried to read .Available from any of them. Structs cannot be null in that sense (they don't have a separate "no value" state), so the zero default fills the gap.
The flip side is that default is sometimes a value the type should not actually accept. A Price with Currency = null is meaningless, and yet default(Price) produces exactly that. Your constructor can refuse to build such a value, but the constructor doesn't run when someone writes default(Price), so you have no opportunity to reject it.
The usual ways to deal with this are:
Point of (0, 0) is fine, a Duration of zero seconds is fine).Nullable<T> (Price?) and let the absence be expressed by null rather than by a degenerate value.A struct cannot prevent default(T) from existing, but it can make peace with it. The readonly struct lesson next covers types that do this well, because making the value immutable removes most of the risk of zero state landing in an unexpected place.
Cost: Allocating a large array of structs is fast but eager. new Stock[100_000] writes 800,000 bytes (or however many fields times count) of zeros up front. For most array sizes this is negligible; for very large arrays you may prefer a Span<T> view over an ArrayPool<T> rented buffer if zeroing is a bottleneck.
Structs live inline. A struct local lives in the stack frame of the method that declared it. A struct field of a class lives inside the class's heap object, alongside the other fields. A struct element of an array lives inside the array's heap allocation, packed back-to-back with the other elements. There is no separate per-instance heap allocation for a struct, which is the whole point.
The contrast with classes is the clearest way to see this. Consider two equivalent declarations, one as a struct and one as a class:
And two locals:
The variable v is the bytes of the struct itself, sitting in the stack frame. The variable r is a reference (a pointer-sized handle) in the stack frame, and the actual object with the fields lives on the heap. The diagram below makes this concrete:
The struct local needs only the bytes for decimal (16 bytes) plus int (4 bytes), plus a little padding. No heap allocation. No garbage collector tracking. When the method returns, the stack frame is gone and so is v. The class local needs the same 20-ish bytes on the heap plus the object header (8-16 bytes of bookkeeping) plus the reference itself. The garbage collector now has one more object to track, and reading r.Amount is one pointer hop away rather than zero.
When a struct is a field of a class, it lives inside the class's heap object. The class still does one heap allocation, but the struct doesn't add a second one; its bytes are part of the class's bytes.
A diagram of a populated Product shows the struct embedded inside the class's heap object:
The Price struct is part of the Product's memory. Accessing product.Price.Amount is one pointer hop (to the object) plus a field offset, the same cost as product.Stock. There is no second indirection like there would be if Price were a class.
Arrays of structs are similar: the elements live inline, packed contiguously inside the array's heap allocation.
An array of Price is one allocation, regardless of how many elements you have. An array of a corresponding Price class would be one allocation for the array plus one allocation per non-null element. For collections of small values that you create a lot of (points in a path, prices in a cart, samples in a buffer), the struct version is dramatically cheaper to allocate and friendlier to the CPU cache, because the elements are contiguous in memory.
That last detail (cache friendliness) is the kind of thing micro-benchmarks reveal: iterating over an array of structs touches a single contiguous block of memory; iterating over an array of class references jumps to a different heap location for each element, and the CPU has to wait on memory each time. For hot loops over small types, the struct can be many times faster, not because the operations are faster, but because the memory access pattern is better.
Cost: A struct field on a class doesn't cost a separate allocation, but it does make the class larger. If the struct is big and most callers don't read it, the cost is paid by every instance of the class. Profile before optimizing, but be aware that large structs in hot classes can blow up cache lines.
The implication for design is that struct authors care about size. A struct of 8-16 bytes is small enough that copies are negligible. A struct of 32-64 bytes is still defensible. A struct that approaches 100 bytes is usually a class trying to escape, and you'll see the cost on every assignment, parameter pass, and return. The C# language reference and Microsoft's design guidelines suggest 16 bytes as a soft ceiling for general-purpose use, with exceptions for performance-critical paths where copy cost is offset by something else (like avoiding heap allocations).
Structs avoid the heap by default, but they can still end up there. If you assign a struct to a variable of type object, or to a variable of an interface type the struct implements, the runtime has to put the value somewhere a reference can point to. It does that by boxing: allocating a small object on the heap, copying the struct's bytes into it, and returning a reference to that object.
The boxed object on the heap is a separate copy. Once the box exists, the original struct and the boxed value are unrelated, the same way two struct locals are unrelated after one is assigned from the other. The mutation to p doesn't follow into the box.
Unboxing is the reverse: casting an object (or interface reference) back to the underlying struct type copies the bytes out of the box and into the destination variable. The cast also checks the type at runtime, so it can throw InvalidCastException if the box holds something else.
Boxing happens in a few places that are easy to overlook:
ArrayList or a Hashtable. Modern code rarely uses these, but they appear in legacy codebases.List<object> or any collection whose element type is object or a non-generic interface.object or a non-generic interface (IEnumerable, IComparable, anything from the old non-generic world).string.Format on a value type before C# 10's improved interpolation. Modern C# avoids most of these allocations now, but the trap still exists in older targets.Each Add call copies the struct onto the heap inside a box. A loop that adds a million prices to an ArrayList allocates a million boxes, each with its own object header and bookkeeping, all to wrap a value that would have been 16 bytes inline if you'd used List<Price> instead.
Cost: Boxing allocates a small object on the heap (header + struct bytes) every time it happens. In a hot loop, this is one of the easiest ways to accidentally make a struct slower than the class it was meant to replace. Use generic collections (List<T>, Dictionary<TKey, TValue>) and generic methods so the JIT keeps your struct on the stack.
A struct can implement an interface, and the implementation works as expected. The trick is that calling the interface through a struct-typed variable is dispatched directly (no box), while calling it through an interface-typed variable boxes the struct.
The visible output is identical, but the cost is not. The second call went through a box that the first and third calls avoided. For a one-off this doesn't matter; for a tight loop that calls an interface method on a value type, it can dominate the program's allocation profile.
For now the rule is: be aware that interface and object typing on a struct triggers a heap allocation. Generic constraints (where T : IFormattable2) let you call interface methods on a struct without boxing, and that's the standard escape route in performance-sensitive code.
A final example pulls together the rules: a Price struct with value semantics, a Product class that holds it as a field, an array of Product to show inline layout, and a method that demonstrates copy on pass and the consequences for mutation.
Two structs (Sku and Price) and one class (Product) coexist comfortably. Sku is a tiny value type that wraps an integer to make it harder to mix up with other integer IDs. Price is a value type that returns a fresh Price from WithDiscount instead of mutating itself, which sidesteps the mutable-struct trap entirely. Product is a reference type because a product is an entity with identity that callers want to share by reference.
The array catalog is one heap allocation that holds three Product references. Each Product is its own heap allocation that contains the Sku and Price structs inline. Calling catalog[1].Discount(10m) updates the Product's Price field by assigning a new struct value, which copies bytes inline. No extra allocations happen during the discount, because everything that moves is a value type passing through a setter.
That mix is the typical shape you'll see in well-designed C# code: small value types for things that are conceptually values (money, IDs, points, ranges), and classes for things that are conceptually entities (products, customers, orders, carts). Later lessons in this section give you the tools to make those structs even safer (readonly struct), more constrained when you need stack-only semantics (ref struct), or terser with built-in equality (record struct).
struct is a user-defined value type. The variable holds the bytes of the value directly, with no per-instance heap allocation. Assignment, parameter passing, and return all copy the value.System.ValueType. They can implement interfaces.default(T), by array element creation, and by unassigned fields, and it bypasses any constructor or initializer you wrote.default value of a struct is the bit pattern of all zeros: numeric fields are 0, bool is false, reference-typed fields inside the struct are null. Design your struct so the zero state is meaningful, or wrap it in Nullable<T> when missing is a real concept.readonly field, an in parameter, or a foreach loop variable. The safe pattern is to return a new value from each method instead of mutating this.object, to an interface type, or stored in a non-generic collection. Each box is a heap allocation and a copy, which is the most common way struct-heavy code accidentally allocates more than it should.