C# splits every type in the language into one of two camps: value types and reference types. The distinction sounds academic, but it shows up everywhere once you start writing real code, from how variables behave when you copy them to which type to pick to hold a product's price. This lesson covers value types in depth, including the numeric types you'll use every day, the literals that produce them, and the value-copy behavior that makes them predictable.
A value type holds its data directly inside the variable. When you write int quantity = 5;, the variable quantity is the storage that contains the number 5. There's no pointer, no indirection, no second hop to find the data somewhere else in memory. The bits that make up 5 live right where quantity lives.
That has two practical consequences you'll feel constantly.
First, assignment copies the data. When you write int copy = quantity;, the runtime takes the bits inside quantity and stamps a fresh copy of them into copy. The two variables are independent from that point on. Changing one doesn't touch the other.
Second, value types are usually allocated on the stack when they're used as local variables. The stack is a region of memory that grows and shrinks as methods are called and return, and allocating space on it is essentially free. There's no garbage collection involved. When the method that owns the variable returns, the stack space disappears with it.
Here's the stack-memory picture for two local int variables:
The diagram shows two separate stack slots. Each one is four bytes (the size of an int), and each one stores its own copy of the number. When the method that declared them returns, both slots are released at the same time.
The built-in value types in C# include every integer type, every floating-point type, decimal, bool, char, and all struct types you define yourself.
C# has eleven integer types. You'll use int almost all the time; the others fit specific situations. Each one is defined by three things: whether it's signed (can hold negatives) or unsigned (zero and up), how many bits it occupies, and the range that follows from those bits.
| Type | Size | Signed | Range | Default | When to use |
|---|---|---|---|---|---|
sbyte | 8 bits | Yes | -128 to 127 | 0 | Rare. Tight binary protocols. |
byte | 8 bits | No | 0 to 255 | 0 | Raw bytes, file I/O, image data. |
short | 16 bits | Yes | -32,768 to 32,767 | 0 | Rare. Interop with older APIs. |
ushort | 16 bits | No | 0 to 65,535 | 0 | Rare. Network ports, UTF-16 code units. |
int | 32 bits | Yes | -2,147,483,648 to 2,147,483,647 | 0 | The default. Counts, indexes, IDs, quantities. |
uint | 32 bits | No | 0 to 4,294,967,295 | 0 | Rare. Bitmasks, interop. |
long | 64 bits | Yes | About -9.2 quintillion to 9.2 quintillion | 0L | Big counters, timestamps in ticks, file sizes. |
ulong | 64 bits | No | 0 to about 18.4 quintillion | 0UL | Bitmasks, very large unsigned counters. |
nint | Pointer-sized | Yes | Depends on platform (32 or 64 bit) | 0 | Low-level interop. |
nuint | Pointer-sized | No | Depends on platform | 0 | Low-level interop. |
int is the default for two reasons. Numeric literals like 100 are typed as int unless you tell the compiler otherwise. And the .NET API surface uses int everywhere for sizes, indexes, and counts. Going with int means your code lines up with the rest of the ecosystem.
A couple of things to call out. The L suffix on 12_500_000_000L is required because the literal is too large to fit in a 32-bit int. Without the suffix you'd get a compile error. The underscores inside the number are digit separators, introduced in C# 7. They make long numbers readable and the compiler ignores them. You can put them wherever you like, as long as they're between digits.
For unsigned types, the suffixes are U (uint), L (long), and UL (ulong). Lowercase works too, though l by itself looks like a 1 and most teams stick to uppercase L.
You can also write integer literals in other bases:
All three lines store the same number. Hex (0x prefix) is useful for color codes and byte-level constants. Binary (0b prefix, added in C# 7) is handy for bitmasks and flags. Digit separators work in all three bases.
What happens if you add 1 to int.MaxValue? By default in C#, the value silently wraps around to int.MinValue. The runtime doesn't check, it just loses the top bit. This is fast, and it matches how integer arithmetic works in hardware, but it can produce surprising results in a program that wasn't expecting it.
C# gives you two keywords to control this. checked makes arithmetic throw an OverflowException on overflow. unchecked forces the default wrapping behavior even in a project configured to check by default. Both are covered properly in the _Operators_ lesson. For now, just know that overflow exists and silently wraps unless you opt in to checking.
For numbers with a fractional part, C# has three options: float, double, and decimal. They all hold decimal numbers, but they store them very differently and pick the wrong one and you'll get bugs that are hard to track down.
| Type | Size | Precision | Suffix | Default | When to use |
|---|---|---|---|---|---|
float | 32 bits | ~6-9 significant digits | f or F | 0f | Graphics, games, sensor data. |
double | 64 bits | ~15-17 significant digits | d or D (optional) | 0d | Science, statistics, general math. |
decimal | 128 bits | 28-29 significant digits | m or M | 0m | Money, exact base-10 arithmetic. |
float and double both use the IEEE 754 binary floating-point format. That format is fast and works well for most math, but it has one quirk you need to be aware of: it can't represent some decimal numbers exactly. The number 0.1, for example, is a repeating fraction in binary, just like 1/3 is a repeating fraction in base-10. The closest double value to 0.1 is something like 0.1000000000000000055511151231257827021181583404541015625. Close enough for most things, exactly equal for nothing.
Here's the classic demonstration:
The result isn't quite 0.3, it's slightly larger. Equality checks on double rarely behave the way you'd hope. For most general math (physics, statistics, graphics), this is fine. The rounding errors are tiny and they don't accumulate dangerously. For anything where exactness matters, you want a different tool.
float is double's smaller, less precise sibling. It takes half the memory and has roughly half the digits of precision. Use it when you have a lot of numbers and memory or bandwidth matters, like vertex data in a 3D game or pixel buffers in image processing. The f suffix is required, because numeric literals with a decimal point default to double:
The :F2 format specifier rounds the displayed value to two decimal places. The stored value is still the full-precision double, the formatting just controls how it prints.
decimal is built for exact base-10 arithmetic. Internally it stores a 96-bit integer and a scale factor (a power of ten), so any number with up to 28 or 29 significant decimal digits is represented exactly. 0.1m + 0.2m is exactly 0.3m. Prices, tax rates, discounts, totals, balances: all of them belong in decimal.
The m suffix tells the compiler "this is a decimal literal." Without it, 19.99 is a double, and assigning a double to a decimal is a compile error because the conversion isn't implicit. The m stands for "money," which is exactly what it's for.
Here's why you don't want double for prices:
The double total drifts by a tiny amount after ten additions. If your reports rely on equality checks or precise totals, that drift becomes a real problem. The decimal total is exact. A larger online store doing millions of these additions per day would see that drift turn into noticeable money over time.
decimal isn't free. It's about 10 to 20 times slower than double for arithmetic, and it takes 16 bytes instead of 8. For raw speed where exact base-10 isn't required, double is the choice. For anything dealing with currency, audit-friendly numbers, or human-meaningful decimal values, pay the cost and use decimal.
bool and char are the two value types that don't deal with numbers in the everyday sense.
bool holds one of two values: true or false. It's the type of every condition in an if statement, every comparison result, and every flag in your code. It takes one byte of storage (the CLR could pack it into one bit, but byte-aligned access is faster on modern hardware), and its default value is false.
C#'s bool is strict. You can't assign 0 or 1 to it, and if (someInt) doesn't compile the way it would in C or Python. The condition has to be a bool or something that evaluates to one. This trips up newcomers from looser languages but it eliminates a whole class of "truthy" bugs.
char holds a single UTF-16 code unit, which is a 16-bit number that maps to a character through the Unicode standard. Char literals use single quotes, and they support escape sequences like '\n' (newline), '\t' (tab), and 'é' (the character é, given as a Unicode code point).
A char is one UTF-16 code unit, not necessarily one user-perceived character. Most common characters fit in a single char, but emoji and some less-common scripts use a pair of char values called a surrogate pair. For the kind of e-commerce text you'll usually deal with (product codes, single-letter ratings, currency symbols), one char is one character and you can stop worrying.
The default value of bool is false. The default value of char is '\0', the null character. You won't run into those defaults often because the compiler forces you to assign before reading, but they matter inside structs and arrays where uninitialized fields get their type's default.
Every type you've seen so far is built into the language. But you can define your own value types using the struct keyword. A struct works like a class in many ways (it has fields, methods, properties, and constructors), but it has value semantics: assignment copies the data, and there's no separate heap allocation in the common case.
The full coverage of structs lives in the Structs & Enums section. A small preview to anchor the idea:
DateTime is the most common struct in the BCL. You use it constantly:
DateTime is a struct, so the assignment copy = orderPlacedAt copies the actual date and time bits, not a reference. The two variables are independent.
You can write your own struct too. A small Money type that pairs an amount with a currency code is a natural fit:
Even though copy was assigned from price, modifying copy.Amount doesn't touch price.Amount. That's value semantics in action. Reference types behave the opposite way.
Large structs (more than 16 bytes) get copied byte-by-byte on every assignment and parameter pass. For a 256-byte struct, that copy isn't free. The standard guidance is to keep structs small or use a class instead.
The single most important behavior of value types is that assignment copies the data, not a reference. Once you've copied a value type, the two variables are completely independent.
The line int copy = quantity; takes the bits inside quantity (the number 5) and stamps them into a fresh slot called copy. From that point on, copy and quantity have no connection. Assigning 99 to copy overwrites the bits in copy's slot. quantity's slot is untouched.
The same is true for method parameters. When you pass an int to a method, the method receives a copy. Mutating the parameter inside the method doesn't change the caller's variable. The Methods section covers the full picture of parameter passing (including ref and out, which override this default).
This predictability is the main reason value types are easier to reason about than reference types. There's no aliasing surprise, no shared mutable state, no chance that something far away in the program is going to change your variable behind your back.
Every value type has a default value, which is what you get when you read an uninitialized field or use the default keyword. For numeric types it's 0. For bool it's false. For char it's '\0'. For a struct it's a struct with every field set to its own default.
| Type | Default |
|---|---|
sbyte, byte, short, ushort, int, uint, long, ulong, nint, nuint | 0 |
float | 0f |
double | 0d |
decimal | 0m |
bool | false |
char | '\0' |
struct | A struct with every field at its default |
You can write the default explicitly with the default keyword or the default(T) expression:
C# is strict about reading from an uninitialized local variable. The compiler reports error CS0165: Use of unassigned local variable if you try. Fields inside a class or struct, on the other hand, get the type's default automatically when the object is constructed. The reason for the difference is that local variables are easy to track at compile time, so the compiler can enforce assignment before use. Fields are harder, so the runtime zero-fills them instead.
A small program that pulls in most of the value types we've covered, in an e-commerce setting:
Five different value types are at work here, plus one string (the name) which is a reference type. The choices: int for ID and stock count, decimal for price, double for rating, char for the grade bucket, and bool for the stock flag. Each choice fits the data being stored.
10 quizzes