Casper's Blog – Why I forked rand
Casper's Blog
Thoughts on code, systems, and craft.
GitHub
Why I forked rand
July 27, 2026 — by Casper — rust
Rust's rand crate is the default choice for randomness in the Rust ecosystem.<br>My experience with rand has frustrating papercuts: many everyday operations are hard to discover!
That led me to fork and build urandom:<br>a randomness crate with a smaller public and implementation surface, organized around a specific user experience.
To quote:
“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.”
One place to look
With rand, useful operations come from several traits:
use rand::RngExt;<br>use rand::seq::{IndexedRandom, SliceRandom};
let mut rng = rand::rng();
let roll: u32 = rng.random_range(1..=6);<br>let color = ["red", "green", "blue"].choose(&mut rng);
let mut numbers: Vec = (1..=10).collect();<br>numbers.shuffle(&mut rng);
Rand 0.10 also provides root-level helpers such as rand::random_range for one-off calls.<br>Once you keep an RNG handle, or need sequence operations such as<br>choosing and shuffling, the method API still comes from several traits.
The prelude shortens the imports, but you still need to know to which types the extension methods apply.<br>IDE completion does not help much when the method may belong to the RNG, the slice, the iterator or elsewhere.<br>Discovering the right method often requires already knowing which trait provides it on which type.
urandom puts its high-level consumer API on one Random wrapper struct:
let mut rand = urandom::new();<br>// rand: urandom::Random
let roll: u32 = rand.uniform(1..=6);<br>let color = rand.choose(&["red", "green", "blue"]);
let mut numbers: Vec = (1..=10).collect();<br>rand.shuffle(&mut numbers);
If you have a Random, editor completion shows you random, uniform, chance, choose, shuffle, sample, and more.<br>These are inherent methods, so there is no high-level extension trait to find or import.
A sealed Rng
rand treats its low-level RNG traits as public extension points. urandom does not:<br>its Rng trait is sealed, and the supported generators are selected and implemented inside the crate.
You cannot plug an arbitrary generator into Random.
A new generator requires a change to urandom.
I kept coming back to a core question: what material benefit does customizing the generator provide?
If the goal is a better algorithm, Xoshiro256 and ChaCha are established<br>default choices for their roles today, and recommendations change slowly.<br>If that changes, urandom can adopt a better choice in a future major release.
If the goal is compatibility with another project, programming language, legacy<br>algorithm, specialized hardware, or simulation-specific generator, matching its<br>generator alone is not enough. Uniform sampling, shuffling, and other<br>algorithms must match too. A dedicated implementation of that complete contract<br>is a better fit.
Sealing Rng lets me shape it around my crate instead. I can add exactly the<br>primitives urandom needs without designing and documenting an implementation<br>contract for unknown generators and their special cases. This lets the generator<br>and algorithms specialize for each other, enabling niche optimizations not<br>available to rand.
For most applications, choosing the entropy is more useful than implementing a<br>new PRNG. The concrete generators expose their native from_seed constructors:
fn from_my_entropy(seed: [u32; 8]) -> urandom::Random {<br>urandom::rng::ChaCha12Rng::from_seed(seed)
This is narrower than accepting any RNG implementation, but it preserves the<br>extension point I expect advanced users to need.
Beating rand without a better algorithm
My fork is not based on novel random-number algorithms.<br>On 64-bit systems, urandom::new() and rand::rngs::SmallRng use the same<br>Xoshiro256 family for non-cryptographic use. urandom::csprng() and<br>rand::rngs::StdRng use ChaCha12 for cryptographic use. Rand's convenient<br>rand::rng() handle is backed by ChaCha12.
rand's generator interface exposes integer words and byte filling.<br>A distribution that wants an f64 starts by asking for a full u64.
urandom::Rng also has specialized next_f32 and next_f64:
pub trait Rng: Sealed {<br>fn next_u32(&mut self) -> u32;<br>fn next_u64(&mut self) -> u64;
fn next_f32(&mut self) -> f32;<br>fn next_f64(&mut self) -> f64;
// byte filling omitted
Random floats need fewer random bits than a full word. A generator can therefore override these methods with a cheaper output function.
The Xoshiro family of algorithms support exactly that.<br>The implementation retains Xoshiro256++ for u64, while u32 and floats use the faster Xoshiro256+.<br>Both share the same state advance, but Xoshiro256+ has a cheaper output function whose upper bits are designed for this use case.
On my system, comparing the current urandom 1.0 implementation with rand 0.10.2,<br>the end-to-end Xoshiro f64 benchmark measured about 31% higher throughput (24% faster) in this microbenchmark.<br>The u64 paths, where both...