02 Functions

Posted on Jun 15, 2026

In this module we will learn about how to reuse code by writing parts of our scripts as functions. We’ll spend a lot of time on the differences between defining a function and calling it and the associated concept’s of a function’s scope. In fact, we’ll go over it in over and over again to make sure we get it.

We’ll learn about how to set default values for some of the values we want to use in our functions and some additional annotation we can add to aid our future selves. By the end of the module we’ll be able to write useful functions that do things inside of our scripts according to modern Python style standards.

This will probably be the longest module as it introduced a huge number of concepts that can be pretty confusing. As always, we encourage experimentation and deviation from the guide to better understand how everything works.

Our first function

Functions allow us to easily reuse code. We’ve already used a couple of functions that come built-in to Python—print() and type(). There are many, many functions that come with Python we’ll see thoughout these guides and later we will use additional packages to get access to even more functions.

The best way to learn about functions is to make our own.

Lets create a new script called functions.py with the following content.

# functions.py
def greet(name):
    print(f"Hello {name}")

Let’s go through what all of this means. The first line is called a function signature which always starts with def. It tells Python the basic details of our function like its name and what arguments it takes.

Arguments are values that we pass into the function so that the function can do something with them. Our function takes a single argument that we’ve called name but we could call it anything we want and we can have as many as we want.

def is a special keyword that tells python that we’re defining a function. Much like with variables, we can give our function a memorable name for when we use it later. In this case the function’s name is greet.

Following the signature is the function body. A function’s body—like many soecific contexts in Python—needs to be indented relative to where we defined it. Indentation is how Python knows which lines of code belong to the function and which belong to the rest of the script.

We can indent our code with the tab key (a good editor will convert these tabs into spaces behind the scenes). All our function body does is use print to show a little greeting in our terminals, we’ll see much more complicated functions later on.

Calling our function

So far our script doesnt actually do anything. We’ve defined a function but we haven’t invoked or called it. We’ve told Python that we want to be able to use the function without actually using it. It’s like getting a hammer out of a tool box. We’re ready to use the tool but that doesn’t mean we’ve bonked in any nails yet. Let’s change that.

# functions.py
def greet(name):
    print(f"Hello {name}")


greet("DeepMay")
$ uv run function.py
Hello DeepMay

Now we’ve called our function and provided a value "DeepMay" for our argument name. How this works can be conceptually tricky so we’ll go through it in excruciating detail.

Broadly, when we call a function Python executes the code inside the function body using the arguments as variables. Because our function takes a single argument (called name) and we’ve provided a single value ("DeepMay") Python infers that inside the function name = "DeepMay". Then, Python starts executing the function body line by line the same as any other code. In the case of our function, it inserts name into the f-string and prints it.

To recap, inside of the function name = "DeepMay" but outside of the function name is not defined. We call the set of variables that exist inside of the function but not outside of it, the variable’s scope. Every time we call the function, the name name will be assigned whatever value we pass in—each call of the function will have its own scope with its own unique definitions.

Argument names, variable names, and scope

In the last module we talked about variables. These allowed us to store values with memorable names. Arguments are similar in that they let us connect values to names but only in the context of our functions. Thus far, when we’ve called our new functions we’ve passed literal values in but most of the time we’ll probably want to use variables instead. At times this can be confusing, but variable names and argument names are completely independent.

Let’s change our script again so that instead of passing in a literal value, we’re passing in variables.

# functions.py
def greet(name):
    print(f"Hello {name}")


my_name = "DeepMay"
greet(my_name)

# this line will fail
print(name)
$ uv run functions.py
Hello DeepMay
Traceback (most recent call last):
  File ".../functions.py", line 10, in <module>
    print(name)
          ^^^^
NameError: name 'name' is not defined

Ok, so now we’ve got names in two different places which can be kind of confusing! Let’s take a look at each of the lines we’ve changed so we can see what is different.

First, we added a line defining a variable called my_name to store the value "DeepMay". In the next line we passed this value into the function. That means inside the function name will be my_name (which is "DeepMay"). Note that it works exactly the same as if we had passed in the literal "DeepMay".

Once passed into the function, Python doesn’t care what the variable is called on the outside. Inside the function, as far as Python is concerned, my_name is just name.

In the final line we wrote some code that didn’t work. We tried to print name outside of the greet function and Python told us that “name name is not defined. That’s because name exists only within the function—inside the function’s scope.

If we wanted to, we could have called our variable name instead of my_name but usually we want to avoid naming our variables the same thing as an argument. The reason is very simple, its just confusing! If we had said name = "DeepMay" instead of calling it my_name than we would be allowed to print name in the final line which might make us think we’re printing the same name as the one inside the function. The name inside the function exists only inside the function. In this case, its equivalent to the name outside the function, but its not really the same.

Returning values

So far our function does stuff but doesn’t really interact with the rest of our code in a meaningful wau. Most of the time, we want a function to give us something back. Let’s write another function that does the calculations for our line equation from the variables module.

# functions.py
def greet(name):
    print(f"Hello {name}")


def line_equation(x, slope, intercept):
    return slope * x + intercept


# same x value as our sctipt
y = line_equation(7.8, 2, 4.0)
print(f"Using the same x value as in our script our y value is {y}")
$ uv run functions.py
Using the same x value as in our script our y value is 19.6

First let’s look at our greet(name) function. Notice that we don’t see a greeting in the output even though we still have the function definition in our script. That’s because we haven’t actually called it. Remember that we need to call our function in order for it to do anything.

Ok, now let’s look at the signature of our new function. We’ve called it line_equation and it takes three arguments x, slope, and intercept. Notice that the order here is a little different than when we made a script dedicated to our line equation. Its good practice to put variables that are likely to be different for different calls to our function earlier in the order. With a line we often want to know how $y$ changes with different values for $x$ for a given slope and intercept so we put x first in the order of our arguments.

Finally, in our function body we see another Python keyword, return. This keyword tells Python that we want our function to give us back a value specified by the expression that follows return. When we call the function, Python executes the code in the body and in this case evaluates slope * x + intercept to some value. The function line_equation is equivalent to `slope * x + intercept. We’ve turned our line equation into a portable, easily reusable tool!

In fact, we’ve already used functions both with and without return values. print doesn’t return anything, we’ll never use a statement like my_val = print("hello world") because print doesn’t evaluate to a value. We can the effects of functions that are not part of their return value side effects. The side effect of print is to put some text in our terminals.

On the other hand type does return a value. When we have used statements like print(type(my_val)) we are really condensing two steps into one. It works from the inside out, we call type on my_val which evaluates to a value (something like <class 'float'> in this case) and then we pass that value into print!

Default values

When we define a function, we can include default values for arguments that will be used if no value is provided in the function call. Let’s add some default values to our line_equation function so that we don’t have to keep reentering the slope and intercept values every time we call the function.

# functions.py
def greet(name):
    print(f"Hello {name}")


def line_equation(x, slope = 2, intercept = 4.0):
    return slope * x + intercept

# using the default values
y1 = line_equation(7.8)
print(f"We can count on default values to keep our y value as {y1}")

# we can also override the default value if we want to by providing a value
y2 = line_equation(7.8, intercept=12.0)
print(f"changing only one of the defailts gives us {y2}")
$ uv run functions.py
Hello DeepMay
We can count on default values to keep our y value as 19.6
Changing only one of the defaults gives us 27.6

We add default values with = by modifying the function signature. When we call the function, it will check for the positions (or names) of the arguments as usual. If Python doesn’t find one or more of the expected arguments, it will check for default values. This can save us a lot of of headache if we’re making (or just calling) complicated fuctions with lots of arguments.

Type hints

Theres just one last thing to cover and this feature doesn’t even do anything! Python for the most part doesn’t care about variable types very much. We, on the other hand, might.

One of the main qualities that distinguishes good code from bad is readability. If we look back on code we’ve previously written we ought to be able to understand it without too much trouble. Python has a feature called type hints that can help us write readable code. These are little annotations we add to function signatures that label what type of thing we expect each argument to be. Here’s what our functions.py script looks like with type hints.

# functions.py
def greet(name: str):
    print(f"Hello {name}")


def line_equation(x: float,
                  slope: int = 2,
                  intercept: float = 4.0) -> float:
    return slope * x + intercept

# using the default values
y1 = line_equation(7.8)
print(f"We can count on default values to keep our y value as {y1}")

First of all, notice that our output didn’t change at all. It is not a joke that type annotations don’t do anything, Python literally ignores them when executing our scripts.

Ok, let’s get into it. Let’s Look at the signature for the greet function. We’ve changed name to name: str. All this says is that we expect name to be a string. This <argument>: <type> syntax is how we write type hints for arguments. Python would be perfectly happy to let us put in something like greet(7) but when we’re looking back over our code, type hints allow us to see that name is really meant to be a string.

The signature for line_equation is not split across multiple lines. We can basically always split up lines in python if the split comes between parentheses as it does in our function signature. Basically python sees that we have ( without a closing ) on the same line and will go ahead and treat all of the stuff between the two as a single line.

More interestingly, in the signature for line_equation we’ve added type hints. Notice also in the signature for line_equation that it now ends with -> float: rather than simply :. This just reminds us that the value coming out of line_equation from the return keyword is a float. For return values, the syntax for type hints is -> <type>. Specifying the type of a return value is nice because we know at a glance that if we call line_equation we will get some float value back.

Summary

Functions allow us to reuse code very easily, changing some parts of the values each time its reused (e.g. by setting x, slope or intercept) while leaving the overall logic the same return slope * x + intercept. We spent a lof of time on arguments and scope—the conceptual set of variables that “belong” to a function. We’ve seen many syntactical variations some of which matter to Python (like default values), and some are just for us like type hints. We will use functions constantly—most of which will have return values—and often write our own as well.