Positional number systems (base 2, 10 and 16), converting between them by hand and in C#, and why hex is the natural way to read bytes, memory addresses, colours, hashes and GUIDs.
They're all positional number systems; only the base changes. Decimal is base 10 (digits 0β9), binary is base 2 (0 and 1) and hexadecimal is base 16 (0β9 then AβF). Computers store everything in binary because transistors have two states. Developers use hex because one hex digit is exactly four bits, so a byte is always two hex digits: 0xFF is 1111 1111 is 255. That makes memory addresses, hashes, GUIDs and byte dumps short and easy to read. In C# you write 0b1010 or 0xFF and format with ToString("X2") or Convert.ToHexString.
A number's value is the sum of each digit multiplied by the base raised to its position (counting from 0 on the right).
| System | Base | Digits | Example | Value |
|---|---|---|---|---|
| Decimal | 10 | 0β9 | 255 |
2Γ100 + 5Γ10 + 5Γ1 |
| Binary | 2 | 0β1 | 1111 1111 |
128+64+32+16+8+4+2+1 = 255 |
| Hexadecimal | 16 | 0β9, AβF | FF |
15Γ16 + 15 = 255 |
The notation never changes what can be stored. A 32-bit int overflows at 2,147,483,647 whether you write it as 2147483647, 0x7FFFFFFF or 0b0111...1.
Digital hardware stores and switches between two reliable states (high/low voltage, charged/not charged). Every value in memory, whether a number, character, pixel or instruction, is a pattern of bits. Eight bits make a byte, the smallest addressable unit of memory.
00 to FF), so byte arrays, hashes and memory dumps line up neatly.11011110101011011011111011101111); decimal hides the byte boundaries. Hex shows both: DEADBEEF.| Where you see hex as a .NET developer | Example |
|---|---|
| Memory addresses in the debugger and dumps | 0x00007FF8A1B2C3D0 |
| Hashes, GUIDs, ETags | SHA-256 = 64 hex characters |
| HRESULT error codes | 0x80070005 (access denied) |
| Colours and Unicode | #1E90FF, U+00E9 |
| File signatures | 25 50 44 46 = %PDF |
| Task | API |
|---|---|
| Literals | 0b1111_1111, 0xFF, 1_000_000 |
| Format a number | 255.ToString("X2") β FF, "x2" β ff, "B8" β 11111111 |
| Bytes to hex | Convert.ToHexString(bytes), Convert.ToHexStringLower(bytes) |
| Hex to bytes | Convert.FromHexString(text) (throws on odd length or bad characters) |
| Parse a number | int.TryParse("FF", NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var v), Convert.ToInt32("1010", 2) |
Hex is a text representation: it doubles the size of the data and each conversion allocates a string. Keep data as byte[] or Span<byte> internally and convert only for logs, URLs and display.
A payment integration returned a signature mismatch. Logging the computed HMAC with Convert.ToHexString and comparing it with the provider's lowercase hex showed the bytes matched but the case did not. Comparing the decoded bytes with CryptographicOperations.FixedTimeEquals fixed it and also made the comparison constant-time. Being fluent in hex made it a five-minute fix instead of an afternoon of guessing.
A warm-up that checks fundamentals and comfort with low-level representations.
Green flags: explains positional notation, converts quickly, knows the four-bits-per-hex-digit rule, gives real places hex appears, knows the C# literals and formatting.
Red flags: can't convert a small number, can't explain why hex is used, confuses bits and bytes.
position: 7 6 5 4 3 2 1 0
binary: 128 64 32 16 8 4 2 1 (2^n)
hex: - - - - 4096 256 16 1 (16^n)
decimal: 10^7 ... 1000 100 10 1 (10^n)
Decimal 202 to binary (divide by 2, read remainders bottom-up):
202 / 2 = 101 r0
101 / 2 = 50 r1
50 / 2 = 25 r0
25 / 2 = 12 r1
12 / 2 = 6 r0
6 / 2 = 3 r0
3 / 2 = 1 r1
1 / 2 = 0 r1 -> 1100 1010
Binary to hex (group in fours): 1100 1010 β C A β 0xCA.
Hex to decimal: 0xCA = 12Γ16 + 10 = 202.
| Hex | Binary | Dec | Hex | Binary | Dec |
|---|---|---|---|---|---|
| 0 | 0000 | 0 | 8 | 1000 | 8 |
| 1 | 0001 | 1 | 9 | 1001 | 9 |
| 2 | 0010 | 2 | A | 1010 | 10 |
| 3 | 0011 | 3 | B | 1011 | 11 |
| 4 | 0100 | 4 | C | 1100 | 12 |
| 5 | 0101 | 5 | D | 1101 | 13 |
| 6 | 0110 | 6 | E | 1110 | 14 |
| 7 | 0111 | 7 | F | 1111 | 15 |
| Power | Value | Where you meet it |
|---|---|---|
| 2βΈ | 256 | Values in a byte |
| 2ΒΉβ° | 1,024 | KiB |
| 2ΒΉβΆ | 65,536 | Ports, ushort, UTF-16 code units |
| 2Β³ΒΉ β 1 | 2,147,483,647 | int.MaxValue |
| 2Β³Β² | ~4.29 billion | IPv4 addresses, uint |
| 2βΆβ΄ | ~1.8 Γ 10ΒΉβΉ | ulong |
Note the difference between KB (1,000 bytes) and KiB (1,024 bytes). Disk vendors use KB/MB/GB; Windows Explorer and many tools show KiB/MiB/GiB values with KB/MB/GB labels.
Hex keeps byte boundaries visible, so patterns jump out that decimal hides.
| What you're looking at | What to recognise in hex |
|---|---|
| A hex dump of an unknown file | File signatures ("magic numbers"): 25 50 44 46 = %PDF, 50 4B 03 04 = PK (ZIP, .docx, .xlsx, .nupkg), 89 50 4E 47 = PNG, 1F 8B = gzip |
| A text file with odd characters | EF BB BF = UTF-8 BOM, C3 A9 = UTF-8 Γ©, 0D 0A = CRLF line ending |
| An exception message | HRESULTs such as 0x80070005 (access denied) and 0x80131500 (generic .NET COR_E_EXCEPTION) |
| The Memory window or a dump | Addresses like 0x00007FF8... (64-bit), object headers and method table pointers |
| A corrupted buffer or protocol frame | ASCII text runs (41β5A letters) where binary should be, repeated fill bytes, or a length field that reads as a huge number |
Example: an upload API rejected some "PDF" files. A hex dump of the first bytes showed 50 4B 03 04: they were Word documents renamed to .pdf. Checking the signature instead of the file extension fixed the validation, and also closed a hole where any file type could be uploaded with a .pdf name.
3F2504E0-4F89-11D3-9A0C-0305E82C3301 is 16 bytes in hex (with mixed byte order in the first three groups; see the endianness question).0x80070002 means a Win32 error (facility 7) with code 2, "file not found".Base 8 (three bits per digit) still appears in Unix file permissions: chmod 755 means rwx r-x r-x. C# has no octal literal; use Convert.ToInt32("755", 8) or UnixFileMode flags.
Convert.FromHexString rejects odd-length strings and invalid characters with a FormatException; hand-written parsers often accept garbage silently, which matters when parsing untrusted input such as tokens or signatures.Convert.ToHexString is vectorised and allocates once; string concatenation in a loop is slow and allocation-heavy.int.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value) instead of catching exceptions.173.ToString("B8") and 173.ToString("X2")."hello" with SHA256.HashData and print it with Convert.ToHexStringLower. Count the characters (64) and explain why.Γ© and spot the two-byte sequence C3 A9..docx and a .pdf and identify each from its first four bytes.namespace NumberSystems;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static class Program
{
public static void Main()
{
int value = 0b1100_1010; // binary literal with a digit separator
Console.WriteLine(value); // 202
Console.WriteLine(value.ToString("X2")); // CA
Console.WriteLine(value.ToString("x2")); // ca
Console.WriteLine(value.ToString("B8")); // 11001010
bool ok = int.TryParse("CA", NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int fromHex);
int fromBinary = Convert.ToInt32("11001010", 2);
Console.WriteLine(ok && fromHex == fromBinary); // True
// A hex literal above int.MaxValue is a uint
uint mask = 0x80000000;
int asInt = unchecked((int)mask); // -2147483648: same bits, different meaning
Console.WriteLine($"{mask} {asInt}");
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes("hello"));
Console.WriteLine(Convert.ToHexStringLower(hash)); // 64 hex chars
// Compare secrets as bytes, in constant time, not as hex strings
byte[] received = Convert.FromHexString(Convert.ToHexString(hash));
Console.WriteLine(CryptographicOperations.FixedTimeEquals(hash, received)); // True
HexDump(Encoding.UTF8.GetBytes("CafΓ© β"));
}
private static void HexDump(ReadOnlySpan<byte> data)
{
for (var offset = 0; offset < data.Length; offset += 16)
{
var line = data.Slice(offset, Math.Min(16, data.Length - offset));
var hex = new StringBuilder();
var text = new StringBuilder();
foreach (var b in line)
{
hex.Append(b.ToString("X2")).Append(' ');
text.Append(b is >= 0x20 and < 0x7F ? (char)b : '.');
}
Console.WriteLine($"{offset:X8} {hex,-48} {text}");
}
}
}
0x10 is 16, not 10, and 0x80070005 is a bit pattern, not a large decimal number. Instead: Always note the base, and keep the 0x prefix when writing or logging hex values."FF" is two characters of text; 0xFF is the integer 255, so comparing formatted strings ("255" vs "FF") fails. Instead: Parse first and compare numbers or bytes.int.Parse or a hand-written loop. Why it is wrong: Parse throws on bad input (exceptions are slow and noisy on hot paths), and hand-written loops accept odd-length strings or stray characters, which matters for tokens and signatures from third parties. Instead: Use int.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var v) for numbers and Convert.FromHexString (or the span-based Convert.FromHexString(ReadOnlySpan<char>, Span<byte>, out _, out _) overload in .NET 9+) for bytes; both validate input and are vectorised.int. Why it is wrong: 0x80000000 and 0xFFFFFFFF are uint literals; int x = 0xFFFFFFFF; does not compile, and unchecked((int)0xFFFFFFFF) is -1. Instead: Use the type that matches the width, and unchecked casts only when you mean a bit pattern.ToString("X"). Why it is wrong: It drops leading zeros, so 0x0A prints as A and joined strings become ambiguous. Instead: Use "X2" per byte or Convert.ToHexString for whole arrays."ff" and "FF" are the same byte, so a signature check fails for no real reason. Instead: Compare decoded bytes, with CryptographicOperations.FixedTimeEquals for secrets.char(64) takes twice the space of binary(32), makes indexes bigger and slower, and invites case mismatches. Instead: Store binary/varbinary (or uniqueidentifier for GUIDs) and convert to hex only for display.Fluency in hex is a debugging and performance skill, not trivia. Seniors read hex dumps to diagnose encoding bugs and corrupted serialisation, recognise HRESULTs and Win32 error codes in exceptions, verify hashes and signatures, and read object headers and field layouts in memory dumps when chasing memory or cache problems.
They separate notation from storage: binary, decimal and hex are only ways to write a value; the type's width decides the range, and integer conversions between the bases are always exact. They use hex for bitwise work, because masks like 0xFF00 or 0x0F show exactly which bits are kept.
They treat hex strings as a presentation format: keep bytes as bytes internally (Span<byte>, BinaryPrimitives), convert with Convert.ToHexString only at boundaries, and compare secrets as bytes in constant time. Logging a token in hex is still logging the token.
A bit is a single 0 or 1; a byte is 8 bits and is the smallest unit memory addresses. C# numeric types have fixed sizes on every platform: byte 1 byte, short 2, int 4, long 8, Int128 16, Half 2, float 4, double 8, decimal 16. Signed integers use two's complement, so int runs from β2,147,483,648 to 2,147,483,647. Integer arithmetic is unchecked by default: int.MaxValue + 1 silently wraps to int.MinValue, while float/double never throw and produce Infinity or NaN, and decimal always throws. Make overflow explicit where a wrap would be a bug (money, counters, sizes from external input): use checked, a wider type, or generic-math conversions such as int.CreateChecked(x) and int.CreateSaturating(x).
Computers store numbers, so text needs two agreements: a character set that gives each character a number, and an encoding that turns those numbers into bytes and back. ASCII is both at once: 128 characters (0β127) stored in 7 bits, enough for English but not Γ©, Δ or ζ±. Unicode is a character set only: it gives every character in every script a code point from U+0000 to U+10FFFF (U+0041 is A, U+00E9 is Γ©). UTF-8, UTF-16 and UTF-32 are the encodings that store code points as code units of 8, 16 or 32 bits. What a user sees as one character, a grapheme cluster, can be several code points (an emoji with a skin-tone modifier, or e plus a combining accent), and the same text can be written in composed or decomposed form, so applications normalise (usually to NFC) before comparing or storing user input.
Last updated 23 Oct 2026.