pickuma.
Dev Knowledge

Two's Complement: How Computers Represent Negative Numbers

How two's complement encodes negative integers, why CPUs run signed and unsigned math on one adder, and the edge cases — INT_MIN, overflow, sign extension — that cause real bugs.

6 min read

Type int8_t x = -1; and the byte sitting in memory is 0xFF — all eight bits set to 1. That looks nothing like the number negative one, yet every mainstream CPU agrees it is. The reason is two’s complement, the encoding almost every machine built since the 1970s uses to store signed integers. Understanding it explains a long list of otherwise baffling behavior: why INT_MIN has no positive twin, why abs() can return a negative number, and why subtraction and addition run on the exact same circuit.

Why a plain sign bit fails

The obvious way to store a negative number is to steal the top bit as a sign flag and use the rest for magnitude. In 8 bits, 0000_0101 is +5 and 1000_0101 is -5. This is called sign-magnitude, and it has two problems that killed it for general-purpose hardware.

First, there are two zeros. 0000_0000 is +0 and 1000_0000 is -0. Two bit patterns for the same value means every equality check has to special-case it, and you waste a representable slot.

Second, arithmetic needs a branch. To add a positive and a negative number, the hardware has to compare magnitudes, subtract the smaller from the larger, and choose the sign of the result. That is a conditional path in silicon, which is slower and larger than a path with no branch.

One’s complement (negate by flipping every bit) removes the branch partway but still carries two zeros (0000_0000 and 1111_1111) and needs an end-around carry. Two’s complement drops both warts.

How two’s complement actually works

The rule for negating a number is two steps: invert every bit, then add 1.

Take +5 in 8 bits: 0000_0101. Invert: 1111_1010. Add one: 1111_1011. That is -5. Run the same procedure on 1111_1011 and you land back on 0000_0101 — the operation is its own inverse, which is exactly what negation should be.

The clean way to read a two’s complement number is to give the top bit a negative weight. In an 8-bit value, bit 7 is worth -128 instead of +128; every other bit keeps its normal positive weight. So 1111_1011 is -128 + 64 + 32 + 16 + 8 + 2 + 1 = -5. The same formula reads 0111_1111 as 127 and 1000_0000 as -128.

That gives an N-bit range of -2^(N-1) to 2^(N-1) - 1. For 8 bits that is -128 to 127. For 32-bit int, it is -2,147,483,648 to 2,147,483,647.

The payoff is that addition does not care about sign at all. Compute -5 + 5 as 1111_1011 + 0000_0101. The sum is 1_0000_0000; the carry out of bit 7 is discarded, leaving 0000_0000 = 0. One adder handles signed and unsigned values, and subtraction is just “negate the second operand and add.” That single-circuit property is the entire reason the encoding won.

The edge cases that bite you

The range is asymmetric: one more negative number than positive. In 8 bits, +127 has a counterpart at -127, but -128 has nobody. Run the negate procedure on -128 (1000_0000): invert to 0111_1111, add one, and you get 1000_0000 again — still -128. So -INT_MIN == INT_MIN, and abs(INT_MIN) is undefined behavior in C because the true answer is not representable. This is a genuine source of crashes in code that negates user-supplied integers without checking.

Overflow has a precise signature. When you add two numbers with the same sign and the result comes out with the opposite sign, the value wrapped. 0111_1111 (127) + 0000_0001 (1) = 1000_0000, which reads as -128. Two positives produced a negative, so the hardware sets the overflow flag. CPUs detect this by checking whether the carry into the sign bit differs from the carry out of it.

Sign extension is the other place people slip. Widening an 8-bit value to 16 bits means copying the sign bit into all the new high bits, not zero-filling them. -5 as 1111_1011 becomes 1111_1111_1111_1011, which is still -5. Zero-filling would turn it into 251. This is why C distinguishes arithmetic shift right (fills with the sign bit) from logical shift right (fills with zero), and why mixing signed and unsigned types in a comparison can silently flip a result.

Here is how the three schemes line up for a single byte:

PatternSign-magnitudeOne’s complementTwo’s complement
0000_0000+0+00
1000_0000-0-127-128
1111_1111-127-0-1
Zerostwotwoone
Add needs branchyesend-around carryno

Cursor

When you're poking at bit-level behavior, an editor that can explain a hex dump or scaffold a quick test harness shortens the loop. Cursor's inline chat is useful for sanity-checking shift and mask logic against what the standard actually guarantees.

Free tier; Pro $20/mo

Try Cursor

Affiliate link · We earn a commission at no cost to you.

The encoding is old and unglamorous, but it sits under every integer your code touches. Once the negative-weight-on-the-top-bit model clicks, the surprises stop being surprises.

FAQ

Why is it called two's complement?+
The negative of a number x in N bits is computed as 2^N minus x. For 8 bits, -5 is 256 - 5 = 251, which is 1111_1011. 'Complement with respect to 2^N' got shortened to two's complement. Invert-and-add-one is just a cheaper way to compute the same subtraction.
Do all computers use two's complement?+
Effectively all modern hardware does, and C++20 made it the mandatory signed-integer representation, dropping sign-magnitude and one's complement from the standard. A few historical machines used the alternatives, but you are extremely unlikely to meet one today.
How do I safely negate a value that might be INT_MIN?+
Check for it first, or do the work in an unsigned type. Negating INT_MIN in signed arithmetic is undefined behavior because the result exceeds INT_MAX. Casting to unsigned, negating, and casting back gives the wrapped value defined behavior if that is the result you want.

Related reading

See all Dev Knowledge articles →

Get the best tools, weekly

One email every Friday. No spam, unsubscribe anytime.