Skip to content

Random Number Generation

Random numbers are used when a program needs variation.

Random numbers are used when a program needs variation.

Games use random numbers for movement, maps, enemies, and loot. Simulations use random numbers to model uncertain events. Tests use random numbers to generate many inputs. Security code uses random bytes for keys, tokens, salts, and nonces.

These uses are not all the same.

For normal program behavior, a pseudo-random number generator is often enough.

For security, you need cryptographic randomness.

Zig makes this distinction visible.

Pseudo-Random Numbers

A pseudo-random number generator, or PRNG, creates a sequence of numbers that looks random.

It is not truly random. It starts from a seed.

The same seed produces the same sequence.

That is useful for tests and reproducible programs.

const std = @import("std");

pub fn main() void {
    var prng = std.Random.DefaultPrng.init(12345);
    const random = prng.random();

    const value = random.int(u32);

    std.debug.print("{}\n", .{value});
}

This creates a PRNG:

var prng = std.Random.DefaultPrng.init(12345);

The seed is:

12345

Then this gets the random interface:

const random = prng.random();

Then this produces a random u32:

const value = random.int(u32);

If you run the program again with the same seed, you should get the same sequence.

Random Integers in a Range

Most programs do not want any possible integer. They want a value inside a range.

For example, a die roll should produce a number from 1 to 6.

const std = @import("std");

pub fn main() void {
    var prng = std.Random.DefaultPrng.init(12345);
    const random = prng.random();

    const roll = random.intRangeAtMost(u32, 1, 6);

    std.debug.print("roll = {}\n", .{roll});
}

This line matters:

const roll = random.intRangeAtMost(u32, 1, 6);

It returns a u32 from 1 through 6.

“At most” means the upper bound is included.

So this range includes:

1 2 3 4 5 6

Exclusive Upper Bounds

Some range functions use an exclusive upper bound.

That means the end value is not included.

For example, a range from 0 to 10 with an exclusive upper bound produces:

0 1 2 3 4 5 6 7 8 9

This is common in programming because it matches array indices.

The exact function names can vary, so check your local Zig standard library docs with:

zig std

The concept matters more than the name:

Inclusive upper bound includes the last number.

Exclusive upper bound stops before the last number.

Be careful when writing random ranges. Off-by-one errors are common.

Random Booleans

You can create random booleans by asking for a random integer and checking it.

const std = @import("std");

pub fn main() void {
    var prng = std.Random.DefaultPrng.init(12345);
    const random = prng.random();

    const bit = random.intRangeAtMost(u8, 0, 1);
    const value = bit == 1;

    std.debug.print("{}\n", .{value});
}

This produces either true or false.

Some Zig versions may provide a direct boolean helper. The manual method above is enough to understand the idea.

Random Array Index

Random numbers are often used to choose an item from an array.

const std = @import("std");

pub fn main() void {
    const colors = [_][]const u8{
        "red",
        "green",
        "blue",
        "yellow",
    };

    var prng = std.Random.DefaultPrng.init(12345);
    const random = prng.random();

    const index = random.intRangeLessThan(usize, 0, colors.len);

    std.debug.print("color = {s}\n", .{colors[index]});
}

The valid indices are:

0 1 2 3

The array length is 4.

So the range should start at 0 and stop before 4.

That is why this form is useful:

random.intRangeLessThan(usize, 0, colors.len)

The upper bound is excluded, so the result is safe as an array index.

Reproducible Randomness

The same seed gives the same sequence.

This is extremely useful in tests.

const std = @import("std");

fn printRolls(seed: u64) void {
    var prng = std.Random.DefaultPrng.init(seed);
    const random = prng.random();

    for (0..5) |_| {
        const roll = random.intRangeAtMost(u32, 1, 6);
        std.debug.print("{} ", .{roll});
    }

    std.debug.print("\n", .{});
}

pub fn main() void {
    printRolls(12345);
    printRolls(12345);
}

Both lines use the same seed, so they produce the same sequence.

This helps when debugging.

If a random test fails with seed 98765, you can run the test again with seed 98765 and reproduce the failure.

Seeding from Time

Sometimes you want a different sequence each time the program runs.

A simple approach is to seed from a timestamp.

const std = @import("std");

pub fn main() void {
    const seed: u64 = @intCast(std.time.nanoTimestamp());

    var prng = std.Random.DefaultPrng.init(seed);
    const random = prng.random();

    const value = random.intRangeAtMost(u32, 1, 100);

    std.debug.print("{}\n", .{value});
}

This is fine for games, demos, and simple simulations.

It is not suitable for security.

Timestamps are guessable. If an attacker can guess the seed, they may be able to predict the random sequence.

Filling a Buffer with Random Bytes

Sometimes you want random bytes, not a single number.

const std = @import("std");

pub fn main() void {
    var prng = std.Random.DefaultPrng.init(12345);
    const random = prng.random();

    var bytes: [16]u8 = undefined;
    random.bytes(&bytes);

    std.debug.print("{any}\n", .{bytes});
}

This fills the array with random bytes.

Random bytes are useful for binary data, tests, generated inputs, and simulations.

For cryptographic tokens, use cryptographic randomness instead.

Shuffling an Array

Randomness can rearrange items.

This is called shuffling.

Some Zig versions provide shuffle helpers through the random interface. The concept looks like this:

random.shuffle(T, items);

For example, shuffling cards means rearranging the order of the cards randomly.

A shuffle must be unbiased if fairness matters. Do not write a careless shuffle using random swaps unless you know the algorithm is correct.

The usual correct algorithm is Fisher-Yates shuffle:

Start from the last item.

Pick a random earlier position including itself.

Swap the two items.

Move backward.

Repeat.

Cryptographic Randomness

Security-sensitive code needs stronger randomness.

Examples:

password reset tokens

session IDs

API keys

encryption keys

salts

nonces

Do not use DefaultPrng for these.

Use a cryptographic random source from the standard library.

The usual interface is:

std.crypto.random

Example:

const std = @import("std");

pub fn main() void {
    var token: [32]u8 = undefined;

    std.crypto.random.bytes(&token);

    std.debug.print("{any}\n", .{token});
}

This fills token with cryptographically secure random bytes.

You do not pass a simple seed. The randomness comes from a secure source provided by the system and standard library.

Printing Random Bytes as Hex

Raw bytes are hard to read.

For tokens, hexadecimal is common.

const std = @import("std");

pub fn main() void {
    var token: [16]u8 = undefined;
    std.crypto.random.bytes(&token);

    for (token) |byte| {
        std.debug.print("{x:0>2}", .{byte});
    }

    std.debug.print("\n", .{});
}

This prints each byte as two hexadecimal digits.

The format:

{x:0>2}

means hexadecimal, padded with zeros to width 2.

Example output:

4f7a09c1e24b84a5b1a39f72dd631908

Your output will be different.

Do Not Mix Up Random Sources

Use the right source for the job.

For tests, use a seeded PRNG.

For games, use a PRNG seeded from time or another non-security source.

For simulations, choose a PRNG and seed deliberately.

For security, use std.crypto.random.

This distinction matters.

A predictable random source can be useful in tests.

A predictable random source can be disastrous in security code.

Randomness in Tests

Randomized tests can be useful, but they should be reproducible.

Bad test design:

var prng = std.Random.DefaultPrng.init(@intCast(std.time.nanoTimestamp()));

If the test fails, you may not know the seed.

Better test design:

const seed = 12345;
var prng = std.Random.DefaultPrng.init(seed);

Or print the seed when a failure occurs.

A failing random test should give you enough information to run the same case again.

Common Mistakes

Do not use modulo for random ranges unless you know it is safe.

This can introduce bias:

const value = random.int(u32) % 6;

Some results may become slightly more likely than others, depending on the generator range and modulo value.

Prefer range functions:

const value = random.intRangeAtMost(u32, 1, 6);

Do not use time-seeded PRNGs for security.

Do not assume a random function includes the upper bound unless the name or documentation says so.

Do not forget that the same seed gives the same sequence.

Do not make tests impossible to reproduce.

The Core Pattern

For reproducible pseudo-random numbers:

var prng = std.Random.DefaultPrng.init(12345);
const random = prng.random();

const value = random.int(u32);

For a range:

const roll = random.intRangeAtMost(u32, 1, 6);

For an array index:

const index = random.intRangeLessThan(usize, 0, items.len);

For random bytes:

var bytes: [16]u8 = undefined;
random.bytes(&bytes);

For security:

var token: [32]u8 = undefined;
std.crypto.random.bytes(&token);

What You Should Remember

Randomness has different levels.

A PRNG is deterministic. It starts from a seed.

The same seed gives the same sequence.

That is useful for tests and debugging.

Use range helpers instead of % for random ranges.

Use exclusive upper bounds for array indices.

Use std.crypto.random for security-sensitive bytes.

Do not use ordinary PRNGs for tokens, keys, salts, or other security values.

Good random code is explicit about the source of randomness and the range of possible values.