Today I re-learned: Python function default arguments are retained between executions

A short story about Python function default arguments, and why testing even simple things is important.

This is the story of a tricky bug in a Python application I maintain that was pestering me.

So, the problem is this. Given the following Python code, I expect the method is_ongoing to return False when today is not included between self.start_date and self.end_date, and to return True when today is included between self.start_date and self.end_date. Simple as it is:

def is_ongoing(self, today = datetime.date.today()):
    return (today >= self.start_date) and (today <= self.end_date)

This method is used to respectively show or hide a piece of interface to users. But the code wasn't working as expected.

The comparison would produce False if the user happened to trigger is_ongoing in a day not included between self.start_date and self.end_date, and the function would continue to return False on each subsequent call. Weird!!

Tests say more than a thousand words ...

I couldn't understand why an apparent simple comparison between two dates was not working as expected, so having a bit of spare time I decided to put the code under test, since this is some code I inherited, and not everything there is covered by tests.

Here's the test I set up:

def test_lesson_status(self):
    self.lesson.start_date = datetime.date(2023, 1, 25)
    self.lesson.end_date = datetime.date(2023, 1, 27)

    with time_machine.travel(datetime.datetime(2023, 1, 18)):
        self.assertFalse(self.lesson.is_ongoing())

    with time_machine.travel(datetime.datetime(2023, 1, 27)):
        self.assertTrue(self.lesson.is_ongoing())

To test the passage of time I used the excellent time-machine by Adam Johnson, which by the way seems to be an order of magnitude faster than another competing library, freezegun.

Turns out, by running the test, I surprisingly discovered that the second assertion was always failing: a lesson starting 2023-1-25, ending 2023-1-27 is not ongoing if today is 2023-1-18, but it should be absolutely ongoing if "today" is 2023-1-27.

At this point I quickly understood that someway, somehow, Python was "remembering" the initial state of the default argument today. Here's the function again:

def is_ongoing(self, today = datetime.date.today()):
    return (today >= self.start_date) and (today <= self.end_date)

Writing JavaScript almost every day, I took for granted that function default arguments in Python were recomputed on each call like JavaScript engines do.

Turns out instead that Python’s default arguments are evaluated once when the function is defined, not each time the function is called. Also, relevant: Python Mutable Defaults Are The Source of All Evil.

I guess the Python interpreter creates a closure over the function, effectively retaining its scope.

The funny thing is that this code was written by Python developers more expert than me, nobody ever noticed this bug, and most important, it was an easy fix:

def is_ongoing(self):
    today = datetime.date.today()
    return (today >= self.start_date) and (today <= self.end_date)

Moving today inside the function fixed the thing: what I want is to recompute the date on every invocation, and this function wasn't used anywhere else, thus there is no need for a default argument.

Key takeaways

Today I re-learned that Python function default arguments are retained between executions. I'm sure I fell on this trick in the past, so better write a couple of words about it for my future self.

Know your tools: I kind of know most of the machinery behind JavaScript engines, and I try to stay updated about it. Understanding why and how the interpreter of your main language works is important as writing good code.

Test, test, test: I know this has been said a billion of times, but I cannot stress enough why testing even simple things is important. Sometimes we neglect to test simple implementations because they appear so trivial most of the time, but it's there that silly bugs lie.

Use a linter: fellow developers on Reddit kindly pointed out that flake8-bugbear can catch this type of "bugs". The bugbear rule is B008, which reports the following:

B008 Do not perform function calls in argument defaults.  The call is performed only once at function definition time. All calls to your function will reuse the result of that definition-time function call.  If this is intended, assign the function call to a module-level variable and use that variable as a default value.

Thanks for reading!

Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!