Two's Complement Explained: How Computers Store Negative Numbers
Every mainstream processor stores signed integers the same way, and almost every overflow bug, unexpected -56 and broken byte parser comes from forgetting how. Two's complement is a few lines of arithmetic once you see why it works. This guide builds it from first principles, then walks through the bugs it causes in real code, from Java bytes to JavaScript bit operators to the year 2038.
The short version
- In an n-bit two's-complement number, the top bit is worth −2n−1 instead of +2n−1. Every other bit keeps its usual positive weight.
- To negate a number: invert every bit and add one.
5is00000101, so−5is11111011. - An 8-bit signed value runs from −128 to 127; 16-bit from −32,768 to 32,767; 32-bit from −2,147,483,648 to 2,147,483,647.
- The same bits mean different numbers as signed and unsigned:
11111111is 255 or −1. Most bugs are a value written as one and read as the other. - Overflow wraps around silently in most languages: 2,147,483,647 + 1 becomes −2,147,483,648 in 32 bits.
The problem: binary has no minus sign
Memory holds bits, so a negative number needs an encoding. The obvious idea, sign and magnitude, uses the top bit as a sign flag and the rest as the value: 00000101 is 5 and 10000101 is −5. It has two flaws. There are two zeros, 00000000 and 10000000, which every comparison has to treat as equal. And adding a positive and a negative number needs different circuitry from adding two positives. Ones' complement, used by some early machines, negates by inverting every bit; it still has a negative zero and needs an "end-around carry" when adding.
Two's complement removes both problems. There is exactly one zero, and the ordinary binary adder that handles unsigned numbers gives the right answer for signed numbers too, with no special cases. That is why every processor in common use, from phones to servers, uses it, and why C++20 and C23 finally made it the only representation the language standards allow.
How it works
Think of an 8-bit number as eight weighted positions. Unsigned, the weights are 128, 64, 32, 16, 8, 4, 2 and 1. Two's complement changes exactly one thing: the top bit is worth −128. So 10000000 is −128, 10000001 is −128 + 1 = −127, and 11111111 is −128 + 127 = −1.
| Bits | Unsigned | Signed (two's complement) |
|---|---|---|
0000 0000 | 0 | 0 |
0000 0001 | 1 | 1 |
0111 1111 | 127 | 127 (largest) |
1000 0000 | 128 | −128 (smallest) |
1100 1000 | 200 | −56 |
1111 1011 | 251 | −5 |
1111 1111 | 255 | −1 |
An equivalent way to see it: a negative number −x is stored as 2n − x. In 8 bits, −5 is stored as 256 − 5 = 251. This is arithmetic modulo 2n, the same wrap-around you get on a clock face, which is exactly why addition works without special cases: 7 + (−5) is 7 + 251 = 258, and 258 modulo 256 is 2.
Negating: invert and add one
To find the bits of −x, flip every bit of x and add one. Flipping every bit gives 2n − 1 − x, so adding one gives 2n − x, which is the definition above.
5 = 0000 0101
~5 = 1111 1010 invert every bit
+1 = 1111 1011 = −5 (0xFB, or 251 read as unsigned)
check: 0000 0101 + 1111 1011 = 1 0000 0000 → the carry falls off the top → 0
The same rule works in reverse, so applying it twice returns the original number. One value has no positive counterpart: the most negative number. In 8 bits, inverting −128 (10000000) and adding one gives 10000000 again. That is why abs(INT_MIN) in C is undefined behavior and returns a negative number in practice, and why Math.abs(Integer.MIN_VALUE) in Java returns −2,147,483,648.
Ranges and sign extension
| Width | Signed range | Unsigned range | Typical types |
|---|---|---|---|
| 8 bits | −128 … 127 | 0 … 255 | int8_t, Java byte, uint8_t |
| 16 bits | −32,768 … 32,767 | 0 … 65,535 | short, int16_t |
| 32 bits | −2,147,483,648 … 2,147,483,647 | 0 … 4,294,967,295 | int, int32_t, SQL INTEGER |
| 64 bits | −9,223,372,036,854,775,808 … 9,223,372,036,854,775,807 | 0 … 18,446,744,073,709,551,615 | long, int64_t, SQL BIGINT |
When a signed value moves into a wider type, it is sign-extended: the top bit is copied into every new bit, so −5 in 8 bits (11111011) becomes 11111111 11111011 in 16 bits, still −5. An unsigned value is zero-extended instead. Mixing the two up is how a byte of 0xFB read from a file turns into −5 when you expected 251, or into 65,531 when you expected −5.
The bugs it causes in real code
- Java's signed bytes. Java has no unsigned byte, so
(byte) 200is −56, and a byte array read from a network socket prints negative values for everything above 127. Mask to recover the unsigned value:b & 0xFF. - JavaScript bit operators work in 32 bits. Numbers are 64-bit floats, but
|,&,^,~,<<and>>convert their operands to signed 32-bit integers first. So0xFFFFFFFF | 0is −1, and2147483647 + 1 | 0is −2,147,483,648. The unsigned right shiftx >>> 0reinterprets the result as unsigned, which is why it appears in hashing code. For wider values useBigInt. - Arithmetic versus logical shift. Shifting a negative number right with
>>copies the sign bit in from the left, so −128 >> 1 is −64. A logical shift (>>>in Java and JavaScript) fills with zeros and produces a large positive number instead. - Silent overflow. In C, signed overflow is undefined behavior, and compilers may optimise on the assumption that it never happens; in Java, C# and Go it wraps. Rust panics in debug builds and wraps in release builds unless you use the
checked_,wrapping_orsaturating_methods. Python's integers never overflow, which hides the problem until the value reaches a database or a C library. - Counters that hit the limit. YouTube's view counter was a signed 32-bit integer until 2014, when one video approached 2,147,483,647 views and the counter was moved to 64 bits. The same limit is behind the year 2038 problem: signed 32-bit Unix time runs out at 03:14:07 UTC on 19 January 2038.
- Narrowing conversions. In 1996 the first Ariane 5 rocket was lost when software converted a 64-bit floating-point velocity into a 16-bit signed integer, the value was too large to fit, and the resulting exception shut down the guidance system.
Reading signed values from bytes
File formats and network protocols specify both a width and a signedness for every field, plus a byte order. To decode a signed 16-bit big-endian value by hand, combine the bytes as unsigned and then subtract 216 if the top bit is set:
// bytes FF 38 → 0xFF38 = 65,336 as unsigned
const u = (bytes[0] << 8) | bytes[1];
const s = u >= 0x8000 ? u - 0x10000 : u; // −200
// or let the platform do it
new DataView(buffer).getInt16(0, false); // false = big-endian
Most languages provide the second form: DataView in JavaScript, struct.unpack('>h', …) in Python, ByteBuffer.getShort() in Java, and binary.BigEndian.Uint16 followed by a conversion to int16 in Go. Use them rather than hand-rolled shifts whenever you can.
Related guides and tools
Frequently asked questions
Write the positive number in binary at the chosen width, invert every bit, then add one. For −12 in 8 bits: 12 is 00001100, inverted it is 11110011, and adding one gives 11110100, which is 0xF4. Equivalently, subtract the magnitude from 2 to the power of the width: 256 − 12 = 244, which is 11110100 in binary.
Because it has a single representation of zero and lets the same adder circuit handle signed and unsigned numbers. Addition, subtraction and multiplication of the low bits work identically for both, so processors need no separate signed arithmetic for those operations. Sign-and-magnitude and ones' complement both have a negative zero and need extra logic to add numbers of different signs.
Eight bits have 256 patterns. Zero takes one of the patterns with the top bit clear, leaving 127 positive values, and all 128 patterns with the top bit set are negative, from −128 to −1. The extra negative value is the price of having a single zero; it is also why negating the most negative number overflows back to itself.
They are two interpretations of the same bits. Unsigned treats every bit as a positive power of two, so 8 bits hold 0 to 255. Signed two's complement gives the top bit a negative weight, so 8 bits hold −128 to 127. The bit pattern 11001000 is 200 unsigned and −56 signed. Choose unsigned for sizes, counts and raw bytes, and signed for quantities that can go below zero.
Hexadecimal of a negative number depends on the width. Take the two's-complement bits at that width and group them in fours: −5 is FB in 8 bits, FFFB in 16 bits and FFFFFFFB in 32 bits. In JavaScript, (−5 >>> 0).toString(16) gives the 32-bit form, fffffffb. The number base converter shows every width at once.