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.
I recently ran into a bug that took one line to write and two to fix. Here’s the shape of it:
import random
class Job: def __init__(self, payload, hash=random.randint(0, 32768)): self.payload = payload self.hash = hashEvery 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 afterworker 0 1,000 763worker 1 0 728worker 2 0 755worker 3 2,000 754Before 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:
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 hashA 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:
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 == 42Comparing 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-knowndef 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
-
There was a small off-by-one next to it, too.
randintincludes both ends, sorandint(0, 32768)can return 32,769 different values.randrange(32768)returns 0 to 32,767. ↩
