Mert Özen Further With Every Line
Computer Science Fundamentals Number Systems and Data Representation — #01

Binary and Hexadecimal: Positional Notation, Bits, Bytes and Conversions

Mert Özen Nov 14, 2026 13 min 5 views
Binary and Hexadecimal: Positional Notation, Bits, Bytes and Conversions

Hex is shorthand for binary because every two characters map to exactly one byte: four bits make one hex digit. Positional notation, converting by hand, C# and TypeScript literals and formats, reading color codes, GUIDs, hashes and magic numbers, and why a 500 GB disk shows 465 GB in Windows.

Version note: The code in this post was built and run with .NET 10 (C# 14), TypeScript 7.0 and Node.js 24; the C# examples are tested with xUnit v3 4.0.3.

Guess First

Bookstore's design team gave the brand color as #3B82F6. You write the same value in C# as int color = 0x3B_82_F6; and print it. What shows up on the console: 3B82F6, something like 3882006, or a completely different number?

Second question: you buy an SSD labelled 500 GB and plug it into Windows. How many GB does File Explorer show? Is the manufacturer lying, or is Windows doing the math wrong?

Third question: a user uploads a profile photo called avatar.png. The extension says PNG, but the first four bytes of the file are 50 4B 03 04. What is this file really?

By the end of this post you'll be able to answer all three by doing the arithmetic. This series is about how a computer stores everything as bits: number bases and hex first, then bitwise operations, integer and floating point representation, and finally text and Unicode. All of it rests on the idea in this post: a digit's value comes from where it stands.

Positional Notation: A Digit's Value Comes from Its Position

When we write 173 in the everyday decimal system, we're really writing a sum: 1 × 100 + 7 × 10 + 3 × 1. Each position is worth ten times the one to its right: 1, 10, 100, 1000. This is positional notation, and there's nothing special about the number ten. Pick a base, or radix, of 2 and the positions become 1, 2, 4, 8, 16… and you only use the digits 0 and 1. Pick base 16 and the positions are 1, 16, 256, 4096… and you need sixteen different digits: 0–9 followed by A–F, where A = 10 and F = 15.

Here is the same 173 written in four bases:

BaseWritten asExpanded
10 (decimal)1731×100 + 7×10 + 3×1
2 (binary)10101101128 + 32 + 8 + 4 + 1
8 (octal)2552×64 + 5×8 + 5×1
16 (hexadecimal)AD10×16 + 13×1

Computers work in binary because telling two states apart in hardware, voltage or no voltage, is reliable and cheap. Today you mostly meet octal in Unix file permissions (chmod 755). Hex, on the other hand, is everywhere: color codes, GUIDs, hash outputs, memory addresses, every byte dump you look at while debugging.

Converting by Hand: Division and Horner

To go from decimal to another base, you divide repeatedly. Divide 173 by 2, write down the remainder, continue with the quotient: 173 → remainder 1, 86 → 0, 43 → 1, 21 → 1, 10 → 0, 5 → 1, 2 → 0, 1 → 1. Read the remainders from right to left and you get 10101101. The first remainder you find is the rightmost digit, which is why the code uses a stack:

namespace Bookstore.Numbers;

public static class Radix
{
    private const string Digits = "0123456789ABCDEF";

    // Repeated division: each remainder is the next digit, from right to left
    public static string ToBase(uint value, int radix)
    {
        ArgumentOutOfRangeException.ThrowIfLessThan(radix, 2);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(radix, 16);
        if (value == 0)
        {
            return "0";
        }

        var digits = new Stack<char>();
        while (value > 0)
        {
            digits.Push(Digits[(int)(value % (uint)radix)]);
            value /= (uint)radix;
        }
        return string.Concat(digits);
    }

    // Horner's method: multiply what we have by the base, add the next digit
    public static uint FromBase(string text, int radix)
    {
        uint result = 0;
        foreach (char c in text.ToUpperInvariant())
        {
            int digit = Digits.IndexOf(c);
            if (digit < 0 || digit >= radix)
            {
                throw new FormatException($"'{c}' is not a base-{radix} digit.");
            }
            result = checked(result * (uint)radix + (uint)digit);
        }
        return result;
    }
}

The reverse direction uses Horner's method: start from the left, multiply what you've accumulated by the base, add the new digit. For "AD": 0 × 16 + 10 = 10, then 10 × 16 + 13 = 173. No powers needed. The checked matters: an input that doesn't fit in 32 bits, like "1FFFFFFFF", throws OverflowException instead of quietly turning into a wrong number. The tests check that behavior and that the results match the framework's own conversion:

[Theory]
[InlineData(173u, 2, "10101101")]
[InlineData(173u, 8, "255")]
[InlineData(173u, 16, "AD")]
[InlineData(0u, 2, "0")]
[InlineData(uint.MaxValue, 16, "FFFFFFFF")]
public void ToBase_matches_framework(uint value, int radix, string expected)
{
    Assert.Equal(expected, Radix.ToBase(value, radix));
    Assert.Equal(expected, Convert.ToString(value, radix).ToUpperInvariant());
}

You won't write this class in production; the framework does it all. But once you've done it by hand, you start seeing the number when you look at hex.

Bits, Nibbles, Bytes, Words: Why Hex Is Shorthand for Bytes

A bit is a single binary digit. Four bits make a nibble, which holds 0000 to 1111, that is, 0 to 15. That range maps exactly onto one hex digit. Eight bits make a byte: two nibbles, so two hex digits, 0x00 to 0xFF (0–255).

That's the real strength of hex: going from binary to hex needs no arithmetic at all. You group the bits by four from the right and turn each group into one hex digit. 1010 1101 → A D. The other way, 0x3B82F6 → 0011 1011 1000 0010 1111 0110. Writing a 24-bit number in binary takes 24 characters, in hex it takes 6, and every pair of characters is exactly one byte. In decimal the byte boundaries disappear: looking at 3900150 you can't tell which byte is which.

A word is a term that depends on the architecture. The type names in the Windows API date from the 16-bit era, so there WORD is 16 bits, DWORD 32 and QWORD 64. A CPU's "natural" word size today is usually 64 bits. When you see "word" in a document, check which meaning it uses.

Binary and Hex in C#: Literals, Formatting and Conversion

Since C# 7.0 you can write numbers in binary directly in source code and split long numbers with _. The separator is only for readability; it doesn't change the value:

int flags = 0b1010_1101;
int color = 0x3B_82_F6;
long population = 8_100_000_000;

Console.WriteLine(flags);                         // 173
Console.WriteLine(color);                         // 3900150
Console.WriteLine(population);                    // 8100000000
Console.WriteLine(Convert.ToString(flags, 2));    // 10101101
Console.WriteLine(Convert.ToString(flags, 8));    // 255
Console.WriteLine(Convert.ToString(flags, 16));   // ad
Console.WriteLine(flags.ToString("X"));           // AD
Console.WriteLine(flags.ToString("X8"));          // 000000AD
Console.WriteLine(flags.ToString("B16"));         // 0000000010101101
Console.WriteLine(Convert.ToString(-1, 2));       // 11111111111111111111111111111111

Here's the answer to the first question: 0x3B_82_F6 is just a number, and the console prints its decimal value, 3900150. The base you use in source code tells the compiler how you wrote it; in memory the same bits sit there either way.

Watch the details: Convert.ToString(x, 16) produces lowercase, the "X" format uppercase, "x" lowercase. "X8" pads the number to at least 8 digits; if you don't use "X2" when writing bytes, 10 becomes "A", 255 becomes "FF", and bytes written side by side can no longer be split apart. The "B" format arrived in .NET 8 and made binary output shorter than Convert.ToString. The 32 ones on the last line are the subject of the third post in this series: what -1 looks like at the bit level.

For byte arrays there are Convert.ToHexString and Convert.FromHexString (.NET 5+), and the lowercase variant Convert.ToHexStringLower came with .NET 9. Turning a color code into RGB is one line with them:

public readonly record struct Rgb(byte R, byte G, byte B)
{
    public static Rgb FromHex(string hex)
    {
        ReadOnlySpan<char> digits = hex.AsSpan().TrimStart('#');
        if (digits.Length != 6)
        {
            throw new FormatException("Expected 6 hex digits.");
        }
        byte[] bytes = Convert.FromHexString(digits);
        return new Rgb(bytes[0], bytes[1], bytes[2]);
    }

    public string ToHex() => $"#{R:X2}{G:X2}{B:X2}";
}
Rgb blue = Rgb.FromHex("#3B82F6");
Console.WriteLine(blue);          // Rgb { R = 59, G = 130, B = 246 }
Console.WriteLine(blue.ToHex());  // #3B82F6

3B = 3 × 16 + 11 = 59, 82 = 130, F6 = 246. A CSS color code is nothing more than three bytes written side by side.

The Same Work in TypeScript

JavaScript and TypeScript also have the 0b, 0o and 0x prefixes and the _ separator. The differences are in the conversion functions:

const flags = 0b1010_1101;
const color = 0x3b_82_f6;

console.log(flags, color);                                       // 173 3900150
console.log(flags.toString(2));                                  // 10101101
console.log(flags.toString(16));                                 // ad
console.log(flags.toString(16).toUpperCase().padStart(8, "0"));  // 000000AD
console.log(parseInt("ad", 16), parseInt("10101101", 2));        // 173 173
console.log(Number("0xAD"), Number("0b10101101"));               // 173 173
console.log(parseInt("0x1F", 16), parseInt("1F", 10), parseInt("1G", 16), Number("1G"));
// 31 1 1 NaN
console.log((-1).toString(2));                                   // -1
console.log((-1 >>> 0).toString(2));                           // 11111111111111111111111111111111

function hexToRgb(hex: string): [number, number, number] {
  const digits = hex.replace(/^#/, "");
  return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)) as [number, number, number];
}
console.log(hexToRgb("#3B82F6"));                                // [ 59, 130, 246 ]

Two lines need attention. parseInt doesn't fail on an invalid character; it returns whatever it has read so far: parseInt("1G", 16) gives 1, while Number("1G") gives NaN. When validating a hex value from a user, don't rely on parseInt; check it first with a regex like /^[0-9a-f]+$/i. Second, (-1).toString(2) gives -1, not 32 ones as in C#; JavaScript writes the number with its sign. To see the bits you need the >>> 0 trick, and the next post explains why it works.

Reading Hex: GUIDs, Hashes and Magic Numbers

Once you can read hex, some values start showing their structure. Bookstore uses Guid.CreateVersion7(), which came with .NET 9, for order IDs:

Guid orderId = Guid.CreateVersion7();
Console.WriteLine(orderId);                       // 01a1d759-f419-717a-bfda-090b4a0e002c
Console.WriteLine(orderId.Version);               // 7
Console.WriteLine(orderId.ToString("N").Length);  // 32
Console.WriteLine(orderId.ToByteArray().Length);  // 16

32 hex characters = 16 bytes = 128 bits. The first character of the third group, the 7 in 717a, is the GUID's version; in RFC 9562, which defines UUIDs, the first 48 bits of a version 7 UUID are a millisecond timestamp, so IDs created one after another sort in order. The first character of the fourth group, b = 1011, carries the variant bits. In the logs you can tell at a glance whether a GUID is version 4 or 7.

Hash outputs are read as hex too. SHA-256 always produces 32 bytes, 64 characters in hex:

byte[] hash = SHA256.HashData("Bookstore"u8);
Console.WriteLine(hash.Length);                   // 32
Console.WriteLine(Convert.ToHexString(hash));
// A77D2912A0F30BED2BED84F2EF923ECDFB7E3AF45FFD1928282C2E12DF5591FC
Console.WriteLine(Convert.ToHexStringLower(hash));
// a77d2912a0f30bed2bed84f2ef923ecdfb7e3af45ffd1928282c2e12df5591fc

A file's type is also often written in its first few bytes. These fixed openings are called magic numbers, or file signatures. You can look at them with xxd in Git Bash or on Linux, or with Format-Hex in PowerShell:

$ xxd -l 16 cover.png
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
$ xxd -l 16 invoice.pdf
00000000: 2550 4446 2d31 2e34 0a25 d3eb e9e1 0a31  %PDF-1.4.%.....1
$ xxd -l 16 avatar.png
00000000: 504b 0304 0a00 0000 0000 6917 6e5d 8c39  PK........i.n].9

The left column is the offset, the middle is the hex bytes, and the right column shows the same bytes as printable ASCII, with dots for the unprintable ones. The PNG signature was chosen on purpose: the first byte 0x89 has its top bit set, then comes a readable PNG, then line-ending characters. If the file passed through a 7-bit channel somewhere or had its line endings converted, the signature no longer matches. PDF starts with plain-text %PDF-. 50 4B 03 04, that is PK, is the ZIP signature; .docx, .xlsx and .nupkg files are ZIP archives inside, so they start with the same bytes. The answer to the third question: avatar.png is really a ZIP archive.

public static class FileSignature
{
    private static readonly (string Kind, byte[] Magic)[] Known =
    [
        ("PNG", [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
        ("PDF", "%PDF-"u8.ToArray()),
        ("ZIP", [0x50, 0x4B, 0x03, 0x04]),
    ];

    public static string Detect(ReadOnlySpan<byte> header)
    {
        foreach ((string kind, byte[] magic) in Known)
        {
            if (header.StartsWith(magic))
            {
                return kind;
            }
        }
        return "unknown";
    }
}

Reading the first eight bytes of each file in the folder and passing them to this method prints:

avatar.png   504B03040A000000 ZIP
cover.png    89504E470D0A1A0A PNG
export.zip   504B03040A000000 ZIP
invoice.pdf  255044462D312E34 PDF

KB or KiB: Why a 500 GB Disk Shows 465 GB

SI prefixes are decimal: kilo = 1000, mega = 1000², giga = 1000³. Because memory addressing works in powers of two, people in computing said "kilo" and meant 1024 for a long time. To clear up the confusion, the IEC defined binary prefixes at the end of 1998: kibi (KiB = 1024), mebi (MiB = 1024²), gibi (GiB = 1024³). Today these definitions live in the IEC 80000-13 standard.

long advertised = 500_000_000_000;
Console.WriteLine((advertised / 1e9).ToString("F2", CultureInfo.InvariantCulture));                // 500.00
Console.WriteLine((advertised / Math.Pow(1024, 3)).ToString("F2", CultureInfo.InvariantCulture));  // 465.66

The manufacturer sells 500 billion bytes and correctly labels them 500 GB. Windows File Explorer divides the same number by 1024³ but still labels the unit "GB", so you see 465 GB. macOS has used decimal units since 10.6, so it shows the same disk as 500 GB. On Linux, df -h uses units of 1024 and df -H units of 1000. No bytes are missing; two different units are shown under the same name. Network speeds in Mbps are always decimal and in bits; a 100 Mbps link carries at most 12.5 million bytes per second.

In Production

Pick one format for logging bytes. Convert.ToHexString writes contiguous uppercase, while BitConverter.ToString produces a dashed format like A7-7D-29. If two parts of the same system write different formats, searching the logs won't find matches.

For file uploads, check the type from the first bytes, not from the extension or the Content-Type the client sent. But a magic number is only a first check: it's possible to craft files that are both a valid PNG and something else. Real validation means opening the file with the intended library and re-saving it; that belongs to the Web Security (OWASP Top 10) series.

Compare hashes as bytes, not as strings. sha256sum on Linux produces lowercase hex, PowerShell's Get-FileHash uppercase; compare them as strings and you'll call the same hash "different". Turn the hex into bytes with Convert.FromHexString and use CryptographicOperations.FixedTimeEquals; it always takes the same time to finish, so the timing leaks nothing:

string published = "a77d2912a0f30bed2bed84f2ef923ecdfb7e3af45ffd1928282c2e12df5591fc";
Console.WriteLine(Convert.ToHexString(hash) == published);                                    // False
Console.WriteLine(Convert.ToHexString(hash).Equals(published, StringComparison.OrdinalIgnoreCase)); // True
Console.WriteLine(CryptographicOperations.FixedTimeEquals(hash, Convert.FromHexString(published))); // True

Write the unit explicitly when you show capacity. If the monitoring dashboard shows a disk as "465 GB" while the purchasing team's invoice says 500 GB, the missing unit label costs you a support ticket. If your own formatting code divides by 1024, label it GiB/MiB.

Common Pitfalls

Comparing hex strings as strings. The configuration file has "0A", the user typed "a"; both mean 10, but as strings they're not equal:

string fromConfig = "0A";
string fromUser = "a";
Console.WriteLine(fromConfig == fromUser);                                              // False
Console.WriteLine(Convert.ToInt32(fromConfig, 16) == Convert.ToInt32(fromUser, 16));    // True

Either parse both sides into numbers and compare, or store values only after bringing them to one canonical form, such as "X2" with a fixed length and fixed case. This bug is common with color codes, MAC addresses and hashes.

Joining bytes without fixing the digit count. $"{r:X}{g:X}{b:X}" produces A82F6 for RGB(10, 130, 246), five characters. When you read it back, you can't tell where one byte ends. Always use X2 per byte.

Leading zeros in JavaScript. In old JavaScript, 010 is octal and has the value 8. In strict mode and in files loaded as modules this syntax is an error:

console.log(010);
// SyntaxError: Octal literals are not allowed in strict mode.

TypeScript catches it at compile time too: error TS1121: Octal literals are not allowed. Use the syntax '0o10'. But in an old, non-module script running in the browser, 010 still silently becomes 8. Keep values with leading zeros, such as postal codes or phone numbers, as strings, not numbers; you won't do arithmetic on them anyway.

Trusting the unit label. When you see "GB", you can't know whether it means 1000³ or 1024³ without checking the source. On a 1 TB disk the difference is about 69 GB, or 7 percent.

A Small Experiment

★☆☆ Convert 2026 to binary and hex by hand: first find the binary with the division method, then group the bits by four to get hex. Check the result with Convert.ToString(2026, 2) and (2026).toString(16).

★★☆ Add the GIF (GIF87a or GIF89a) and JPEG (FF D8 FF) signatures to FileSignature and write a test for each. Look at the first bytes of a JPEG you have with xxd or Format-Hex and confirm the test against a real file.

★★★ Write a small xxd in C#: 16 bytes per line, an 8-digit hex offset on the left, hex grouped in pairs of bytes in the middle, printable ASCII characters on the right (0x20–0x7E; dots for the rest). Compare your output line by line with the real xxd output for cover.png.

In this post we learned to read bits. The next post in the series, "Bitwise Operations", is about changing them one by one: we'll keep Bookstore's user permissions in a single integer, count them with BitOperations, and see how JavaScript silently breaks bitwise operators on large numbers.