Start here: what a computer actually stores
A computer’s memory is made of billions of tiny switches, each either off or on. We write off as 0 and on as 1, and call one switch a bit (short for binary digit). That’s all the hardware stores. Text, images, numbers, your program’s own code: all of it is patterns of bits, and some piece of software has agreed on what each pattern means. Bits are grouped in eights. A group of 8 bits is a byte, and it’s the smallest piece of memory a CPU can point to and read on its own. When a file is “2 KB”, it holds about 2,000 bytes.
Counting in binary
You count in base 10: each digit is worth 10 times the one to its right (ones, tens, hundreds). Binary is the same idea in base 2: each bit is worth twice the one to its right. In a byte, numbering bits from the right starting at 0:
| Bit position | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0
| Worth | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1
To read a byte, add up the worths of the bits that are 1. Take 0000_1101:
- Bit 0 is 1, worth 1.
- Bit 2 is 1, worth 4.
- Bit 3 is 1, worth 8.
- Total: 1 + 4 + 8 = 13.
The underscore is only there to make it readable; Zig allows it inside number literals too. With all 8 bits on you get 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255, so one byte holds 0 through 255: 256 different values. In general, n bits give 2ⁿ different values. That’s why powers of two show up everywhere in computing: 256, 1024, 65,536.
To go the other way, from decimal to binary, repeatedly take the biggest worth that still fits. 200 = 128 + 64 + 8, so bits 7, 6 and 3 are on:
1100_1000.
One byte, many meanings
This is the most important idea in the lesson: bits don’t know what they mean. The byte 1111_1011 could be the number 251, the number −5, part of a colour, or one quarter of a bigger number. The meaning comes from the type your program uses when it reads the byte.
In Zig, u8 means “read this byte as an unsigned number from 0 to 255” and i8 means “read it as a signed number from −128 to 127”. Same byte, different answer. Click the bits below to flip them and watch one pattern read three ways at once.
Hexadecimal: shorthand for bits
Long rows of 0s and 1s are hard to read, so programmers write bits in hexadecimal (base 16). The hex digits are 0–9 and then A–F, where A = 10, B = 11, and so on up to F = 15. The reason hex is so popular: one hex digit is exactly 4 bits, because 4 bits have 2⁴ = 16 possible values. So you can convert by splitting a byte into two halves of 4 bits (each half is called a nibble):
binary: 1111 1011
hex: F B → 0xFB
decimal: 15 × 16 + 11 = 251
The 0x prefix means “hex number”, just as 0b means “binary number”. You’ll see hex in memory addresses (0x7ffd5e3c), colours (#FF8800) and debugger output, because a programmer can read the bits straight off it without any arithmetic.
Negative numbers: two’s complement
Memory only holds bits, so there’s nowhere to put a minus sign. Computers solve this with a scheme called two’s complement, and the rule is simpler than the name: in a signed byte, the top bit (bit 7) is worth −128 instead of +128. Every other bit keeps its normal worth.
Read 1111_1011 as a signed number:
- Bit 7 is on: −128.
- Bits 6, 5, 4, 3, 1 and 0 are on: 64 + 32 + 16 + 8 + 2 + 1 = 123.
- Total: −128 + 123 = −5.
In the same way,
1111_1111is −128 + 127 = −1. Whenever the top bit is 1 the result is negative, which is why it’s called the sign bit. To negate a number by hand: flip every bit, then add 1.
5 = 0000_0101
flip = 1111_1010
+ 1 = 1111_1011 = −5
Why this scheme and not something more obvious? Because it keeps the hardware simple:
- There’s exactly one zero (
0000_0000). Older schemes had both +0 and −0. - Addition needs no special cases. Add 5 and −5:
0000_0101+1111_1011=1_0000_0000. The 9th bit doesn’t fit in a byte and falls off, leaving 0, which is the right answer. The same adding circuit works for signed and unsigned numbers. - An n-bit signed number ranges from −2ⁿ⁻¹ to 2ⁿ⁻¹ − 1. For 8 bits that’s −128 to 127: one more negative than positive, because zero takes up one of the non-negative patterns.
When a number doesn’t fit: overflow
A u8 can’t hold 256. If you add 1 to 255, the true answer needs 9 bits (1_0000_0000), and the 9th bit has nowhere to go. In hardware it simply falls off and you’re left with 0. This is called overflow, or wrapping around, like a car’s odometer rolling from 999999 back to 000000.
In C, overflow silently wraps (or for signed numbers, is “undefined behaviour”), and that has caused countless bugs. Zig treats overflow as a mistake unless you say you want it:
- Plain
+that overflows: in Debug and ReleaseSafe builds the program stops with an “integer overflow” panic pointing at the line. In ReleaseFast it’s illegal behaviour that the optimizer assumes never happens. +%means “wrap around on purpose”. Useful for hashes, checksums and counters that are meant to roll over.+|means “saturate”: stick at the maximum or minimum instead of wrapping.
Widening: zero extension vs sign extension
Sometimes you move a value into a bigger type, say from 8 bits to 16. The new high bits have to be filled with something, and what’s correct depends on signedness:
u8 200 = 1100_1000 → u16: 0000_0000_1100_1000 zero extension: fill with 0
i8 −5 = 1111_1011 → i16: 1111_1111_1111_1011 sign extension: copy the sign bit
If you filled −5 with zeros you’d get 251, which is wrong. Copying the sign bit into the new bits keeps it −5. Zig does the right one automatically when you assign a smaller integer to a bigger type.
Integers in Zig
Zig names integer types by signedness and width: u8 is unsigned 8-bit, i32 is signed 32-bit. Unlike most languages, any width works: u1, u3, i7, u128. usize is the size of a pointer (64 bits on a 64-bit machine) and is what Zig uses for lengths and indexes.
Zig never shrinks a number behind your back. Going from a bigger type to a smaller one needs a builtin that says what you mean:
@intCast: “this value fits, convert it”. Safe builds check, and panic if it doesn’t fit.@truncate: “keep only the low bits, throw the rest away”.@bitCast: “don’t change a single bit, just read them as another type of the same size”.
const std = @import("std");
pub fn main() void {
var x: u8 = 250;
x +%= 10; // wrapping add: 260 doesn't fit, wraps to 4
std.debug.print("wrapped: {d}\n", .{x});
var y: u8 = 250;
y +|= 10; // saturating add: stops at 255
std.debug.print("saturated: {d}\n", .{y});
const neg: i8 = -1;
const bits: u8 = @bitCast(neg); // same bits 1111_1111, read as u8: 255
const wide: i32 = neg; // sign-extended automatically, still -1
const low: u8 = @truncate(@as(u32, 0x1234)); // keeps the low byte, 0x34
std.debug.print("{d} {d} 0x{x}\n", .{ bits, wide, low });
}
Recap
- A bit is 0 or 1, a byte is 8 bits, and n bits give 2ⁿ values.
- Bits have no meaning by themselves; the type decides how they’re read.
- One hex digit is 4 bits, so a byte is always two hex digits.
- Two’s complement: the top bit is worth −2ⁿ⁻¹. Negate by flipping all bits and adding 1.
- Overflow panics in Zig’s safe builds. Use
+%to wrap or+|to saturate when that’s what you mean.