Random Number Generator: Picking 10 Giveaway Winners Nobody Can Predict (Python, Node, and the Browser)
Our marketing team ran a webinar in October and promised ten $200 vouchers to ten randomly chosen registrants. The export hit my inbox at 09:40, a CSV with 8,453 rows.
Legal added two conditions before I touched it. Nobody may predict the winners, and we have to reproduce the exact draw six months later if anyone contests it. Those two conditions fight each other. Reproducible means a seed. A seed published before the draw lets anybody compute all ten names in advance.
It took most of an afternoon to satisfy both, and the mistakes along the way are the interesting part.
Method 1: Python 3.11.1 - draw now, publish the seed afterwards
The trick that settled it: mint a 64-bit seed from the OS entropy pool at the moment you run the draw, use it, then publish it after the winners go out. Until that instant nobody can know the outcome, because secrets.randbits(64) reads from /dev/urandom. Afterwards anyone with the CSV and the recorded seed replays the identical list.
# draw_winners.py - Python 3.11.1
import csv, json, random, secrets
from datetime import datetime, timezone
rows = list(csv.DictReader(open("registrants.csv")))
seen = {}
for row in rows:
# last registration wins
seen[row["email"].strip().lower()] = row
pool = sorted(seen.values(), key=lambda r: int(r["registration_id"]))
seed = secrets.randbits(64)
rng = random.Random(seed)
winners = rng.sample(pool, 10)
print(f"rows {len(rows)} -> unique people {len(pool)}")
print(f"seed {seed}")
for n, w in enumerate(winners, 1):
print(f"{n:2d}. {w['registration_id']:>5} {w['email']}")
with open("draw-record.json", "w") as f:
json.dump({"drawn_at": datetime.now(timezone.utc).isoformat(), "seed": seed, "pool_size": len(pool), "winners": [w["email"] for w in winners]}, f, indent=2)
My run:
rows 8453 -> unique people 7912
seed 6803436325215821286
- 5218 hu*******@proton.me
- 2644 jo********@fastmail.com
- 3899 om*******@fastmail.com
- 7623 in*******@acme.io
- 5788 gr********@outlook.com
- 3998 qu**********@outlook.com
- 5236 ki*******@gmail.com
- 6944 ch********@proton.me
- 7197 hu*******@proton.me
- 6047 ro*******@proton.me
Two details worth copying. The dedupe runs before the draw, keyed on the lowercased email with the last registration winning, so someone who filled the form twice still gets one entry. And random.sample draws without replacement, unlike random.choices, which is pitfall 3.
Replaying six months later is short enough to paste into a dispute email:
import json, random
record = json.load(open("draw-record.json"))
pool = build_pool("registrants.csv") # same dedupe as above
print([w["email"] for w in random.Random(record["seed"]).sample(pool, 10)] == record["winners"])
Seeding also answers the "who verified this" question in a way that logging ten email addresses never does. If you would rather skip seeding, secrets.choice(pool) per winner is fine too, but keep the printed list as your evidence since there is no number to replay.
Method 2: Node 22.13.1 - crypto.randomInt with a partial Fisher-Yates
Same job with zero dependencies. crypto.randomInt(min, max) draws from the CSPRNG and handles rejection sampling internally, so unlike crypto.randomBytes(4).readUInt32BE() % n it does not skew the low end of the range.
// drawWinners.mjs - Node 22.13.1
import { readFileSync } from "node:fs";
import { randomInt } from "node:crypto";
const lines = readFileSync("registrants.csv", "utf8").trim().split("\n").slice(1);
const byEmail = new Map();
for (const line of lines) {
const [id, email] = line.split(",");
byEmail.set(email.trim().toLowerCase(), id); // last one wins
}
const pool = [...byEmail.entries()].sort((a, b) => Number(a[1]) - Number(b[1]));
function pick(pool, k) {
const remaining = pool.slice();
const out = [];
for (let i = 0; i < k; i++) {
const j = randomInt(i, remaining.length); // crypto-backed, no modulo bias
[remaining[i], remaining[j]] = [remaining[j], remaining[i]];
out.push(remaining[i]);
}
return out;
}
console.log(`rows: ${lines.length} unique: ${pool.length}`);
for (const [n, [email, id]] of pick(pool, 10).entries()) {
console.log(`${String(n + 1).padStart(2)}. ${id.padStart(5)} ${email}`);
}
On the same CSV:
rows: 8453 unique: 7912
- 1514 fa********@gmail.com
- 7382 sv*******@acme.io
- 7162 ki*******@gmail.com
- 7934 qu**********@outlook.com
- 3338 qu**********@outlook.com
- 1595 ta*******@fastmail.com
- 8364 an******@gmail.com
- 4519 dm*********@acme.io
- 4791 gr********@outlook.com
- 6326 gr********@outlook.com
Note that pick consumes only k values and then stops, so the cost stays flat whether the pool holds thousands of rows or millions.
What Node will not give you is a seedable, reproducible CSPRNG stream. Math.random() is seeded once per context and cannot be rewound, so replaying an old draw in JS means rolling your own PRNG on top of a randomInt-generated seed, or simply doing the draw elsewhere.
Method 3: when you just want ten numbers right now
Plenty of the time nobody needs a script. Meetup raffle with 200 tickets, splitting a workshop into four groups live on a call, seeding a few fixture values while writing tests. I open this browser-based random picker, set the range to 1 through 7912, switch on no-duplicates, ask for 10, and copy them into the sheet.
Everything runs client-side, so the registrant list never travels to a server. That matters more than it sounds: most giveaway exports contain emails you have no permission to push through somebody else's API.
Dedupe and sorting are checkboxes there rather than lines you can forget, which makes the third pitfall impossible on that path. It also covers the boring asks: seat numbers, ticket stubs, ten integers between 1 and 100 to pad a test fixture. For those I use the random number generator at utilvy and never open an editor.
Pitfalls I actually hit
1. The old results page leaked its seed in 10 ms
Before rewriting anything I read the script marketing had been using for previous draws. It was a MINSTD generator seeded from the clock:
let state = BigInt(Date.now());
const next = () => { state = (state * 48271n) % 2147483647n; // Lehmer / MINSTD return Number(state) / 2147483647; };
Once three consecutive outputs are visible anywhere, whether on a results page or inside sequential promo codes, the seed falls out by brute force over plausible millisecond values:
observed: [ 3285, 2106, 5622 ] recovered seed (epoch ms): 1793239041039 in 10.1 ms
Ten milliseconds, and every later number from that page was already determined when it was written. MDN puts it plainly: "Math.random() does not provide cryptographically secure random numbers." randomInt and secrets do.
2. Modulo bias is real, and usually far smaller than you fear
The classic shape:
idx = int.from_bytes(os.urandom(1), "big") % 100
A byte spans 0 to 255, which is not a multiple of 100, so 0 through 55 are reachable three ways and 56 through 99 two ways. Across 200,000 draws the low group totalled 131,253 against an expected 131,250, averaging 2,344 hits per slot against 1,562. The busiest slot (46) came up 2,461 times and the quietest (92) only 1,456. That gap is 1.5001x, precisely 3/2. S
Comments
No comments yet. Start the discussion.