Binary, hex and octal: number bases every developer uses
· 6 min read
Most of the time you can ignore how numbers are written. Then a colour is #FF8800, a file mode is 0755, a hash is 64 hex characters, a bit flag has to be checked with &, and -1 shows up in a debugger as 0xFFFFFFFF. At that point binary, octal and hex stop being school maths and become things you read every day.
This article covers the number bases developers actually run into, why each one exists, and the conversion traps in JavaScript and other languages. The number base converter converts between all of them live, including negative numbers and arbitrarily large values.
One idea: positional notation
Every base works the same way. Each digit is multiplied by a power of the base, and the results are added:
decimal 255 = 2·10² + 5·10¹ + 5·10⁰
binary 11111111 = 1·2⁷ + 1·2⁶ + … + 1·2⁰ = 255
hex FF = 15·16¹ + 15·16⁰ = 255
octal 377 = 3·8² + 7·8¹ + 7·8⁰ = 255
Bases above 10 need more digit symbols, so hex uses A to F for 10 to 15. Base 36 uses all of 0–9 and A–Z, which is why it turns up in short ids: 1234567890 in base 36 is kf12oi.
Why hex is everywhere
One hex digit is exactly 4 bits (a nibble), so two hex digits are exactly one byte. That makes hex the natural way to show raw bytes: each byte always takes two characters, and you can read the bits of each digit without doing arithmetic.
| Hex | Binary | Hex | Binary |
|---|---|---|---|
| 0 | 0000 | 8 | 1000 |
| 1 | 0001 | 9 | 1001 |
| 2 | 0010 | A | 1010 |
| 3 | 0011 | B | 1011 |
| 4 | 0100 | C | 1100 |
| 5 | 0101 | D | 1101 |
| 6 | 0110 | E | 1110 |
| 7 | 0111 | F | 1111 |
Once you know that table, you can read hex at a glance:
- Colours:
#FF8800is three bytes: redFF(255), green88(136), blue00(0). - Hashes: a SHA-256 digest is 32 bytes, so it’s 64 hex characters. The hash generator shows digests in hex for exactly this reason.
- UUIDs: 128 bits written as 32 hex digits in five groups. The version is a single hex digit you can read directly, as described in the UUID tool.
- Memory addresses, byte dumps and Unicode code points (
U+1F600): all hex.
Octal: mostly file permissions
Octal (base 8) digits are exactly 3 bits each. That fits Unix file permissions, which come in groups of three bits: read, write, execute for owner, group and others.
chmod 755 file
7 = 111 = rwx (owner)
5 = 101 = r-x (group)
5 = 101 = r-x (others)
So 755 is rwxr-xr-x and 644 is rw-r--r--. In code, octal literals have a prefix: 0o755 in JavaScript and Python 3, 0755 in C and older languages. That leading zero causes trouble, as the next section shows.
Binary: flags and masks
Binary is what you reach for when individual bits have meanings. Feature flags, permission sets and protocol headers often pack several booleans into one integer:
const READ = 0b001,
WRITE = 0b010,
EXEC = 0b100;
let mode = READ | WRITE; // 0b011
const canWrite = (mode & WRITE) !== 0; // true
mode &= ~WRITE; // clear the flag: 0b001
Network masks are the same idea. A /24 subnet mask is 24 one-bits followed by 8 zero-bits: 11111111 11111111 11111111 00000000, which is 255.255.255.0.
Negative numbers: two’s complement
Computers store signed integers in two’s complement. To get −x in n bits, invert all the bits of x and add 1. In 8 bits:
| Decimal | Binary (8-bit) | Hex |
|---|---|---|
| 127 | 01111111 |
7F |
| 1 | 00000001 |
01 |
| 0 | 00000000 |
00 |
| −1 | 11111111 |
FF |
| −128 | 10000000 |
80 |
The top bit works as the sign, and an 8-bit signed integer holds −128 to 127. The same bit pattern 11111111 is 255 as unsigned and −1 as signed. That’s why a debugger shows −1 as 0xFFFFFFFF in 32 bits, and why a “very large” number in a log sometimes turns out to be a small negative one read as unsigned.
The number base converter has a two’s-complement panel for 8, 16, 32 and 64 bits, which shows both readings of the same bits.
Traps in JavaScript
JavaScript has several ways to turn text into a number, and they disagree.
parseInt('0x1F'); // 31 (hex prefix is understood)
parseInt('0b101'); // 0 (binary prefix is not)
Number('0b101'); // 5 (Number understands 0b, 0o and 0x)
parseInt('1e3'); // 1 (stops at the first non-digit)
Number('1e3'); // 1000
parseInt('08'); // 8 (in old engines this could be 0: octal!)
parseInt('ff', 16); // 255 (always pass the radix)
(255).toString(2); // "11111111"
The safe habit is parseInt(text, radix) with an explicit radix, or Number(text) when you want the whole string to be a number.
The 2^53 limit
JavaScript numbers are 64-bit floats. They represent every integer exactly only up to 2^53 − 1 = 9,007,199,254,740,991. Past that, gaps appear:
2 ** 53 + 1; // 9007199254740992 (not ...993)
0.1 + 0.2; // 0.30000000000000004
For 64-bit ids, bit masks wider than 32 bits, or any arithmetic that must be exact, use BigInt:
(2n ** 64n - 1n).toString(16); // "ffffffffffffffff"
Bitwise operators (&, |, >>) convert their operands to 32-bit integers, which is another source of surprises. -1 >>> 0 is 4294967295, the unsigned reading of the same 32 bits.
The converter on this site uses BigInt internally, so a 100-digit hex number converts without losing a digit.
Readability: grouping digits
Long binary and hex values are hard to read, so most languages allow separators: 0b1111_0000, 0xFFFF_FFFF, 1_000_000 (JavaScript since ES2021, Python since 3.6, Java since 7). Group binary in 4s (nibbles) or 8s (bytes), hex in 2s or 4s, decimal in thousands. The converter accepts _ and spaces in its input and can group its output the same way.
Quick reference
| Base | Prefix (JS/Python) | Digits | Bits per digit | Typical use |
|---|---|---|---|---|
| 2 | 0b |
0–1 | 1 | Flags, masks, protocol fields |
| 8 | 0o |
0–7 | 3 | Unix permissions |
| 10 | none | 0–9 | n/a | Humans |
| 16 | 0x |
0–9 A–F | 4 | Bytes, colours, hashes, addresses |
| 36 | none | 0–9 A–Z | n/a | Short ids and codes |
When you need to check a value, paste it into the number base converter. Every field updates as you type, prefixes like 0x and 0b are understood, and invalid digits are flagged on the field where they appear.