Skip to content

Bitwise operations and two's complement for programmers

By · Math & Programming Calculators · 5 min read · Published

Most application code never touches individual bits, until it does: a permissions field stored as a bit mask, a binary protocol, a hash function, a color value, a network mask, or a bug where a large number suddenly turns negative. This guide explains the bitwise operators, shows the patterns developers actually use them for, and explains two's complement, the representation of negative numbers that causes most of the confusion. You can try every example in the Programmer Calculator.

Bits, bytes and bases

Integers are stored as fixed-width sequences of bits: 8 bits in a byte, and typically 32 or 64 bits in an int or long. Each bit is a power of two, so the 8-bit value 00101101 is 32 + 8 + 4 + 1 = 45.

Binary is long to write, so developers use hexadecimal: each hex digit represents exactly four bits, and a byte is exactly two hex digits. 0x2D is 0010 1101, which is 45. That clean mapping is why memory addresses, colors (#FF8800), hashes and byte dumps are written in hex. Octal, where each digit is three bits, survives mainly in Unix file permissions like 0755.

The bitwise operators

Bitwise operators work on each bit position independently:

        a = 1100
        b = 1010
a & b   =   1000    AND: 1 where both are 1
a | b   =   1110    OR: 1 where either is 1
a ^ b   =   0110    XOR: 1 where they differ
   ~a   =   0011    NOT: every bit flipped (shown in 4 bits)

Shifts move all bits left or right:

  • x << n shifts left by n positions, filling with zeros. Each shift multiplies by 2, as long as no bits fall off the top.
  • x >> n shifts right. For signed types this is usually an arithmetic shift, which copies the sign bit so negative numbers stay negative; each shift divides by 2, rounding toward negative infinity.
  • x >>> n, in Java and JavaScript, is a logical shift that always fills with zeros.

Practical patterns

Flags in a single integer

Assign each option one bit, then combine them with OR:

const READ    = 1 << 0;   // 0001
const WRITE   = 1 << 1;   // 0010
const DELETE  = 1 << 2;   // 0100
const ADMIN   = 1 << 3;   // 1000

let perms = READ | WRITE;              // 0011
const canWrite = (perms & WRITE) !== 0; // test a flag
perms |= DELETE;                       // set a flag
perms &= ~WRITE;                       // clear a flag
perms ^= ADMIN;                        // toggle a flag

This stores many booleans in one column or field and makes checks fast. Unix file permissions, feature flags, event masks and many C APIs work this way.

Extracting fields

Shift and mask to read part of a value. To get the red component of a 24-bit color 0xFF8800: (color >> 16) & 0xFF gives 255; green is (color >> 8) & 0xFF, 136. The same technique parses packed binary protocols and IP addresses.

Quick checks

  • (x & 1) === 0 tests whether x is even.
  • x & (x - 1) clears the lowest set bit; it is zero exactly when x is a power of two (for x > 0).
  • x & (n - 1) equals x % n when n is a power of two, which hash tables use to pick buckets.

Two's complement: how negative numbers are stored

A fixed number of bits can represent a fixed number of values. With 8 bits, there are 256 patterns. Unsigned interpretation uses them for 0 to 255. Signed interpretation, in almost every modern CPU, uses two's complement for -128 to 127.

The rule: to negate a number, invert all its bits and add one.

 5  = 0000 0101
~5  = 1111 1010
-5  = 1111 1011   (inverted, plus one)

The highest bit acts as the sign: 0 for non-negative, 1 for negative. Two's complement is used because ordinary binary addition works without special cases: adding 0000 0101 (5) and 1111 1011 (-5) gives 1 0000 0000, and the ninth bit falls off, leaving 0.

The same bits mean different numbers depending on the interpretation. 1111 1111 is 255 unsigned and -1 signed. 0x80000000 is 2,147,483,648 as an unsigned 32-bit value and -2,147,483,648 as a signed one. This is why a value read from a binary file or database can appear negative: the bits are right, the interpretation is not.

Overflow

When a result does not fit in the available bits, the extra bits are discarded and the value wraps around. In a signed 32-bit integer, 2,147,483,647 + 1 becomes -2,147,483,648. Languages handle this differently:

  • Java and C# wrap silently for int and long; use Math.addExact or checked blocks to throw instead.
  • In C and C++, signed overflow is undefined behaviour, which compilers may exploit in surprising ways. Unsigned overflow wraps.
  • Rust panics on overflow in debug builds and wraps in release builds unless you use explicit methods like checked_add or wrapping_add.
  • Python integers have arbitrary precision and never overflow.

JavaScript's special case

JavaScript numbers are 64-bit floating point, but bitwise operators first convert their operands to 32-bit signed integers. So 2 ** 31 | 0 is -2147483648, and 0xFFFFFFFF | 0 is -1. Use >>> 0 to reinterpret a result as unsigned. For 64-bit values, such as IDs or hashes, use BigInt: (1n << 63n) - 1n works exactly, and BigInt.asIntN and BigInt.asUintN wrap to a chosen width.

Habits that avoid bit bugs

  1. Always state the width and signedness when sharing a binary or hex value.
  2. Use named constants for masks and shifts instead of magic numbers.
  3. Parenthesise bitwise expressions; in C, Java and JavaScript, == binds tighter than &, so x & MASK == 0 does not do what it looks like.
  4. Use unsigned types or logical shifts when working with raw bytes.
  5. Check intermediate values in a calculator that wraps like the real type, rather than a decimal desk calculator that never overflows.