Python Function Default Argument Value

python
technical
exploration
Exploring an unexpected Python behavior that could cause your program to behave inconsistently
Author

Kevin Bird

Published

February 7, 2022

Introduction

This post is an exploration into when a python function gets the default argument from a function signature. Here is the scenario that got me to this point:

I had a chunk of code that had a filename defined.

from datetime import datetime
from time import sleep

def save_file_w_timestamp(filename=f'{datetime.now().isoformat()}/file.csv'):
    print(filename)
save_file_w_timestamp()
sleep(5)
save_file_w_timestamp()
2022-02-02T21:00:58.160733/file.csv
2022-02-02T21:00:58.160733/file.csv

My initial thought was that these two function calls would return the time that the function was called, but it doesn’t. That is what we will explore in this blog post.

Let’s explore how to make this work as expected.

If you’re in a hurry:

def save_file_w_None(filename=None):
    if filename is None: filename=f'{datetime.now().isoformat()}/file.csv'
    print(filename)

Failed Attempt #1: passing through a function

def get_current_datetime():
    return datetime.now().isoformat()

def save_file_w_timestamp(filename=f'{get_current_datetime()}/file.csv'):
    print(filename)
save_file_w_timestamp()
sleep(5)
save_file_w_timestamp()
2022-02-02T21:01:03.198774/file.csv
2022-02-02T21:01:03.198774/file.csv

Attempt #2: Brute force

def save_file_w_timestamp(filename=None):
    if filename is None: filename=f'{datetime.now().isoformat()}/file.csv'
    print(filename)
save_file_w_timestamp()
sleep(5)
save_file_w_timestamp()
2022-02-02T21:01:17.708874/file.csv
2022-02-02T21:01:22.712349/file.csv

Exciting Attempt #3: Mutable Madness

This third example surprised me a lot (thank you to miwojc on the fastai discord for bringing it to my attention!). If you have an empty list as your default value, it seems innocent enough. Naive Kevin from yesterday would have assumed that this code would create an empty list if x was not passed. Naive Kevin would be sadly mistaken. This is a really good example of what is actually happening above. This creates a variable x that starts as an empty list, but let’s see what happens when we call the function.

def mutable_madness(x=[]):
    x.append(1)
    print(x)
mutable_madness()
[1]

What do you think the value is going to be here?

#collapse_output
mutable_madness()
[1, 1]

How about if we pass an empty list in?

#collapse_output
mutable_madness([])
[1]

And what about now?

#collapse_output
mutable_madness()
[1, 1, 1]

I got all of these wrong when I was initially coding this so if it doesn’t seem intuitive to you, just know you aren’t alone. This is a fairly common gotcha that can lead to frustrating bugs. Here is another good blog post for further reading: https://docs.python-guide.org/writing/gotchas/.

Just to explore a few more ideas from this concept, I am going to add a few more examples below.

j=1
def immutable_nonmadness(y=j):
    #global j #This could be added to allow j to be used inside and outside the function.  
    y+=1
    print(y)

My initial thought with this example was that j would keep incrementing because we are setting j inside of our function. This is actually a good lesson about context which I won’t get into a ton except to mention that the j in line 1 and line 2 are the same j and the j in line 3 is a different j which is only accessible inside of the function. If we wanted this to behave similarly to the functions above which kept using the default value from above, the global argument would need to be added but this really is using a different concept to keep incrementing the value once we introduce global.

immutable_nonmadness()
2
#collapse_output
immutable_nonmadness()
2

In this example, because the value 1 is an immutable object, it doesn’t hold onto the previous value but if instead, we had put an empty list in j, it would act the same way as the examples from above. This is because a variable is neither mutable nor immutable. A variable is give its type and therefor its mutability based on the object it is storing.

j=[]
def function_1(y=j):
    y+=[1]
    print(y)
function_1()
[1]
function_1()
[1, 1]

This behavior will happen with lists, dicts, sets, and most custom classes.

def function_dict(value, x={}):
    x[value] = len(x)
    print(x)
function_dict('thing 0')
{'thing 0': 0}
function_dict('thing 1')
{'thing 0': 0, 'thing 1': 1}
def function_set(value, x=set()):
    x.add(value)
    print(x)
function_set('thing 0')
{'thing 0'}
function_set('thing 1')
{'thing 0', 'thing 1'}

I hope you learned something going through this blog post and if you take one thing away from this it is to be mindful when setting your default arguments.