All articles
Converters
34 min readBy DevUtilX Team

Number Bases: Binary, Octal, Decimal and Hexadecimal Conversion

Learn how binary, octal, decimal and hex work, convert them by hand, and avoid two's complement, 2^53 and leading-zero octal traps with tested JS and Python.

Number Bases: Binary, Octal, Decimal and Hexadecimal Conversion

You open a debugger and see 0x7FFE4A3C. A teammate tells you to run chmod 750. A designer sends #FF8800. A log line prints 11111011 and nobody can say whether that is 251 or minus 5. All four are the same idea wearing different clothes: a number written in a base other than ten. This guide is for developers, students and anyone who has ever squinted at a hex dump. You will learn how positional notation works, how to convert between binary, octal, decimal and hexadecimal by hand, how computers store negative numbers and fractions, how bitwise masks work, and how to avoid the classic traps in JavaScript and Python. Every code sample was run before it was published, and the outputs shown are the real ones.

What is a number base?

A number base, also called a radix, is the count of distinct digits a numbering system uses before it carries into the next position. Decimal uses ten digits, 0 to 9. Binary uses two, 0 and 1. Octal uses eight, 0 to 7. Hexadecimal uses sixteen, so it borrows letters: 0 to 9, then A to F for the values ten to fifteen.

All of these are positional systems. A digit's contribution depends on where it sits. In decimal, the number 2024 means 2 thousands, 0 hundreds, 2 tens and 4 ones. Each position is worth ten times the one to its right. In any base b, each position is worth b times the position to its right. The rightmost position is worth b to the power 0, which is always 1, the next is b to the power 1, then b squared, and so on.

That single rule explains everything else in this article. Converting a number from one base to another does not change its value. It only changes how the value is written, in the same way that "twelve", "12" and "XII" name one quantity. The Radix article on Wikipedia gives the general definition if you want the formal treatment.

Why the digit set matters

A base needs exactly as many symbols as its value, with zero included. Base 8 has no digit 8, because the value eight is written 10. Base 16 needs six extra symbols, and the convention is the letters A to F, in either case. Beyond sixteen there is no universal standard, but programming languages commonly continue through the alphabet. JavaScript's toString and parseInt accept any base from 2 to 36, which uses all ten digits plus the 26 letters.

A common beginner error is to treat a string of digits as if it carried its base with it. The text 101 is five in binary. In octal it is 1 times 64 plus 0 plus 1, which is 65. In decimal it is one hundred and one, and in hexadecimal it is 257. The digits alone are ambiguous, which is why every serious language gives you a prefix, covered later.

A short history

People did not choose base ten because it is mathematically special. The usual explanation is that we have ten fingers. Other bases appeared in many cultures. The Babylonians used a base-60 system, and traces of it survive in how we divide hours into 60 minutes and minutes into 60 seconds. Counting in twelves survives in dozens and in the twelve hours on a clock face.

Binary has a long intellectual history. The philosopher and mathematician Gottfried Wilhelm Leibniz wrote about binary arithmetic in the early eighteenth century and saw it as an elegant way to do calculation with only two symbols. It remained a curiosity until electronics made it practical: a switch or a transistor has two reliable states, on and off, and distinguishing two states is far more robust against noise than distinguishing ten voltage levels. That engineering fact, not mathematical fashion, is why computers are binary. See the Binary number article on Wikipedia for the broader story.

Long strings of ones and zeros are miserable for humans to read, so early computing culture adopted compact notations that map neatly onto bits. Octal was popular on machines whose word sizes were multiples of three bits. When the 8-bit byte became the standard unit of memory, hexadecimal took over, because two hex digits describe exactly one byte. Octal did not disappear. It survives in Unix file permissions, where three bits per user class is exactly what you need. The Hexadecimal article on Wikipedia describes the notation and its use in computing.

The four bases programmers meet every day

Before the algorithms, here is why each base exists and where you will encounter it.

Binary (base 2)

Binary is the machine's native representation. A bit is one binary digit. A group of four bits is a nibble, which has 16 possible values. A group of eight bits is a byte, which has 256 possible values, 0 to 255. You see binary directly when working with flags, bitmasks, network masks, low-level protocols and hardware registers.

Octal (base 8)

One octal digit stores exactly three bits. Its main modern home is Unix permissions: chmod 754 file sets read, write and execute bits for the owner, group and others in three octal digits. You will also meet octal in escape sequences in some languages and in a few legacy file formats.

Decimal (base 10)

Decimal is for humans. Prices, counts, ages and user-facing numbers are decimal. Most programming languages parse decimal by default, and when you print a number without asking for a base you get decimal.

Hexadecimal (base 16)

One hex digit stores exactly four bits, so one byte is always two hex digits, from 00 to FF. That tidy alignment makes hex the standard way to show:

  • Colors, such as #FF8800, where the three byte pairs are red, green and blue.
  • Memory addresses in debuggers and crash reports.
  • Hashes and checksums, such as the hex output of SHA-256.
  • Byte data in hex dumps, packet captures and binary file formats.
  • Unicode code points, written like U+1F600.
  • MAC addresses and IPv6 addresses, which are groups of hex digits.

Here is the whole 0 to 15 range side by side. Memorise the hex column's pattern and you will be able to read most dumps without a tool.

Decimal Binary Octal Hex
0 0000 0 0
1 0001 1 1
2 0010 2 2
3 0011 3 3
4 0100 4 4
5 0101 5 5
6 0110 6 6
7 0111 7 7
8 1000 10 8
9 1001 11 9
10 1010 12 A
11 1011 13 B
12 1100 14 C
13 1101 15 D
14 1110 16 E
15 1111 17 F

Two facts from this table are worth burning into memory. The largest nibble, 1111, is F, which is 15. The largest byte, 11111111, is FF, which is 255.

How conversion works: three algorithms

You can convert between bases with a calculator or a tool, but knowing the algorithms lets you sanity-check results and debug code. There are three methods, and each suits a different case.

Method 1: positional expansion (any base to decimal)

To read a number in base b as decimal, multiply each digit by its place value and add the results.

Convert hex 7E8 to decimal. The digits are 7, E (which is 14) and 8. The place values from the left are 16 squared (256), 16 and 1.

  • 7 times 256 is 1792
  • 14 times 16 is 224
  • 8 times 1 is 8

The sum is 1792 + 224 + 8 = 2024.

Convert binary 11111101000 to decimal. Only the positions holding a 1 contribute. Reading from the left, those positions are worth 1024, 512, 256, 128, 64, 32 and 8. The sum is 1024 + 512 + 256 + 128 + 64 + 32 + 8 = 2024. Both routes agree, which is the point: one value, several spellings.

A more efficient way to do the same calculation is Horner's method. Start with zero. For each digit from the left, multiply the running total by the base and add the digit. For 7E8: 0 times 16 plus 7 is 7. Then 7 times 16 plus 14 is 126. Then 126 times 16 plus 8 is 2024. Computers use this form because it needs no powers, only one multiplication and one addition per digit.

Method 2: repeated division (decimal to any base)

To write a decimal integer in base b, divide by b repeatedly. Record each remainder. Stop when the quotient is zero. The remainders, read from last to first, are the digits.

Convert 2024 to binary:

Division Quotient Remainder
2024 / 2 1012 0
1012 / 2 506 0
506 / 2 253 0
253 / 2 126 1
126 / 2 63 0
63 / 2 31 1
31 / 2 15 1
15 / 2 7 1
7 / 2 3 1
3 / 2 1 1
1 / 2 0 1

Read the remainders from the bottom up: 11111101000. The first remainder you get is the least significant bit, which is why the order flips. The same procedure with 16 gives hex: 2024 divided by 16 is 126 remainder 8, then 126 divided by 16 is 7 remainder 14 (E), then 7 divided by 16 is 0 remainder 7. Reading upward gives 7E8. With 8 you get octal 3750.

Why does this work? Each division by b peels off the lowest digit, because the remainder is exactly the value of the rightmost position. What remains is the number with that position shifted away. It is the positional rule run in reverse.

Method 3: grouping bits (between binary, octal and hex)

Because 8 is 2 cubed and 16 is 2 to the fourth power, conversions among binary, octal and hex need no arithmetic at all. You regroup bits.

Binary to hex. Start at the right and split the bits into groups of four, padding the leftmost group with zeros. Replace each group with its hex digit.

11111101000 becomes 0111 1110 1000, which is 7 E 8. So the answer is 7E8.

Binary to octal. Group in threes: 011 111 101 000 is 3 7 5 0, so 3750.

Hex to binary. Expand each digit to four bits. Hex A5 is 1010 0101.

Octal to hex. Go through binary. Octal 3750 expands to 011 111 101 000, which regroups into fours as 0111 1110 1000, or 7E8.

Always group from the right, at the binary point for integers. Grouping from the left is the most common manual mistake, and it gives wrong answers when the bit count is not a multiple of the group size.

Worked example: the same value in four bases

Take 2024 and write it everywhere.

Base Notation Written value Language literal
2 Binary 11111101000 0b11111101000
8 Octal 3750 0o3750
10 Decimal 2024 2024
16 Hexadecimal 7E8 0x7E8

The binary form has 11 digits, the octal form 4, the hex form 3 and the decimal form 4. Higher bases are more compact because each digit carries more information: a hex digit carries 4 bits, an octal digit 3, a decimal digit about 3.3 bits and a binary digit 1. That compactness, with the clean bit alignment of octal and hex, is why programmers prefer them to decimal when they care about bits.

Fractions in other bases

Integers convert cleanly. Fractions need a second algorithm, and they produce a surprise that explains a famous floating-point puzzle.

Converting a fraction by repeated multiplication

To convert the fractional part of a decimal number to base b, multiply it by b. The integer part of the product is the next digit. Keep the fractional part and repeat. Stop when the fractional part is zero or you have enough digits.

Convert 0.375 to binary:

  • 0.375 times 2 is 0.75, digit 0
  • 0.75 times 2 is 1.5, digit 1, keep 0.5
  • 0.5 times 2 is 1.0, digit 1, fraction is now zero

The result is 0.011. Check it with positional expansion: 0 halves, 1 quarter and 1 eighth gives 0.25 + 0.125 = 0.375.

To read a fraction in another base, use negative powers. The digits after the point are worth 1/b, 1/b squared, 1/b cubed and so on.

Why 0.1 never fits in binary

Try the same algorithm on 0.1:

  • 0.1 times 2 is 0.2, digit 0
  • 0.2 times 2 is 0.4, digit 0
  • 0.4 times 2 is 0.8, digit 0
  • 0.8 times 2 is 1.6, digit 1, keep 0.6
  • 0.6 times 2 is 1.2, digit 1, keep 0.2
  • 0.2 times 2 is 0.4... and the cycle repeats

The fraction never reaches zero. In binary, one tenth is 0.0001100110011... with the block 0011 repeating forever. A computer stores a finite number of bits, so it must round. This is the reason 0.1 + 0.2 in JavaScript or Python prints 0.30000000000000004. It is not a bug in either language. It is a consequence of writing a number whose denominator contains the factor 5 in a base whose only prime factor is 2.

The general rule: a fraction has a finite expansion in base b only if every prime factor of its denominator, in lowest terms, divides b. In base 10 the primes are 2 and 5, so 1/8 and 1/5 terminate but 1/3 does not. In base 2 only denominators that are powers of two terminate. In base 3, one third is simply 0.1. The verified outputs below come from the Python converter shown later in this guide.

Fraction Base 2 Base 3 Base 16
3/8 0.011 repeats 0.6
1/3 repeats 0.1 repeats
1/10 repeats repeats repeats (0.1999...)

You can see this in JavaScript directly:

console.log((0.5).toString(2), (0.75).toString(2), (0.625).toString(8));
console.log((0.1).toString(2));

Output:

0.1 0.11 0.5
0.0001100110011001100110011001100110011001100110011001101

The second line is the stored double closest to one tenth, rounded after 52 bits of fraction, so it ends in ...1101 instead of repeating forever. For money, the standard remedy is to store integer minor units, such as cents, or to use a decimal type, never binary floating point.

Signed numbers and two's complement

So far every number has been non-negative. Hardware has no minus sign, only bits, so it needs a convention for negative values. Almost every modern CPU uses two's complement.

The idea

In an n-bit two's complement integer, the most significant bit has a negative weight. For 8 bits, the place values are -128, 64, 32, 16, 8, 4, 2, 1. The range is -128 to 127. The top bit acts as a sign indicator, but it is part of the arithmetic, not a separate flag.

To negate a number: invert every bit, then add one.

Find the 8-bit form of -5:

  1. Start with 5: 00000101
  2. Invert: 11111010
  3. Add 1: 11111011

So -5 is 11111011, which is hex FB. Check it with the weights: -128 + 64 + 32 + 16 + 8 + 2 + 1 = -5. Reading the same bits as an unsigned value gives 251, which is exactly the log-line puzzle from the start of this article. The bits do not know whether they are signed. The type decides how to interpret them.

Why do engineers use this scheme? Because addition and subtraction use the same circuit for signed and unsigned values, and there is only one zero. Sign-and-magnitude, the more intuitive alternative, has both +0 and -0 and needs special handling in the adder.

Overflow and wraparound

A fixed number of bits means results can leave the range. When that happens in two's complement arithmetic, the result wraps around.

  • Unsigned 8-bit: 255 + 1 gives 0.
  • Signed 8-bit: 127 + 1 gives -128, because 01111111 plus one is 10000000.

Wraparound is silent in many languages, which has caused real bugs in counters, timers and size calculations. The range of a signed n-bit integer is -2 to the (n-1) up to 2 to the (n-1) minus 1. Note the asymmetry: there is one more negative value than positive, since zero uses a slot on the non-negative side. Negating the most negative value, -128 in 8 bits, gives -128 again.

Languages differ in how they treat overflow. C leaves signed overflow undefined, Rust panics in debug builds and wraps in release unless you ask explicitly, JavaScript converts bitwise results to 32-bit signed integers, and Python integers never overflow at all because they grow as needed. Know which regime you are in.

Sign extension

When you widen a signed value from 8 bits to 16, you copy the top bit into the new positions. 11111011 (-5) becomes 1111111111111011, still -5. Widening an unsigned value pads with zeros instead. Mixing the two is a classic source of bugs when reading bytes from binary files: a byte 0xFF becomes 255 if treated as unsigned and -1 if treated as signed, and widening it picks different 32-bit patterns.

Showing negative numbers in code

Printing a negative number in base 2 usually gives a minus sign, not two's complement bits. To see the stored pattern, mask to the width you care about:

console.log((-5).toString(2));                       // -101
console.log((-5 & 0xff).toString(2).padStart(8, "0")); // 11111011
console.log((-5 >>> 0).toString(2));                 // 32 bits

Output from a real run:

-101
11111011
11111111111111111111111111111011

The >>> 0 trick reinterprets a 32-bit result as unsigned. Python behaves similarly, and its infinite-precision integers make masking the standard way to get a fixed width.

Bitwise operations and masks

Once you can read bits, bitwise operators become straightforward. They act on each bit position independently.

Operator JS and Python Meaning Example (5 and 3)
AND & 1 only if both bits are 1 5 & 3 = 1
OR | 1 if either bit is 1 5 | 3 = 7
XOR ^ 1 if the bits differ 5 ^ 3 = 6
NOT ~ flips every bit ~5 = -6
Left shift << moves bits left, multiplies by 2 1 << 3 = 8
Right shift >> moves bits right, divides by 2 -8 >> 1 = -4

The values in the last column were taken from a real run of the sample code. Note that ~5 is -6: flipping all the bits of a two's complement number gives the negative of the value minus one, which connects back to the "invert and add one" rule.

Masks: the everyday use

A mask is a number whose set bits select the positions you care about.

  • Test a bit: value & mask is non-zero if any selected bit is set.
  • Set a bit: value | mask.
  • Clear a bit: value & ~mask.
  • Toggle a bit: value ^ mask.
  • Extract a field: shift down, then AND with a mask of the field width.

Worked example with hex: 0xF0 & 0x3C. In binary 11110000 AND 00111100 gives 00110000, which is 0x30. OR gives 11111100, or 0xFC. XOR gives 11001100, or 0xCC. Because hex digits are nibbles, you can do this work one digit at a time without writing any bits.

Extracting color channels

A 24-bit color such as 0xFF8800 packs red, green and blue into three bytes. Shifting and masking pulls each one out:

const rgb = 0xff8800;
console.log((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff);
// 255 136 0

The shift moves the wanted byte into the lowest position, and & 0xff discards everything above it. This is why 0xff is the most common mask in all of programming: it means "keep the low eight bits".

Unix permissions as a bitfield

Permissions use three bits per class: read is 4, write is 2 and execute is 1. Add them to get one octal digit per class. Mode 754 means:

  • Owner: 7 = 4 + 2 + 1, so read, write and execute.
  • Group: 5 = 4 + 1, so read and execute.
  • Others: 4, so read only.

In binary that is 111 101 100. A real run of 0o754 confirmed the pattern 111101100, with the owner digit 7, group 5 and others 4 when extracted by shifting and masking with 7. This is the textbook case where octal beats hex: each digit lines up with one user class. Common modes are 644 for a normal file (owner read and write, everyone else read) and 755 for an executable or directory.

Shifts, and the 32-bit trap in JavaScript

JavaScript's bitwise operators convert their operands to 32-bit signed integers first. That has surprising consequences:

console.log(1 << 31, 1 << 32, -1 >>> 0, 0xffffffff | 0);

Real output:

-2147483648 1 4294967295 -1

1 << 31 sets the sign bit and becomes negative. 1 << 32 wraps, because the shift count is taken modulo 32, so it equals 1 << 0. The value 0xffffffff | 0 becomes -1 because the 32 one-bits are reinterpreted as signed. Use >>> 0 to get an unsigned view back. For anything wider than 32 bits, use BigInt, covered below.

Right shift also comes in two kinds. >> is arithmetic and copies the sign bit, so -8 >> 1 is -4. >>> is logical and shifts in zeros, so -8 >>> 28 is 15. Python has only >>, which behaves arithmetically for negative integers.

Literals and prefixes in programming languages

To avoid ambiguity, languages mark a number's base with a prefix.

Base Prefix JavaScript Python Example
Binary 0b yes yes 0b1010 is 10
Octal 0o yes yes 0o755 is 493
Hexadecimal 0x yes yes 0xFF is 255
Decimal none yes yes 255

Both languages also allow digit separators in modern versions, such as 1_000_000 and 0xFF_FF, to make long literals easier to scan. A run of the JavaScript sample printed 10 493 255 1000000 65535 for 0b1010, 0o755, 0xff, 1_000_000, 0xFF_FF.

The legacy leading-zero octal trap

Older languages, including C and early JavaScript, treated a leading zero as an octal marker. In that tradition 010 means 8, not 10. This produces maddening bugs when someone zero-pads a decimal number like a date or a code and the language quietly reinterprets it.

Modern behaviour varies by context:

  • In JavaScript, strict mode makes a legacy octal literal such as 010 a syntax error. In sloppy mode it may still be accepted, which is why linters flag it.
  • parseInt("08") returns 8 in current engines, as the run confirmed, but you should always pass the radix explicitly so you never depend on engine behaviour.
  • Python 3 rejects 010 as a literal with a syntax error, which is a deliberate safety measure. Python 2 accepted it as octal.
  • C and many shell tools still treat a leading zero as octal. In C, int x = 010; is 8. In a shell arithmetic expression, 08 can fail as an invalid octal number, a famous trap when parsing zero-padded months.

The safe habit: write octal with 0o, parse with an explicit radix, and treat leading zeros in data as text unless the format tells you otherwise. If you ever need to see what a legacy value really means, a base converter lets you check it in a second.

Converting numbers in JavaScript

JavaScript has two built-in directions. Number.prototype.toString(radix) formats a number in a base from 2 to 36. parseInt(text, radix) reads text in a base.

console.log((255).toString(2), (255).toString(8), (255).toString(16).toUpperCase(), (255).toString(36));
console.log(parseInt("ff", 16), parseInt("101", 2));

Output:

11111111 377 FF 73
255 5

Base 36 gave 73, which is 7 times 36 plus 3. For zero-padded output, add padStart:

console.log((255).toString(16).padStart(4, "0")); // 00ff

parseInt versus Number

The two functions treat input differently, and the difference matters:

console.log(parseInt("ff", 16), parseInt("0xff"), parseInt("0b101"), parseInt("101", 2));
console.log(Number("0b101"), Number("0o17"), Number("0xff"));
console.log(parseInt("12px"), parseInt(""), Number(""), Number("12px"));

Output:

255 255 0 5
5 15 255
12 NaN 0 NaN

Notice what happened:

  • parseInt("0xff") understands the 0x prefix when no radix is given, but parseInt("0b101") does not know 0b, so it stops at the b and returns 0.
  • Number("0b101") accepts all three prefixes.
  • parseInt reads as much of the string as it can and silently ignores the rest, so parseInt("12px") is 12. Number is stricter and returns NaN.
  • Number("") is 0, while parseInt("") is NaN. An empty text box becoming zero is a regular source of bugs.

For user input, prefer a strict check. Validate against a pattern, such as /^[0-9a-f]+$/i, before converting. Otherwise parseInt("1g", 16) will happily return 1 and hide the typo.

The 2^53 limit and BigInt

JavaScript numbers are 64-bit floating-point values. They represent every integer exactly only up to 2 to the 53rd power, which is 9,007,199,254,740,992. The constant Number.MAX_SAFE_INTEGER is one less. Beyond it, neighbouring integers collapse together:

console.log(2 ** 53, 2 ** 53 + 1, Number.MAX_SAFE_INTEGER);

Real output:

9007199254740992 9007199254740992 9007199254740991

2 ** 53 + 1 equals 2 ** 53 because the double cannot hold the odd value. This matters for base conversion whenever you handle 64-bit identifiers, hashes, file sizes in bytes, nanosecond timestamps or memory addresses on 64-bit systems. Converting such a value with Number and toString(16) silently returns the wrong digits.

BigInt solves it. It is an arbitrary-precision integer type, written with an n suffix, and its toString accepts a radix too:

console.log(BigInt("0xffffffffffffffff"));
console.log((2n ** 64n - 1n).toString(16), (2n ** 64n - 1n).toString(2).length);
console.log(BigInt.asIntN(8, 200n), BigInt.asUintN(8, -56n), BigInt.asIntN(64, 2n ** 63n));

Output:

18446744073709551615n
ffffffffffffffff 64
-56n 200n -9223372036854775808n

The last line shows two useful helpers. BigInt.asIntN(8, 200n) reinterprets 200 as a signed 8-bit value, giving -56. BigInt.asUintN(8, -56n) goes the other way and gives 200. They give you exact two's complement behaviour at any width, which plain numbers cannot offer beyond 32 bits.

Two cautions. BigInt does not mix with ordinary numbers in arithmetic, so 1n + 1 throws a TypeError. And BigInt("0xff") accepts a prefixed string, but BigInt("ff") does not, so add the prefix or build it yourself.

A hand-rolled converter, checked against built-ins

Writing the algorithm yourself is the best way to internalise it, and testing it against the built-ins proves it is right. This JavaScript version implements repeated division in one direction and Horner's method in the other:

const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";

function toBase(n, base) {
  if (!Number.isInteger(n) || n < 0) throw new RangeError("non-negative integer required");
  if (base < 2 || base > 36) throw new RangeError("base must be 2..36");
  if (n === 0) return "0";
  let out = "";
  while (n > 0) {
    out = DIGITS[n % base] + out;
    n = Math.floor(n / base);
  }
  return out;
}

function fromBase(text, base) {
  let value = 0;
  for (const ch of text.toLowerCase()) {
    const d = DIGITS.indexOf(ch);
    if (d < 0 || d >= base) throw new SyntaxError(`bad digit '${ch}' for base ${base}`);
    value = value * base + d;
  }
  return value;
}

let bad = 0;
for (let n = 0; n < 5000; n++) {
  for (const b of [2, 8, 10, 16, 36]) {
    if (toBase(n, b) !== n.toString(b) || fromBase(n.toString(b), b) !== n) bad++;
  }
}
console.log("mismatches:", bad);
console.log(toBase(2024, 2), toBase(2024, 8), toBase(2024, 16));
console.log(fromBase("7e8", 16));
try { fromBase("19", 8); } catch (e) { console.log(e.message); }

Real output:

mismatches: 0
11111101000 3750 7e8
2024
bad digit '9' for base 8

The loop compared 25,000 conversions, five bases for each of 5,000 integers, against the engine's own results and found no mismatches. The function rejects a digit that is out of range for the base, which is exactly the validation the built-in parseInt lacks. Remember its limit: it uses ordinary numbers, so it is only exact up to 2 to the 53rd power. For larger values, port it to BigInt by replacing n % base with n % BigInt(base) and the division with plain /.

Converting numbers in Python

Python offers bin, oct, hex, int(text, base) and the format mini-language. Python integers are unbounded, so the precision problems above do not arise.

print(bin(2024), oct(2024), hex(2024), int("7e8", 16), int("0x7e8", 16), int("0b101", 0), int("0o17", 0))
print(f"{255:08b} {255:#x} {255:#o} {255:#010b} {-5:b} {255:X}")

Real output:

0b11111101000 0o3750 0x7e8 2024 2024 5 15
11111111 0xff 0o377 0b11111111 -101 FF

Several details deserve attention:

  • int("0x7e8", 16) accepts the prefix when the base matches it, and int("0b101", 0) with base 0 infers the base from the prefix.
  • int("0x7e8") with the default base 10 raises ValueError: invalid literal for int() with base 10: '0x7e8', which is the correct, strict behaviour.
  • In format specs, 08b means zero-padded to eight binary digits, #x adds the prefix and X gives upper case.
  • f"{-5:b}" prints -101, not two's complement, just as in JavaScript.

Two's complement and fixed widths in Python

Because Python integers have no width, you create one with a mask:

def twos(n, bits):
    return format(n & ((1 << bits) - 1), f"0{bits}b")

def from_twos(s):
    v = int(s, 2)
    return v - (1 << len(s)) if s[0] == "1" else v

print(twos(-5, 8), twos(5, 8), twos(-128, 8), from_twos("11111011"), from_twos("10000000"))
print((-5) & 0xff, hex(2**64 - 1))

Real output:

11111011 00000101 10000000 -5 -128
251 0xffffffffffffffff

from_twos subtracts 2 to the power of the width when the top bit is set, which is the mathematical definition. The same pattern, mask to get a fixed width and subtract to sign-extend, appears in nearly every language.

Converting fractions in Python

Using exact fractions avoids contaminating the demonstration with floating-point rounding:

from fractions import Fraction

DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"

def frac_to_base(x, base, digits=12):
    out = []
    for _ in range(digits):
        x *= base
        d = int(x)
        out.append(DIGITS[d])
        x -= d
        if x == 0:
            break
    return "0." + "".join(out)

print(frac_to_base(Fraction(3, 8), 2), frac_to_base(Fraction(1, 3), 3))
print(frac_to_base(Fraction(1, 10), 2, 16), frac_to_base(Fraction(1, 10), 16))

Real output:

0.011 0.1
0.0001100110011001 0.199999999999

The first line shows terminating expansions: 3/8 in binary and 1/3 in base three. The second shows 1/10 repeating forever in binary and hex, cut off at the digit limit. Python can also show the exact bits of a double with float.hex: 0.1.hex() printed 0x1.999999999999ap-4, and float.fromhex of that string returned 0.1. The repeating 9 digits are the repeating 1001 pattern of one tenth, rounded in the last place.

A complete hand-rolled version in Python

def to_base(n, base):
    if n < 0:
        raise ValueError("non-negative only")
    if n == 0:
        return "0"
    out = []
    while n:
        n, r = divmod(n, base)
        out.append(DIGITS[r])
    return "".join(reversed(out))

def from_base(text, base):
    v = 0
    for ch in text.lower():
        d = DIGITS.find(ch)
        if d < 0 or d >= base:
            raise ValueError(f"bad digit {ch!r} for base {base}")
        v = v * base + d
    return v

assert all(to_base(n, b) == format(n, {2: "b", 8: "o", 16: "x"}[b])
           for n in range(5000) for b in (2, 8, 16))
assert all(from_base(to_base(n, b), b) == n for n in range(5000) for b in (2, 8, 16, 36))
print("ok", to_base(2024, 2), to_base(2024, 8), to_base(2024, 16))

The assertions passed and the run printed ok 11111101000 3750 7e8. Because divmod returns quotient and remainder together, the Python loop is the division method in its purest form. Python integers are unbounded, so this version works for huge values without modification.

Endianness in one section

A multi-byte value must be laid out in memory in some order. Big-endian stores the most significant byte first, the way we write numbers. Little-endian stores the least significant byte first. Most desktop and mobile processors you will meet are little-endian, while network protocols traditionally use big-endian, so it is often called network byte order.

The value 0x12345678 shows the difference. Written to four bytes both ways, a real run gave:

12 34 56 78    (big-endian)
78 56 34 12    (little-endian)

The JavaScript used DataView.setUint32(0, value, littleEndian), and the Python used struct.pack(">I", value) for big and "<I" for little. The number is the same; only the order of its bytes in storage differs.

Endianness matters when you read binary files, parse network packets, or print a memory dump of a multi-byte integer. A hex dump of a little-endian 32-bit value looks "backwards", which confuses newcomers. Always check a format's specification for its byte order, and set it explicitly when you read or write. Byte order is a property of bytes, not bits, so it does not change how you write a single byte in binary or hex.

Real-world use cases

Number bases show up wherever software meets hardware or formats.

Web colors. CSS accepts #RRGGBB and shorthand #RGB. Converting between hex and the decimal rgb(255, 136, 0) form is exactly the shift-and-mask example above, or three two-digit hex conversions. Our CSS formatting and architecture guide covers writing stylesheets that use these values consistently.

File permissions. chmod 640 and umask 022 are octal. Understanding the bit layout lets you reason about them instead of memorising tables.

Debugging and reverse engineering. Memory addresses, stack traces and register dumps are hex. Reading 0x7FFE4A3C as a 32-bit value, or noticing that two pointers differ by 0x40, is routine.

Networking. IPv4 addresses are four bytes written in decimal, and subnet masks are runs of ones. The mask 255.255.255.0 is 11111111.11111111.11111111.00000000, which is a 24-bit prefix. IPv6 and MAC addresses are written in hex.

Hashes and identifiers. A SHA-256 digest is 256 bits, shown as 64 hex digits. UUIDs are 128 bits shown as 32 hex digits with dashes.

Character encodings. Unicode code points are written in hex, and UTF-8 byte patterns are described in binary, with leading bits like 110 and 10 marking how many bytes belong to a character.

Feature flags and permissions in applications. Packing booleans into one integer, where each bit means one option, saves space and makes combining and testing options a single bitwise operation.

Embedded and hardware work. Registers are controlled by setting and clearing individual bits, and datasheets give masks in hex.

Binary data in text formats. URLs and data in other formats often carry percent-encoded bytes, such as %20 for a space (hex 20, decimal 32). When you handle encoded payloads in JSON, our JSON formatting guide is a useful companion.

Common mistakes and pitfalls

These errors account for most base-conversion bugs.

  • Forgetting that digits carry no base. 10 is two, eight, ten or sixteen depending on context. Label data with its base, and prefer prefixed literals in code.
  • Grouping bits from the wrong end. Group from the right (the binary point), not the left, and pad the leftmost group with zeros.
  • Dropping leading zeros that carry meaning. 0x0A and 0xA are the same number, but a fixed-width field such as a byte in a hex dump, or a zero-padded identifier, needs its zeros. Use padStart or format specs.
  • Case confusion. ff, FF and Ff are the same hex number. Parsers accept all of them, but comparisons of hex strings as text are case sensitive, so normalise before comparing.
  • Treating a negative number's binary text as two's complement. (-5).toString(2) prints -101. Mask to a width to see the real bits.
  • Using parseInt without a radix on untrusted input. Always pass the second argument, and validate the string first.
  • Believing parseInt validates. It parses a prefix and ignores the rest, so parseInt("12px", 10) is 12 and parseInt("1g", 16) is 1.
  • Exceeding 2^53. Large IDs, hashes and addresses lose precision as plain numbers. Use BigInt or keep them as strings.
  • Assuming JavaScript bitwise operators are 64-bit. They are 32-bit and signed. 1 << 31 is negative.
  • Legacy leading-zero octal. Never write 010 expecting ten.
  • Confusing bit and byte units. A hex digit is four bits. A byte is two hex digits. Megabit and megabyte differ by eight.
  • Ignoring endianness when reading multi-byte values from files or networks.
  • Using binary floating point for decimal fractions. Store money as integer cents.
  • Mixing signed and unsigned. The same bits mean 251 or -5 depending on the type, so declare it.

Step-by-step: convert bases with DevUtilX

For a quick conversion, with no code and no setup, use the DevUtilX Base Converter. It runs in your browser, so the numbers you enter are processed locally and are not uploaded.

  1. Open the Base Converter.
  2. Enter your number in the field for the base you already have: binary, octal, decimal or hexadecimal.
  3. Read the same value in the other bases as they update.
  4. Check that the digits you typed are valid for the chosen base. A digit like 8 in octal or G in hex is not valid, so if a field rejects your input, check for that first.
  5. Copy the result you need, with or without a prefix depending on where it is going.

Here are some uses that save real time:

  • Reading a permission mode. Enter 754 in the octal field and look at the binary result, 111101100, to see exactly which bits are set.
  • Checking a color channel. Enter FF in hex to confirm it is 255, or 88 to see 136.
  • Decoding a log value. Enter 11111011 in binary. Decimal shows 251, which you can then interpret as -5 if the field is a signed byte.
  • Sizing a mask. Enter FFFF and see that it is 65535 and sixteen ones, then use it as a 16-bit mask.
  • Verifying your own code. When a function disagrees with expectations, convert the same value in the tool and compare with your output.

Because everything runs locally, it is fine to paste values from a debugging session, but never paste secrets, keys or tokens into any online tool. If your number is very large, remember that the tool works with the values a browser can handle, so for 64-bit identifiers confirm the digits you get against a BigInt check in code.

Performance, security and accessibility considerations

Base conversion is cheap, but a few points are worth knowing.

Performance

  • Built-ins beat hand-rolled loops. toString(radix) and parseInt are implemented natively and are fast. Use your own converter for learning, validation of unusual formats or arbitrary alphabets.
  • Powers of two are special. Converting to or from base 2, 8 or 16 can be done by bit manipulation, with no division. Dividing by a non-power-of-two base is slower, and for huge numbers it gets expensive, because the work grows with the square of the digit count in the simple algorithm.
  • Avoid repeated string concatenation in hot loops. Collect digits in an array and join once, as the Python version does, instead of building a string one digit at a time in performance-critical code.
  • Prefer shifts to multiplication only when it clarifies intent. Modern compilers and engines optimise x * 8 just as well as x << 3, so choose the form that reads best.

Security

  • Validate before parsing. Untrusted strings that become integers can be used for indexing, sizing or permission decisions. Check format and range, and reject anything unexpected.
  • Watch for integer overflow in size calculations. A length computed with wraparound arithmetic can become small and lead to a buffer that is too short. This is a recognised class of vulnerability in languages without bounds checks.
  • Be careful with permissions. chmod 777 makes a file writable by everyone. When you write modes in code, prefer explicit, minimal values such as 0o600 for private files and 0o644 or 0o755 for public ones, and make sure the leading 0o is there.
  • Do not treat hex or Base64 as secrecy. Hex encoding is a representation, not encryption. Anyone can decode it.
  • Compare secrets in constant time, not by converting and comparing integers. That is a separate topic, but it is easy to introduce a timing leak while "just comparing hex strings".

Accessibility

  • Do not rely on color hex alone. A hex color is a precise number, but contrast ratios and color-blind legibility matter more than the exact digits. Check contrast before you ship.
  • Make hex and binary readable in interfaces. Group digits in fours with spaces or thin separators, such as 1111 1101 000, and use a monospace font so columns line up. Screen reader users benefit when long digit strings are chunked, because a reader announcing one 64-digit token is unusable.
  • Label the base. Visible text like "hex" or a prefix is clearer than leaving users to guess, and an accessible label on an input should state the expected base and the allowed characters.
  • Do not use l, 1, O and 0 ambiguously in identifiers meant to be read aloud or typed. Hex avoids most of these by design, but base 36 and similar encodings do not.

Troubleshooting guide

When a conversion goes wrong, work from symptom to cause.

Symptom: parseInt("0b101") returns 0

Cause. parseInt recognises 0x but not 0b or 0o when you give no radix. It stops at the first invalid character.
Fix. Use Number("0b101"), or strip the prefix and pass the radix: parseInt("101", 2).

Symptom: a value is off by one or two after converting a big number

Cause. The number exceeded 2^53 and lost precision as a double, so 2**53 + 1 became 2**53.
Fix. Use BigInt, or keep the identifier as a string end to end. Convert with BigInt("0x...").toString(2).

Symptom: binary output has a minus sign instead of bits

Cause. toString(2) on a negative number prints the sign and the magnitude, not two's complement.
Fix. Mask to a width first: (n & 0xff).toString(2).padStart(8, "0"), or (n >>> 0).toString(2) for 32 bits.

Symptom: a positive number becomes negative after a bitwise operation

Cause. JavaScript bitwise operators work on 32-bit signed integers. 0xffffffff | 0 is -1, and 1 << 31 is negative.
Fix. Add >>> 0 to reinterpret the result as unsigned, or use BigInt for wider values.

Symptom: 010 is 8, or a shell script fails on 08

Cause. A leading zero marks a legacy octal literal in C, shell arithmetic and sloppy-mode JavaScript. The digit 8 is invalid in octal, which is why 08 errors.
Fix. Write decimal numbers without padding, use 0o for octal, and in shell strip zeros or force the base, for example with 10#08 in Bash arithmetic.

Symptom: the hex string has the wrong length

Cause. Leading zeros were dropped. (10).toString(16) is a, not 0a.
Fix. Pad to the field width with padStart(2, "0") for bytes, or use a format spec like Python's f"{n:02x}".

Symptom: bytes look reversed in a dump

Cause. The value is stored little-endian, so the least significant byte comes first.
Fix. Check the format's byte order and read it with an explicit endianness setting, such as DataView flags or struct prefixes < and >.

Symptom: 0.1 + 0.2 is not 0.3

Cause. One tenth and one fifth have infinite binary expansions, so doubles store rounded values.
Fix. Compare with a tolerance, or use integer minor units or a decimal library for money.

Symptom: an uppercase hex string will not match a lowercase one

Cause. The values are equal but the text differs, and string comparison is case sensitive.
Fix. Normalise with .toLowerCase() before comparing, or compare as integers.

Symptom: the same byte is 255 in one place and -1 in another

Cause. One code path reads the byte as unsigned and the other as signed.
Fix. Declare the type at both ends. In JavaScript use Uint8Array for unsigned and Int8Array for signed views of the same buffer.

Best practices and checklist

  • Prefix literals with 0b, 0o and 0x, and never rely on a leading zero.
  • Pass the radix explicitly to parseInt and int.
  • Validate input against the allowed digits before converting, and reject trailing junk.
  • Use hex for bytes and addresses, octal only for permissions and similar three-bit fields, and decimal for anything a human reads as a quantity.
  • Pad fixed-width values to their full width so dumps line up.
  • Choose the integer type deliberately: signed or unsigned, and the width.
  • Use BigInt for integers above 2^53, and keep large IDs as strings in JSON.
  • Mask after shifting to extract fields, and name your masks as constants instead of repeating magic numbers.
  • State endianness whenever you serialise multi-byte integers.
  • Store money as integers, not binary floats.
  • Test conversions against a trusted reference, as the hand-rolled converters above were tested against the built-ins, including edge cases: zero, one, the largest value for the width, and negative numbers.
  • Group digits for display, in fours for binary and hex, and label the base.
  • Keep unfamiliar values in a quick tool, like the Base Converter, and compare to your code when in doubt.

Before you merge code that touches bases, run through this short list:

  1. Does every literal show its base with a prefix?
  2. Is the integer width and signedness documented?
  3. Could any value exceed 2^53 or 32 bits?
  4. Is the byte order specified for multi-byte data?
  5. Are inputs validated, and is the radix always explicit?
  6. Is there a test covering zero, the maximum value and a negative value?

Comparison: ways to write the same number

Representation Digits per byte Best for Weakness
Binary 8 Flags, masks, learning how bits work Long and hard to scan
Octal about 2.7 Unix permissions, three-bit fields Misaligned with 8-bit bytes
Decimal about 2.4 Human-facing quantities Hides bit structure
Hexadecimal 2 Bytes, colors, addresses, hashes Needs letters, easy to misread digits
Base 32 about 1.6 Case-insensitive identifiers Not aligned with bytes
Base 64 about 1.33 Embedding binary data in text Not human-readable as numbers

The digits-per-byte column is a calculation: a byte holds 8 bits, and a digit in base b holds the base-2 logarithm of b bits, so a byte needs 8 divided by that many digits, rounded for display. Base 32 and base 64 are encodings of byte data rather than everyday numeric notations, but they follow the same logic of packing more bits into each character, and they come up when you move binary data through text-only channels. Our JSON minification and payload size guide explains what Base64 costs when binary data is embedded in JSON.

FAQ

What is the difference between a bit, a nibble and a byte?
A bit is a single binary digit. A nibble is four bits, one hex digit. A byte is eight bits, two hex digits, with 256 possible values.

Why do programmers use hexadecimal instead of binary?
Hex is a compact view of binary. Each hex digit maps to exactly four bits, so conversion is mechanical and a byte is always two characters. Binary is accurate but hard to read at length.

Why is octal still used?
Mostly for Unix file permissions, where three bits per user class map to one octal digit. It also survives in escape sequences and a few older formats.

How do I convert decimal to binary by hand?
Divide by 2 repeatedly, write down each remainder, and read the remainders from last to first. Alternatively, subtract the largest power of two that fits and repeat with the remainder.

How do I convert binary to hex quickly?
Split the bits into groups of four from the right, pad the left group with zeros, and replace each group with its hex digit using the 0 to 15 table.

What does the 0x prefix mean?
It tells the language that the digits that follow are hexadecimal. Similar prefixes are 0b for binary and 0o for octal.

Why does 0.1 + 0.2 not equal 0.3?
Both 0.1 and 0.2 have infinite repeating expansions in binary, so floating point stores slightly rounded values. Their sum differs from the stored 0.3 in the last bit.

What is two's complement?
It is the standard way to store signed integers. The top bit has a negative weight, and you negate a number by inverting all bits and adding one. It lets one adder serve signed and unsigned arithmetic.

Why is -5 shown as 251 sometimes?
The same eight bits, 11111011, are 251 when read as unsigned and -5 when read as signed. The interpretation comes from the type, not from the bits.

What is the largest integer JavaScript can represent exactly?
Number.MAX_SAFE_INTEGER, which is 2 to the 53rd power minus 1, or 9,007,199,254,740,991. For larger values use BigInt.

How do I convert a very large hex number in JavaScript?
Use BigInt("0x...") and call toString(10) or toString(2) on it. Ordinary numbers lose precision beyond 2^53.

What does parseInt("08") return?
Current engines return 8, but old ones treated a leading zero as octal. Always pass the radix, as in parseInt("08", 10).

What is the difference between big-endian and little-endian?
Big-endian stores the most significant byte first. Little-endian stores the least significant byte first. The value is the same, but the byte order in memory or on the wire differs.

How do I check whether a particular bit is set?
AND the value with a mask that has only that bit set, such as value & (1 << n), and test whether the result is non-zero. In JavaScript, stay below bit 31 or use BigInt.

Is it safe to use an online base converter?
It is safe when the tool works in your browser, as the DevUtilX converter does, since nothing is uploaded. Still, avoid pasting secrets or credentials into any tool.

Can I use bases above 16?
Yes. JavaScript and Python's formatting helpers support bases up to 36 using digits and letters. Base 32 and base 64 are common as encodings, but they use their own alphabets rather than the simple digits-then-letters scheme.

Conclusion

Number bases look like trivia until a debugger, a permission string or a stubborn bug forces you to read one. The ideas are small and they compound: positional notation explains every conversion, grouping bits makes binary, octal and hex interchangeable, two's complement explains negative numbers and overflow, and a handful of masks covers most bit manipulation. Add three habits, prefixed literals, explicit radixes and BigInt for large values, and you will avoid the traps that catch most developers.

When you just need an answer, open the Base Converter, enter a value in any base and see the rest instantly, locally in your browser. To keep learning, read our JSON minification and payload size guide to see what encoding choices cost on the wire, and our JavaScript code formatting best practices for keeping numeric literals and bit-twiddling code readable.

Further reading

Try the tools

Further reading

Related articles