explainer
Two's Complement: How Negative Numbers Are Stored
Two's complement explained: why 255 reads as -1, why (int8_t)200 is -56, how invert-and-add-1 works, and what sign extension does when you widen a value.
A byte arrives off the wire holding 0xC8. Your Python parser logs 200, the Java service logs -56, and you spend an afternoon deciding which one is broken. Neither is, because there is no minus sign anywhere in a CPU — there are N bits and an agreement about what the top one is worth, and the two programs made different agreements. Once you know the rule, every one of these symptoms becomes the same symptom.
Every bit pattern below is one you can check in our number base converter: pick a width, pick signed or unsigned, and it reads the bits back the way a CPU would.
The same eight bits, five ways
| Code | Result |
|---|---|
int.from_bytes(b'\xc8', 'big') (Python) | 200 |
int.from_bytes(b'\xc8', 'big', signed=True) (Python) | -56 |
new Uint8Array([0xC8])[0] (JS) | 200 |
new Int8Array([0xC8])[0] (JS) | -56 |
(byte) 200 (Java) | -56 |
(uint8_t) 0xC8 / (int8_t) 0xC8 (C) | 200 / -56 |
The bits never changed. signed=True, Int8Array over Uint8Array, and Java’s decision not to have an unsigned byte type are three spellings of one question: does the top bit mean +128 or −128?
Why not just use a sign bit?
The obvious design is sign-magnitude: top bit for the sign, magnitude in the rest. It’s how humans write numbers, and how (-5).toString(2) in JavaScript and bin(-5) in Python both print — -101 and -0b101. Almost no hardware uses it. It has two zeros (0000 0000 and 1000 0000), so every equality check needs a special case; and addition stops being addition, since you must compare signs, compare magnitudes, decide whether you’re really adding or subtracting, then fix the sign of the result — a branchy mess in silicon.
One’s complement (negate by inverting every bit) helps the adder but keeps two zeros (1111 1111 is negative zero) and needs an end-around carry. Two’s complement has exactly one zero and no special cases at all, which is why every mainstream CPU uses it — and since C23, the C standard requires it too.
The rule: the top bit’s weight is negative
“Invert and add one” is the procedure. This is the model, and it explains more.
In an N-bit signed integer, every bit has its usual positional weight except the top one, which is worth −2ⁿ⁻¹.
For a signed byte:
| Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| Weight | −128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
Now read patterns straight off:
1100 1000= −128 + 64 + 8 = −561001 1100= −128 + 16 + 8 + 4 = −1001111 1111= −128 + 127 = −1 (which is why an all-ones byte reads as 255 or −1 depending on who’s asking)1000 0000= −128,0111 1111= 127
Invert-and-add-1 falls out of this: x + ~x is all ones, which is −1, so ~x + 1 == -x, and rearranged, ~x == -x - 1. That holds in every language with a ~, including Python, where integers have no fixed width at all: ~5 is -6, ~200 is -201.
Converting by hand
Decimal to pattern. Take −100 in 8 bits: write 100 as 0110 0100, invert to 1001 1011, add one to get 1001 1100 = 0x9C. The shortcut is that all of that is just 2ⁿ − x: 256 − 100 = 156 = 0x9C.
32 bits, same recipe. −1,234,567 is 0x0012D687, inverted 0xFFED2978, plus one 0xFFED2979 — or 4,294,967,296 − 1,234,567 = 4,293,732,729, the same value.
Pattern to decimal. Top bit 0, read it normally. Top bit 1, either sum the weights (with the top one negative) or subtract 2ⁿ from the unsigned reading: 0xFFED2979 is 4,293,732,729 unsigned, minus 4,294,967,296 = −1,234,567.
The hex glance. One hex digit is four bits, so the sign lives entirely in the leading digit: top nibble 8–F means negative at that width. 0x9C is negative as an int8, 0xFFFFFFC8 as an int32, and 0x7FFFFFFF is the largest positive int32 there is. (It’s the same nibble-aligned habit behind IPv6 compression and CIDR masks.)
Why the arithmetic needs no special cases
Two’s complement is really arithmetic modulo 2ⁿ, with the carry out of the top bit thrown away. Add 5 and −3 in four bits:
0101 (5)
+ 1101 (-3)
------
10010 -> discard the carry -> 0010 = 2
The adder neither knew nor cared that one operand was negative. Subtraction is addition of the negation, and the low N bits of a multiplication are identical for signed and unsigned operands: (−3) × (−5) = 15, and unsigned 253 × 251 = 63,503, whose low byte is also 15.
What does need signed-aware instructions: division, comparison (0xFF vs 0x01 is 255 > 1 read unsigned and −1 < 1 read signed — opposite answers from identical bits), right shift, and the high half of a widening multiply. Everything else is free, which is the entire reason this representation won — and why XOR, AND and OR never ask about signedness at all, which is where the toy-cipher story picks these same bit patterns up.
The range is asymmetric, and abs() can fail
One negative value has no positive twin, because zero eats a slot on the positive side:
| Type | Minimum | Maximum |
|---|---|---|
int8 | −128 | 127 |
int16 | −32,768 | 32,767 |
int32 | −2,147,483,648 | 2,147,483,647 |
int64 | −9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
Negate −128 the usual way: invert 1000 0000 to 0111 1111, add one, and you’re back at 1000 0000. −128 is its own negation. In Java this is not a crash, it’s a documented return value:
Math.abs(Integer.MIN_VALUE) // -2147483648
An absolute value that is negative. Same pattern: abs(INT_MIN) in C is undefined behavior, i32::MIN.abs() in Rust panics with overflow checks on. Any if (abs(x) < limit) guard has exactly one input that walks straight past it.
The expensive instance is at the other end of the range: a Unix time_t counting seconds hits int32’s 2,147,483,647 at 03:14:07 UTC on 19 January 2038, then wraps to 0x80000000 — 13 December 1901. That’s the whole Year 2038 problem: not a date bug, a signed-overflow bug. The same ceiling capped 32-bit file offsets at 2,147,483,647 bytes; drop that into the bytes converter for the 2 GiB limit that haunted a decade of software.
Widening and narrowing
Going from 8 bits to 32, you can’t pad with zeros — that would turn −56 into 200. You sign-extend: copy the top bit into every new position, so 0xC8 widens to 0xFFFFFFC8, still −56. Zero-extension (0x000000C8) is correct for unsigned values and quietly corrupts signed ones.
Narrowing just truncates to the low N bits: 300 stored into a byte becomes 300 & 0xFF = 44, and 256 becomes 0. In C this is implementation-defined (C17 §6.3.1.3), though GCC and Clang both document the modulo-2ⁿ result you’d expect; in Java, Rust (as) and JavaScript’s typed arrays the wrap is fully specified.
Arithmetic vs logical right shift
Shifting right has to decide what to feed in at the top, which is where signedness reappears in the operators themselves:
-1 >> 1 // -1 arithmetic: sign bit copied in
-1 >>> 1 // 2147483647 logical: zero shifted in
-1 >>> 0 // 4294967295 the idiom for "read this as uint32"
JavaScript is unusual in exposing both. Every bitwise operator first coerces its operands to 32-bit integers — signed, except for >>>, which reads the left operand as unsigned — which is why 0xFFFFFFFF | 0 is -1 and (2147483647 + 1) | 0 is -2147483648, even though plain JS numbers are doubles and Math.abs(-2147483648) returns the correct 2,147,483,648.
One more trap: arithmetic shift is not integer division for negatives. -7 >> 1 is −4, because the shift rounds toward −∞, while integer division in C, Java and JavaScript truncates toward zero and gives −3. (Python is the exception that proves the rule: // floors, so -7 // 2 is also −4 and the two agree.)
The 32-bit coercion is also what breaks flag masks above bit 31. Bit masks and flags covers that and the BigInt fix, and the bitwise calculator shows every shift bit by bit at 8, 16, 32 or 64 bits.
What each language does
| Language | Behavior worth knowing |
|---|---|
| JavaScript | Bitwise ops are int32; >>> gives the unsigned view; (-5).toString(2) prints -101, not the bit pattern — use (-5 >>> 0).toString(2) for 11111111111111111111111111111011 |
| Java | No unsigned integer types (only 16-bit char); overflow wraps silently; Byte.toUnsignedInt(b) or b & 0xFF recovers 0–255; Math.addExact throws instead of wrapping |
| C | Unsigned wraps modulo 2ⁿ by definition; signed overflow is undefined behavior even in C23, which mandates two’s complement representation but not overflow semantics — the compiler may optimize on the assumption it never happens |
| Rust | Overflow panics when overflow checks are on (the debug default) and wraps when they’re off (the release default); use wrapping_add, checked_add, saturating_add to say which you meant; >> is arithmetic on signed types, logical on unsigned |
| Python | Integers are unbounded, so there’s no wrap at all — but ~5 == -6 and -1 >> 1 == -1 still hold, as if the sign extended infinitely; mask with & 0xFF to get a fixed-width view |
That Java row explains a classic: print the UTF-8 bytes of é in Java and you get -61 -87, while every other tool shows C3 A9 (195, 169). Same bytes, signed printer. Our Unicode inspector shows unsigned byte values next to the code point; text to hex and text to binary do it for whole strings.
Reading patterns with the converter
The number base converter does this post’s arithmetic for you. Left on Auto, it behaves like Python: an arbitrary-precision value in decimal, hex, binary or octal — a pasted 0x, 0b or 0o prefix is fine — parsed with BigInt, so FFFFFFFFFFFFFFFF converts exactly and -56 prints as -38 in hex, sign-magnitude, exactly the way (-56).toString(16) does.
Pick a width instead — 8, 16, 32 or 64 bits — and it stops converting numbers and starts converting patterns. Decimal is read as a value in that width’s range; hex, binary and octal are read as the raw bits. Both readings come back at once:
- 8-bit signed, dec
-56→ HexC8, Bin1100 1000, Unsigned200. The two numbers that opened this post, from one input. - 8-bit signed, hex
C8→ Dec−56. The other direction is just switching the input base. - 32-bit signed, hex
FFED2979→ Dec−1,234,567, with nobody subtracting 4,294,967,296 by hand. - 64-bit signed, hex
FFFFFFFFFFFFFFFF→ Dec−1, still BigInt-exact.
Hex is zero-padded to the width and binary is grouped in nibbles, so 5 in a byte shows as 05 and 0000 0101 rather than 5 and 101 — the padding was always the part you were doing in your head. Overshoot the range and it says so rather than wrapping silently: 128 at 8-bit signed gets Does not fit in 8-bit signed (−128 to 127), which is precisely the overflow a C compiler would wave through. The Signed/Unsigned toggle decides which reading is the headline and which sits in the row beneath it, and the caption under the results says what the sign bit is doing.
Four constants are still worth carrying for the times you’re doing this without a tool: 2⁸ = 256, 2¹⁶ = 65,536, 2³² = 4,294,967,296, 2⁶⁴ = 18,446,744,073,709,551,616.
What to do
- When two systems disagree about a number, check width and signedness first. 200 vs −56, 65,480 vs −56 and 4,294,967,240 vs −56 are one byte pattern read at three widths.
- Never store a byte in a signed type you then compare against 0. In Java and C,
if (b > 127)is dead code for abyte/signed char; mask with& 0xFFfirst. - Treat
abs, unary minus and/ -1as overflow sites. One bad input each, and it’s the type’s minimum. - Sign-extend when widening signed values, zero-extend when widening unsigned ones — and make the cast explicit.
- Grep for
>>on values that might be negative. If you wanted division, write division; the two differ by one for odd negatives. - When you just need to see the bits, paste the value into the number base converter and set the width and signedness you actually meant — dec, hex, binary and octal side by side, zero-padded, BigInt-exact, entirely in your browser.
The short version: a signed integer isn’t a number with a sign attached. It’s N bits, plus a convention that the most significant one counts against you.