Data Types in C#: Sizes, Ranges, Value vs Reference Types (.NET 10)

Every variable you declare picks one of the data types in C#, and they come in two families. Value types like int, double, decimal, bool and char hold the data itself; reference types like string, object and arrays hold a reference to it:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");

int units = 3;                        // value type: whole number
decimal price = 24.99m;               // value type: money
bool shipped = false;                 // value type: true/false
string customer = "Jenna Morales";    // reference type: text
Console.WriteLine($"{customer} owes {units * price} (shipped: {shipped})");

It prints:

Jenna Morales owes 74.97 (shipped: False)

Below, the sizes and ranges come straight from sizeof and MaxValue on .NET 10, plus the 0.1 + 0.2 test that settles float vs double vs decimal.

Value Types vs Reference Types in C#

The difference shows up the moment you copy a variable. A value type copies the data; a reference type copies the address of one object. I’ll use a stock count for a Tulsa hardware store, once as an int and once as an int[]:

int stockA = 40;
int stockB = stockA;            // copies the value
stockB -= 5;
Console.WriteLine($"int:   stockA = {stockA}, stockB = {stockB}");

int[] binsA = { 40, 12 };
int[] binsB = binsA;            // copies the reference
binsB[0] -= 5;
Console.WriteLine($"int[]: binsA[0] = {binsA[0]}, binsB[0] = {binsB[0]}");

Console.WriteLine($"int value type?    {typeof(int).IsValueType}");
Console.WriteLine($"int[] value type?  {typeof(int[]).IsValueType}");
Console.WriteLine($"string value type? {typeof(string).IsValueType}");

Output:

int:   stockA = 40, stockB = 35
int[]: binsA[0] = 35, binsB[0] = 35
int value type?    True
int[] value type?  False
string value type? False
C# value type vs reference type in VS Code: copying an int leaves stockA at 40, copying an int[] changes binsA[0] to 35, with IsValueType output
Changing the copied int leaves the original alone; changing the copied array changes both

Subtracting 5 from stockB left stockA at 40. Doing the same through binsB turned binsA[0] into 35, because both names point at one array on the heap.

Value types are all the numeric types, bool, char, enums and structs in C#. Reference types are classes, interfaces, delegates, string, object and arrays in C#, even an array of int.

Note: “value types live on the stack” is only half true. An int field inside a class lives on the heap with that object. What matters is copy semantics, not location.

C# Data Types Table: Keyword, .NET Type, Size and Range

Every built-in keyword is an alias for a .NET type, so int and System.Int32 are the same thing:

int a = 123;
System.Int32 b = 123;           // same type, .NET name
Console.WriteLine(a.GetType() == b.GetType());
Console.WriteLine(typeof(decimal).FullName);
Console.WriteLine(typeof(float).FullName);

Output:

True
System.Decimal
System.Single

I printed sizeof, MinValue and MaxValue for every built-in numeric type plus char on .NET 10. A small Row helper keeps each line short:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");

Row("sbyte",   sizeof(sbyte), sbyte.MinValue, sbyte.MaxValue);
Row("byte",    sizeof(byte), byte.MinValue, byte.MaxValue);
Row("short",   sizeof(short), short.MinValue, short.MaxValue);
Row("ushort",  sizeof(ushort), ushort.MinValue, ushort.MaxValue);
Row("int",     sizeof(int), int.MinValue, int.MaxValue);
Row("uint",    sizeof(uint), uint.MinValue, uint.MaxValue);
Row("long",    sizeof(long), long.MinValue, long.MaxValue);
Row("ulong",   sizeof(ulong), ulong.MinValue, ulong.MaxValue);
Row("float",   sizeof(float), float.MinValue, float.MaxValue);
Row("double",  sizeof(double), double.MinValue, double.MaxValue);
Row("decimal", sizeof(decimal), decimal.MinValue, decimal.MaxValue);
Row("char",    sizeof(char), (int)char.MinValue, (int)char.MaxValue);

void Row(string kw, int size, object min, object max) =>
    Console.WriteLine($"{kw,-8}{size,2} B  {min} to {max}");

Output:

sbyte    1 B  -128 to 127
byte     1 B  0 to 255
short    2 B  -32768 to 32767
ushort   2 B  0 to 65535
int      4 B  -2147483648 to 2147483647
uint     4 B  0 to 4294967295
long     8 B  -9223372036854775808 to 9223372036854775807
ulong    8 B  0 to 18446744073709551615
float    4 B  -3.4028235E+38 to 3.4028235E+38
double   8 B  -1.7976931348623157E+308 to 1.7976931348623157E+308
decimal 16 B  -79228162514264337593543950335 to 79228162514264337593543950335
char     2 B  0 to 65535
C# data types sizes and ranges in VS Code: sizeof, MinValue and MaxValue for sbyte, byte, short, int, long, float, double, decimal and char
sizeof and MinValue/MaxValue printed for 12 built-in numeric and char types on .NET 10

Here’s the same output as a reference table, with the .NET name and the literal suffix you’ll type:

Keyword.NET typeBytesRange (from the run)Literal
sbyteSystem.SByte1-128 to 127—
byteSystem.Byte10 to 255—
shortSystem.Int162-32768 to 32767—
ushortSystem.UInt1620 to 65535—
intSystem.Int324-2147483648 to 2147483647—
uintSystem.UInt3240 to 4294967295U
longSystem.Int648-9223372036854775808 to
9223372036854775807
L
ulongSystem.UInt6480 to 18446744073709551615UL
floatSystem.Single4-3.4028235E+38 to
3.4028235E+38
f
doubleSystem.Double8-1.7976931348623157E+308 to
1.7976931348623157E+308
none or d
decimalSystem.Decimal16-79228162514264337593543950335 to
79228162514264337593543950335
m
charSystem.Char20 to 65535'A'

The reference-type keywords are string (System.String), object (System.Object) and dynamic, which compiles to object. How you declare and name these, including var and const, is covered in variables in C#.

Pro Tip: nint and nuint are native-size integers (4 bytes in a 32-bit process, 8 in a 64-bit one). You’ll only need them for interop and low-level code.

Integer Data Types and Overflow: int, long, byte and checked

I use int for everyday counts, long when values can pass 2.1 billion (file sizes, ticks, IDs) and byte for raw binary data. What happens at the edge surprises people. Same hardware store, now at the int limit:

int maxStock = int.MaxValue;
int wrapped = maxStock + 1;               // unchecked by default
Console.WriteLine($"int.MaxValue + 1 = {wrapped}");

try
{
    int safe = checked(maxStock + 1);     // throws instead
}
catch (OverflowException ex) { Console.WriteLine($"checked: {ex.Message}"); }

long bigger = (long)maxStock + 1;         // widen first
Console.WriteLine($"as long: {bigger}");

int cartons = 300;
byte perPallet = (byte)cartons;           // keeps only the low 8 bits
Console.WriteLine($"(byte)300 = {perPallet}");

Output:

int.MaxValue + 1 = -2147483648
checked: Arithmetic operation resulted in an overflow.
as long: 2147483648
(byte)300 = 44
C# int overflow in VS Code: int.MaxValue + 1 wraps to -2147483648, checked throws OverflowException, (byte)300 becomes 44
Plain int math wraps silently; checked turns the same overflow into an exception

Adding 1 to int.MaxValue quietly gave -2147483648, because C# arithmetic is unchecked by default. Wrap it in checked and you get an OverflowException: “Arithmetic operation resulted in an overflow.”

Casting to long first gave the right answer, 2147483648. (byte)300 kept only the low 8 bits, so it printed 44. Type casting in C# lists which conversions can lose data.

To check a whole project, add <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> to the .csproj. Integer division by zero always throws, even unchecked; DivideByZeroException in C# covers that one.

Float vs Double vs Decimal: Which Should You Use for Money?

All three hold fractions, but float and double are binary, so most decimal fractions like 0.1 can’t be stored exactly. decimal is base 10. Here’s the classic 0.1 + 0.2 test, then ten 10-cent items rung up at a register:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");

float f = 0.1f + 0.2f;
double d = 0.1 + 0.2;
decimal m = 0.1m + 0.2m;
Console.WriteLine($"float:   {f}  equals 0.3? {f == 0.3f}");
Console.WriteLine($"double:  {d}  equals 0.3? {d == 0.3}");
Console.WriteLine($"decimal: {m}  equals 0.3? {m == 0.3m}");

double dTotal = 0;
decimal mTotal = 0;
for (int i = 0; i < 10; i++)          // ten 10-cent items
{ dTotal += 0.10; mTotal += 0.10m; }
Console.WriteLine($"10 x 0.10 as double:  {dTotal}");
Console.WriteLine($"10 x 0.10 as decimal: {mTotal}");
Console.WriteLine($"1/3: {1f / 3} | {1d / 3} | {1m / 3}");

Output:

float:   0.3  equals 0.3? True
double:  0.30000000000000004  equals 0.3? False
decimal: 0.3  equals 0.3? True
10 x 0.10 as double:  0.9999999999999999
10 x 0.10 as decimal: 1.00
1/3: 0.33333334 | 0.3333333333333333 | 0.3333333333333333333333333333
C# float vs double vs decimal precision in VS Code: 0.1 + 0.2 is 0.30000000000000004 as double but 0.3 as decimal, and ten 0.10 items total 1.00 only as decimal
double drifts on 0.1 + 0.2 and on ten 10-cent items; decimal stays exact

The double sum was 0.30000000000000004 and failed the equality test. Ten 10-cent items came to 0.9999999999999999 as a double, but exactly 1.00 as a decimal, which even kept the two decimal places.

The float line printed 0.3 and passed only because 0.3f rounds to the same nearest float. Don’t count on that luck.

TypePrecisionSizeUse it for
float~6-9 digits4 bytesGraphics, game physics, ML data where memory matters
double~15-17 digits8 bytesScience, measurements, Math functions (the default)
decimal28-29 digits16 bytesMoney, prices, tax rates, anything with cents

My rule: decimal for dollars and cents, always with the m suffix. Parsing user input? See converting a string to double in C#, and formatting numbers with commas and decimals for display.

If you start from whole numbers, convert an int to double with 2 decimal places shows the rounding options.

char, bool and string Data Types in C#

These three cover letters, flags and text. I’ll use a short member record to show how each one behaves:

char grade = 'A';
char next = (char)(grade + 1);              // char is a UTF-16 code unit
Console.WriteLine($"{grade} -> {next}, code {(int)grade}");

bool isMember = true;
Console.WriteLine($"isMember: {isMember}, !isMember: {!isMember}");

string first = "Tyler";
string full = first + " Brooks";            // builds a new string
string shout = full.ToUpperInvariant();     // another new string
Console.WriteLine($"{full} ({full.Length} chars) / {shout}");
Console.WriteLine($"== compares text: {full == "Tyler Brooks"}");

Output:

A -> B, code 65
isMember: True, !isMember: False
Tyler Brooks (12 chars) / TYLER BROOKS
== compares text: True

char is a 2-byte UTF-16 code unit, so 'A' + 1 is the number 66 and casting back gave B. bool is only true or false; unlike C, an int never converts to it.

string is a reference type that acts like a value. It can’t change, so + and ToUpperInvariant() returned new strings, and == compares the text (True), not the reference.

That immutability is why heavy appending in a loop belongs to StringBuilder instead of string.

Nullable Value Types: int?, HasValue and ??

A plain int can’t be null, which is a problem for a rating a customer hasn’t given yet. Add ? and you get Nullable<int>:

int? rating = null;                         // customer hasn't rated yet
Console.WriteLine($"HasValue: {rating.HasValue}");
Console.WriteLine($"Shown as: {rating ?? 0} stars");

rating = 4;
if (rating is int stars)
    Console.WriteLine($"Rated {stars} stars");
Console.WriteLine($"GetValueOrDefault: {rating.GetValueOrDefault()}");

int? missing = null;
try { int n = missing!.Value; }          // ! silences the warning, not the error
catch (InvalidOperationException ex) { Console.WriteLine($".Value on null: {ex.Message}"); }

Output:

HasValue: False
Shown as: 0 stars
Rated 4 stars
GetValueOrDefault: 4
.Value on null: Nullable object must have a value.

rating ?? 0 showed 0 stars while it was empty, and the is int stars pattern read the 4 safely. Calling .Value on an empty one threw InvalidOperationException: “Nullable object must have a value.”

Reference types like string are nullable already; string? only tells the compiler you expect null. For the full set of checks, see how to check for null in C# and converting null to int.

object vs var vs dynamic Types: What’s the Difference?

These three look alike in a declaration but behave very differently. Same value, an Austin store city, through each one:

#:property PublishAot=false
using Microsoft.CSharp.RuntimeBinder;

object boxed = 42;                    // holds anything, cast to use it
int n = (int)boxed + 1;
Console.WriteLine($"object:  {n} ({boxed.GetType().Name})");

var city = "Austin";                  // var: string, fixed at compile time
Console.WriteLine($"var:     {city.Length} letters ({city.GetType().Name})");

dynamic anything = 42;                // checked at run time instead
anything = "Austin";
Console.WriteLine($"dynamic: {anything.ToUpperInvariant()}");
try { anything.Launch(); }
catch (RuntimeBinderException ex)
{ Console.WriteLine($"dynamic: {ex.Message}"); }

Output:

object:  43 (Int32)
var:     6 letters (String)
dynamic: AUSTIN
dynamic: 'string' does not contain a definition for 'Launch'
C# object vs var vs dynamic in VS Code: object needs a cast, var is a compile-time string, dynamic throws RuntimeBinderException for a missing method
var is checked by the compiler; dynamic only fails when the line runs

object holds anything, but you cast to use it, and storing 42 there boxes the int (see boxing and unboxing in C#). var isn’t a type at all: the compiler fixed city as String.

dynamic skips compile-time checks. anything.Launch() compiled fine and failed at run time with ‘string’ does not contain a definition for ‘Launch’

Note: .NET 10 file-based apps turn on Native AOT publishing by default, and dynamic isn’t AOT-compatible, so the build prints IL2026/IL3050 warnings. Line 1, #:property PublishAot=false, turns that off.

I reach for dynamic only for COM interop or loosely shaped JSON. Everywhere else, a real type catches the typo before you ship.

Default Values of C# Data Types

Fields, array slots and default(T) start at zero-like values. Local variables don’t; the compiler makes you assign them first:

Console.WriteLine($"int {default(int)}, double {default(double)}, decimal {default(decimal)}");
Console.WriteLine($"bool {default(bool)}, char code {(int)default(char)}");
Console.WriteLine($"string: {default(string) ?? "null"}");
Console.WriteLine($"int? HasValue: {default(int?).HasValue}");

var order = new Order();
Console.WriteLine($"Order: Qty={order.Qty}, Total={order.Total}, Name null? {order.Name is null}");

class Order
{
    public int Qty { get; set; }
    public decimal Total { get; set; }
    public string? Name { get; set; }
}

Output:

int 0, double 0, decimal 0
bool False, char code 0
string: null
int? HasValue: False
Order: Qty=0, Total=0, Name null? True

Numbers default to 0, bool to False, char to code 0 and every reference type to null. The new Order got Qty 0, Total 0 and a null Name without any constructor code.

User-Defined Data Types: struct, enum, class and record

Built-in types cover single values. For your own shapes, C# gives you these:

TypeKindUse it when
struct / record structValueSmall data that copies cheaply (a point, a price + currency)
enumValueA fixed set of named constants, see enum in C#
class / recordReferenceObjects with identity or behavior; record adds value equality
interfaceReferenceA contract several types share, see interfaces in C#
Tuple (int, string)ValueA quick multi-value return, see tuples in C#

Common Data Type Errors in C# and How to Fix Them

Most data type bugs I see are caught at compile time. These four lines each break one rule:

decimal price = 24.99;          // double literal into decimal
int qty = 12.5;                 // double into int
byte pallets = 300;             // out of byte range
decimal tax = price * 0.0825;   // decimal * double

Build errors:

C:\DotNetDemo\DataTypes\TypeErrors.cs(1,17): error CS0664: Literal of type double cannot be implicitly converted to type 'decimal'; use an 'M' suffix to create a literal of this type
C:\DotNetDemo\DataTypes\TypeErrors.cs(2,11): error CS0266: Cannot implicitly convert type 'double' to 'int'. An explicit conversion exists (are you missing a cast?)
C:\DotNetDemo\DataTypes\TypeErrors.cs(3,16): error CS0031: Constant value '300' cannot be converted to a 'byte'
C:\DotNetDemo\DataTypes\TypeErrors.cs(4,15): error CS0019: Operator '*' cannot be applied to operands of type 'decimal' and 'double'
ErrorCauseFix
CS066424.99 is a double literalAdd the suffix: 24.99m
CS0266No implicit double to intCast (int)12.5 or use Math.Round
CS0031300 doesn’t fit in a byteUse short or int
CS0019Can’t mix decimal and doubleMake both decimal: 0.0825m

Picking a type is usually quick: int for counts, decimal for money, double for measurements, string for text, and ? when a value can be missing.

Once your types are settled, these are good next reads:

Frequently Asked Questions

What are the data types in C#?

C# has value types (the integral types sbyte to ulong, float, double, decimal, bool, char, enums and structs) and reference types (string, object, dynamic, arrays, classes, interfaces, delegates and records).

Is string a value type or a reference type in C#?

string is a reference type (System.String), but it’s immutable and == compares the text, so it often feels like a value type.

What is the size of int, long and decimal in C#?

int is 4 bytes (-2,147,483,648 to 2,147,483,647), long is 8 bytes and decimal is 16 bytes. Sizes are fixed on every OS and CPU, as listed on the integral numeric types page on Microsoft Learn.

Should I use double or decimal for money in C#?

Use decimal. It stores base-10 fractions like 0.10 exactly, while double gives 0.9999999999999999 for ten 10-cent items. double is faster and fine for measurements.

What is the difference between int and Int32 in C#?

None. int is the C# keyword alias for System.Int32, and the two compile to the same type.

What happens when an int overflows in C#?

By default it wraps around silently, so int.MaxValue + 1 becomes -2147483648. Use a checked block or the CheckForOverflowUnderflow project setting to get an OverflowException instead.