Back to Blog
C#

C# Built-in Data Types: Selection Guide

A practical guide to C# built-in data types: ranges, defaults, memory behavior, and how to choose the right type for your variables.

C# data typesvalue typesreference typesnumeric typestype conversion
C# code snippet showing variable declarations with different built-in data types like int, double, and decimal, with a visual representation of memory allocation.

C# provides a small set of built-in data types that cover most variables you'll declare. The type you choose controls memory usage, range, precision, and runtime behavior. This guide explains the built-in value and reference types, their defaults, and how to pick the right type for common scenarios.

The Built-in Value Types in C#

C# defines built-in value types including the numeric types, bool, and char. Each fixed-size numeric type maps to a storage size and a representable range. C# also provides nint and nuint, native-sized integer types whose storage depends on the runtime architecture; they matter mainly for interop and low-level code.

The integer types are the most common. sbyte and byte are 8-bit, short and ushort are 16-bit, int and uint are 32-bit, and long and ulong are 64-bit. The signed versions use two's complement representation, so they can store negative values, while the unsigned versions cannot. For most application logic, int is a sensible default because it balances range with performance on modern hardware.

Floating-point types are float and double. They store values as binary fractions and are intended for approximate arithmetic. float uses 32 bits and double uses 64 bits. The decimal type is different: it is a 128-bit value type that stores base-10 decimal values, which is why it is often used for financial calculations where small binary rounding errors are unacceptable.

int count = 42; long fileSize = 1024L * 1024 * 1024; double ratio = 0.75; decimal price = 19.99m; bool isActive = true; char grade = 'A';

The numeric types each have MinValue and MaxValue constants that define their range. For example, int.MinValue is -2,147,483,648 and int.MaxValue is 2,147,483,647. Using these constants in validation logic prevents magic numbers and makes the intent clear.

Reference Types That Ship with the Runtime

The built-in reference types are object, string, and dynamic. object is the base class for all types, value or reference. When you assign a value type to an object variable, boxing occurs: the value is copied into a boxed object on the heap. This has a runtime cost and should be avoided in hot paths.

string is a reference type, but it behaves like a value type in many ways. It is immutable, so every modification creates a new instance. Comparing strings with == compares content, not reference, because the operator is overloaded. This is a common source of confusion for developers coming from languages like Java.

dynamic bypasses compile-time type checking, and the actual type is resolved at runtime. This can simplify interop with dynamic languages or COM, but it removes the compiler's safety net and can lead to runtime exceptions if a member does not exist.

object boxed = 42; // boxing string name = "C#"; dynamic value = GetValue(); // type resolved at runtime

When you use dynamic, the compiler emits runtime binding code. This is slower than direct calls and should be limited to scenarios where you genuinely need late binding.

Default Values and the default Keyword

Every built-in type has a default value. Value types default to zero, bool defaults to false, and reference types default to null. This matters when you declare a field or an array element without explicit initialization.

int[] numbers = new int[3]; // all elements are 0 bool[] flags = new bool[2]; // all elements are false string text = null; // default for reference types

The default keyword can be used to obtain the default value of any type, which is useful in generic code:

T GetDefault<T>() => default(T);

For value types, default(T) returns the zeroed value. For reference types, it returns null. The C# compiler does not allow reading a local variable before it is assigned, but fields and array elements are always initialized to their default.

Choosing Between int, long, and decimal

The choice between integer types depends on the range of values you expect. int is sufficient for most counters, IDs, and indexes. Use long when you need to store values larger than 2.1 billion, such as file sizes or timestamps. short and byte are rarely beneficial unless you are working with binary formats or optimizing memory in a large array.

For non-integer numbers, the choice between double and decimal is more nuanced. double is faster and has a wider range, but it stores values as binary fractions. This means 0.1 is not represented exactly, and repeated arithmetic can accumulate small errors. decimal stores digits in base 10, so it represents values like 0.1 exactly, but it is slower and has a smaller range.

Aspectdoubledecimal
Storage size64 bits128 bits
Precision15-17 significant digits28-29 significant digits
Exact decimal?NoYes
Typical useScientific, graphicsFinancial, monetary

Use decimal for money, tax calculations, or any value where rounding errors cannot be tolerated. Use double for measurements, physics, or graphics where range and speed matter more than exact representation.

Memory Behavior: Value Types vs Reference Types

Value types are copied by value. A local value type variable is often stored on the stack, and a value type field is stored inline in its containing object. Reference type objects are allocated on the managed heap, and the variable holds a reference to the object. These details can vary for captured variables, async state machines, and other special cases, but the copy/reference distinction is the essential idea.

When you pass a value type to a method, a copy is made. Changes inside the method do not affect the original variable unless you use the ref or out keyword. When you pass a reference type, the reference itself is copied; the method can modify the referenced object's state, but assigning a new object to the parameter does not change the caller's variable unless you pass it with ref.

void ModifyValue(int x) => x = 10; void ModifyObject(StringBuilder sb) => sb.Append("changed");

Large value types, such as decimal or a custom struct with many fields, can cause copying overhead. In performance-sensitive code, consider whether a smaller value type or a reference type would be more appropriate.

Common Pitfalls with Numeric Types

Overflow is a classic issue. Integer arithmetic wraps around by default in an unchecked context. For example, int.MaxValue + 1 becomes int.MinValue when checked arithmetic is disabled. You can enable checked arithmetic to throw an OverflowException instead:

int a = int.MaxValue; int b = checked(a + 1); // throws OverflowException

Floating-point precision is another trap. Comparing two double values with == can fail because of tiny rounding differences. Use a tolerance or a fixed number of decimal places instead.

double x = 0.1 + 0.2; double y = 0.3; bool equal = Math.Abs(x - y) < 1e-9;

decimal avoids the binary fraction problem, but it can still overflow. decimal.MaxValue is about 7.9e28, which is large but finite. Dividing an integer or decimal by zero throws DivideByZeroException; floating-point division by zero does not throw, and instead returns Infinity or NaN.

Converting Between Built-in Types

Implicit conversions are allowed when the source range fits within the target type's range. For example, int converts implicitly to long, float, double, or decimal; the destination range is larger, even though float may not represent every int exactly. Explicit conversions are required when data could be lost, such as double to int or long to int.

int i = 100; long l = i; // implicit long big = 10000000000; int narrowed = (int)big; // explicit, may overflow

For numeric conversions, the Convert class provides methods that throw exceptions on overflow. The checked keyword also applies to explicit casts and raises an OverflowException if the value does not fit.

int result = checked((int)big); // throws if out of range

String conversions are handled by int.Parse, decimal.Parse, or the TryParse variants. TryParse is preferred when the input might be invalid because it avoids exceptions.

if (int.TryParse(input, out int parsed)) { // use parsed }

When converting between numeric types, be aware of sign and range. By default, casting a negative int to uint wraps modulo 2^32, and casting a double to int truncates toward zero. Checked casts turn out-of-range conversions into exceptions. These behaviors are defined by the language and should be handled explicitly when they matter.

C# Built-in Data Types: How to Choose the Right Type | RYUSLOG DEV