Writing

Random, but only once

Python evaluates a default argument once, when the function is defined. With a random number as the default, every job a process created landed on the same worker.

2 min readpython, debugging

I recently ran into a bug that took one line to write and two to fix. Here’s the shape of it:

jobs.py
import random
class Job:
def __init__(self, payload, hash=random.randint(0, 32768)):
self.payload = payload
self.hash = hash

Every job gets a random number, and the workers split the jobs between them with job.hash % worker_count. Random numbers spread evenly, so the work should too.

It doesn’t. Python evaluates default arguments once, when the def statement runs. For a method, that’s when the class body runs, which is when the module is imported. So random.randint was called exactly once per process, and every job that process created got the same “random” hash. All of them went to the same worker.

Why it looked fine

Nothing failed. There were several API processes, and every restart drew a new number, so across the whole system the jobs did end up on different workers. The randomness was there, just per process instead of per job. It only showed up as uneven load.

Here’s a quick simulation: three processes create 1,000 jobs each, for four workers.

before after
worker 0 1,000 763
worker 1 0 728
worker 2 0 755
worker 3 2,000 754

Before the fix, each process sent all of its jobs to one worker. In this run two of the three processes landed on the same one, so it got 2,000 jobs while two workers sat idle. With three processes and four workers, at least two of them share a worker five times out of eight.

The fix

Default to None, and draw the number inside the constructor with randrange1:

jobs.py
class Job:
def __init__(self, payload, hash=random.randint(0, 32768)):
def __init__(self, payload, hash=None):
self.payload = payload
self.hash = hash
self.hash = random.randrange(32768) if hash is None else hash

A job that already has a hash, passed in or loaded from the database, keeps it. The hash decides which worker owns the job, so it can’t change after a reload.

Collisions still happen. In the simulation, about fifteen jobs in a thousand shared a hash with another one, which is expected. What matters is that every job is now its own draw.

The regression tests check both sides without being flaky:

test_jobs.py
def test_each_job_draws_its_own_hash():
assert len({Job({}).hash for _ in range(100)}) > 1
def test_a_given_hash_is_kept():
assert Job({}, hash=42).hash == 42

Comparing just two jobs would fail once in 32,768 runs. A hundred jobs that all draw the same number won’t happen.

What I took from it

  • Anything that should be fresh on every call doesn’t belong in a default: random, uuid.uuid4(), datetime.now(). It’s the same cause as the well-known def f(items=[]) trap, only quieter, because nothing is shared or mutated.
  • In dataclasses and Pydantic models, use default_factory. field(default=random.randrange(32768)) has the same bug.
  • A linter would have caught it. Ruff’s rule B008 flags function calls in argument defaults, and it flags exactly this line.
  • A bug in how work is distributed doesn’t raise errors. If the spread matters, look at the spread.

Footnotes

  1. There was a small off-by-one next to it, too. randint includes both ends, so randint(0, 32768) can return 32,769 different values. randrange(32768) returns 0 to 32,767. ↩

jbndr.com

Johannes Binder

Software engineer

Karlsruhe, Germany

QR code that opens jbndr.com

Scan to open jbndr.comScan to add me to your contacts

Save contact