Let's do some bit masking
Bit masking, it sounds complicated but it’s not. It’s just working with individual bits inside a number. If you know what a byte is but have never touched bits, that’s what we are going to do today. By the end hopefully you’ll be able to check, set, clear, and toggle any bit in a number.
What is a bit?
A bit is the smallest piece of data a computer has. It’s either 0 or 1. That’s it. Everything your computer does every character on screen, every file is stored as sequences of bits.
Here’s how small numbers map to bits -
Number Binary
0 0000
1 0001
2 0010
3 0011
4 0100
5 0101
10 1010
255 11111111
Each position in a binary number represents a power of 2. Reading right to left -
Position: 3 2 1 0
Value: 8 4 2 1
Binary: 0 1 0 1
So 0101 means: 0 eights + 1 four + 0 twos + 1 one = 5. Each bit is like a light switch either on (1) or off (0).
Hex is shorthand for binary
Writing binary is hard. Humans prefer hexadecimal (hex) because one hex digit maps to exactly four bits -
Hex Binary
0 0000
1 0001
2 0010
3 0011
4 0100
5 0101
A 1010
F 1111
So 0x5 is 0101 in binary. 0xA is 1010. 0xFF is 11111111 eight bits, all ones. That’s the maximum value for a single byte (255 in decimal).
In Zig you write binary with 0b prefix and hex with 0x -
const a: u8 = 0b0101; // 5
const b: u8 = 0x5; // also 5
Both are the same number 5. Hex is just easier to type.
The operators
Bit masking uses four operators. If you know + and -, these work the same way they just operate on individual bits instead of whole numbers.
AND (checking bits)
AND compares two bits. The result is 1 only if both bits are 1 -
0101
& 0011
------
0001
Bit by bit:
- Bit 3: 0 & 0 = 0
- Bit 2: 1 & 0 = 0
- Bit 1: 0 & 1 = 0
- Bit 0: 1 & 1 = 1
AND zeros out everything except the bits you care about. If you AND with 0011, you keep only bits 0 and 1. Everything else becomes 0. It’s like a filter only the 1-bits in the mask let data through.
Say you want to check if bit 2 is set in a number -
const value: u8 = 0b0101; // 5
const mask: u8 = 0b0100; // only bit 2 is 1
if (value & mask != 0) {
// bit 2 is set
}
If value & mask is nonzero, the bit was on. If it’s zero, the bit was off.
But hardcoding masks is annoying. You want to say “check bit n” where n is a variable -
const value: u8 = 0b0101;
const bit: u3 = 2;
if (value & (@as(u8, 1) << bit) != 0) {
// bit 2 is set
}
The 1 << bit part shifts the 1-bit left by bit positions - 0001 becomes 0100. Then AND with value keeps only that bit. The @as(u8, 1) is Zig’s way of saying “treat this 1 as a u8” so the types match.
OR (setting bits)
OR compares two bits. The result is 1 if either bit is 1 -
0101
| 0010
------
0111
Bit by bit:
- Bit 3: 0 | 0 = 0
- Bit 2: 1 | 0 = 1
- Bit 1: 0 | 1 = 1
- Bit 0: 1 | 0 = 1
OR turns on bits without turning off anything else. If you OR with 0010, bit 1 becomes 1. All other bits stay exactly as they were.
Say you want to turn on bit 1 -
var value: u8 = 0b0101; // 5
const mask: u8 = 0b0010; // bit 1
value |= mask; // value is now 0b0111 (7)
Bit 1 was 0, now it’s 1. Bits 0, 2, 3 are unchanged. The |= is shorthand for value = value | mask.
Using shifts to set bit n -
var value: u8 = 0b0101;
const bit: u3 = 1;
value |= @as(u8, 1) << bit; // value is now 0b0111
OR works on all bits simultaneously. Set multiple flags at once -
var flags: u8 = 0b0000;
const ENABLED: u8 = 0b0001;
const VISIBLE: u8 = 0b0010;
flags |= ENABLED; // flags = 0b0001
flags |= VISIBLE; // flags = 0b0011
Or in one line -
flags |= ENABLED | VISIBLE; // flags = 0b0011
If the bit is already 1, OR does nothing to it -
var value: u8 = 0b0111; // bits 0, 1, 2 are on
value |= 0b0010; // bit 1 is already on
// value is still 0b0111
This is why OR is safe for “set this flag” you don’t need to check first.
One thing to know - OR is not addition.
var a: u8 = 0b0001; // 1
a |= 0b0001; // still 0b0001 (1), not 0b0010 (2)
OR turns on bits. Addition carries. They’re completely different.
XOR (toggling bits)
XOR compares two bits. The result is 1 if the bits are different -
0101
^ 0011
------
0110
Bit by bit:
- Bit 3: 0 ^ 0 = 0 (same → 0)
- Bit 2: 1 ^ 0 = 1 (different → 1)
- Bit 1: 0 ^ 1 = 1 (different → 1)
- Bit 0: 1 ^ 1 = 0 (same → 0)
XOR toggles bits. If a bit is 0, XOR with 1 makes it 1. If a bit is 1, XOR with 1 makes it 0. XOR with 0 leaves the bit unchanged.
Toggle bit 1 -
var value: u8 = 0b0101; // 5
value ^= 0b0010; // toggle bit 1
// value = 0b0111 (7)
Toggle bit 1 again -
value ^= 0b0010;
// value = 0b0101 (back to 5)
Using shifts -
var value: u8 = 0b0101;
const bit: u3 = 1;
value ^= @as(u8, 1) << bit;
XOR has a special property, self-cancellation. XOR a number with itself and you get zero -
0101
^ 0101
------
0000
This is useful for swapping values without a temporary variable -
var a: u8 = 5;
var b: u8 = 3;
a ^= b; // a = 6
b ^= a; // b = 5
a ^= b; // a = 3
Not practical in real code (use a temp variable), but it shows XOR’s nature.
NOT (flipping everything)
NOT flips every bit. 0 becomes 1, 1 becomes 0 -
~ 0101
------
1010
In Zig:
const value: u8 = 0b0101;
const flipped: u8 = ~value; // 0b1010
For a u8, ~0b00000001 = 0b11111110. NOT inverts all 8 bits.
NOT is useful for clearing bits. Say you want to clear bit 2 and you only have the mask 0b0100. You need the inverse: ~0b0100 = 0b1011.
var value: u8 = 0b1111;
const mask: u8 = 0b0100; // bit 2
value &= ~mask; // value = 0b1011
~mask turns the mask inside out - every 0 becomes 1, every 1 becomes 0. Then AND keeps everything except the bit you want to clear.
XOR can also toggle bits, but XOR is cleaner -
// Toggle bit 2 using OR and AND (messy)
value = (value | 0b0100) & ~(value & 0b0100); // don't do this in my opinion
// Toggle bit 2 using XOR (clean)
value ^= 0b0100;
XOR does it in one operation.
Shifts (moving bits around)
Shifts move bits left or right. They’re how you target a specific bit position for the other operations.
Left shift («)
Moves bits to the left. Zeros fill in from the right -
1 << 0 = 0001 = 1
1 << 1 = 0010 = 2
1 << 2 = 0100 = 4
1 << 3 = 1000 = 8
Each shift left multiplies by 2. 1 << 4 = 16. 3 << 2 = 12.
const x: u8 = 1 << 3; // 8
const y: u8 = 3 << 2; // 12
This is how you create a mask for bit n - 1 << n puts a single 1-bit at position n.
Right shift (»)
Moves bits to the right. Zeros fill in from the left (for unsigned) -
8 >> 0 = 1000 = 8
8 >> 1 = 0100 = 4
8 >> 2 = 0010 = 2
8 >> 3 = 0001 = 1
Each shift right divides by 2 (integer division). 10 >> 1 = 5. 7 >> 1 = 3 (not 3.5 — integer division drops the remainder).
const x: u8 = 8 >> 3; // 1
const y: u8 = 7 >> 1; // 3
Extracting a specific bit
To get the value of bit n (0 or 1) -
const value: u8 = 0b10110100;
const bit: u3 = 3;
const extracted: u8 = (value >> bit) & 1;
// value >> 3 = 0b00010110
// 0b00010110 & 1 = 0
Shift right to move the bit you want to position 0. AND with 1 to isolate it.
Extracting multiple bits
Shifts and AND together can pull out a range of bits. Say you want bits 4-5 from a byte -
const value: u8 = 0b10110100;
const extracted: u8 = (value >> 4) & 0b11;
// value >> 4 = 0b00001011
// 0b00001011 & 0b11 = 0b11 = 3
This is how you read fields from packed data. A protocol might store two 4-bit values in one byte. Shift and mask to extract each.
Shifts are not multiplication
Left shift by n is the same as multiplying by 2^n. Right shift is dividing by 2^n. But shifts are operations on bits, not arithmetic. This matters for signed numbers and edge cases. Use shifts when you’re working with bits. Use * and / when you’re doing math.
The four patterns
You’ve seen AND, OR, XOR, NOT, and shifts. Here are the four patterns you’ll actually use 90% of the time. Memorize these.
Check bit n
Is bit n set? AND with a mask that has only bit n on -
if (value & (@as(u8, 1) << n) != 0) {
// bit n is set
}
1 << n creates 00010000 (for n=4). AND keeps only bit n. If the result is nonzero, the bit was on.
Set bit n
Turn on bit n. OR with the mask -
value |= @as(u8, 1) << n;
OR turns on bit n. If it was already on, nothing changes. Safe to call multiple times.
Clear bit n
Turn off bit n. AND with the inverted mask -
value &= ~(@as(u8, 1) << n);
~(1 << n) flips the mask. Instead of 00010000 you get 11101111. AND keeps everything except bit n.
Toggle bit n
Flip bit n. XOR with the mask -
value ^= @as(u8, 1) << n;
XOR flips bit n. If it was 0, it becomes 1. If it was 1, it becomes 0.
Summary
| Goal | Operation | Code |
|---|---|---|
| Check bit n | AND | value & (@as(u8, 1) << n) != 0 |
| Set bit n | OR | value |= @as(u8, 1) << n |
| Clear bit n | AND NOT | value &= ~(@as(u8, 1) << n) |
| Toggle bit n | XOR | value ^= @as(u8, 1) << n |
Working with multiple bits
Sometimes you need to set or check multiple bits at once. Build the mask from multiple positions -
// Set bits 0, 2, and 4
const mask = (@as(u8, 1) << 0) | (@as(u8, 1) << 2) | (@as(u8, 1) << 4);
value |= mask;
// Check if any of bits 0, 2, 4 are set
if (value & mask != 0) {
// at least one is set
}
// Check if ALL of bits 0, 2, 4 are set
if (value & mask == mask) {
// all are set
}