Python functions — theory with the café till, print versus return and the call stack (and the function that showed the total but never handed it over)

In my first FP1 lab with functions I had to program the till of the university café. I wrote a function for the order total, tried it with a coffee and a sandwich, and the screen said Total: 3.7. Perfect, I thought. I added the student card discount… and the program blew up on the very next line:

# The café till: a function that "calculates" the order total
def order_total(coffee, sandwich):
    print("Total:", coffee + sandwich)   # it SHOWS it on screen... but doesn't return it


total = order_total(1.20, 2.50)
with_card = total * 0.90                 # the student card takes off 10 %
print("With card:", with_card)

What came out:

Total: 3.7
Traceback (most recent call last):
  File "hook.py", line 7, in <module>
    with_card = total * 0.90                 # the student card takes off 10 %
                ~~~~~~^~~~~~
TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'

NoneType? But the 3.7 was right there on the screen! That is exactly the misunderstanding that costs the most in this topic: showing a value is not the same as returning it. The function printed the total, but the variable total received None, and None * 0.90 can’t be calculated. By the end of this article you’ll understand it so well that you’ll see it coming before you run anything.

Today you learn to write Python functions: what they are and why they exist, how to define them with def, the difference between parameters and arguments (by position, by name and with default values), what return really does and what happens when you leave it out, how functions call each other (the call stack) and where each variable lives (local and global scope). You’re coming from Python loops, and we’re finally keeping a promise from its exercises: the two-loop search that a single return fixes. When you finish, the Python functions practice has three complete programs waiting.

What are Python functions?

A function is a named block of code that does one specific task. You write it once and it runs every time you call it by its name, from wherever you want, as many times as you want.

You’ve actually been using functions since day one: print(), len(), round(), int() and range() are functions. Someone wrote them, named them, and you just call them. The difference is that from today you’ll create your own.

Why use functions?

Look at the café till serving three orders. Without functions, the same change calculation is copied three times:

# Three orders at the till WITHOUT functions: the same sum, copied three times
payment1 = 5.00
total1 = 1.20 + 2.50                    # coffee + sandwich
print("Order 1 · change:", round(payment1 - total1, 2))

payment2 = 10.00
total2 = 1.20 + 1.20 + 3.10             # two coffees + salad
print("Order 2 · change:", round(payment2 - total2, 2))

payment3 = 2.00
total3 = 1.50                           # a juice
print("Order 3 · change:", round(payment3 - total3, 2))

Output:

Order 1 · change: 1.3
Order 2 · change: 4.5
Order 3 · change: 0.5

It works, but imagine that tomorrow you want to round the change differently, or warn when the customer pays too little. You’d have to change three lines (or thirty, as the program grows) and remember all of them. Now with a function:

# The same three orders WITH a function: the sum is written only once
def charge(total, payment):
    """Returns the change to give back, rounded to cents."""
    return round(payment - total, 2)


print("Order 1 · change:", charge(1.20 + 2.50, 5.00))
print("Order 2 · change:", charge(1.20 + 1.20 + 3.10, 10.00))
print("Order 3 · change:", charge(1.50, 2.00))

Output:

Order 1 · change: 1.3
Order 2 · change: 4.5
Order 3 · change: 0.5

The output is identical, but the calculation lives in one single place: charge. If the change rule changes, you change it there and all three orders find out on their own. This is the DRY principle (Don’t Repeat Yourself), and functions are the tool that makes it possible.

A practical rule for FP1: if you catch yourself copying and pasting the same piece of code a second time, stop and turn it into a function.

Besides avoiding repetition, functions make code more readable (charge(3.70, 5.00) says what it does better than a loose calculation), easier to test (you test the function on its own, with data you choose) and easier to debug (if the change comes out wrong, you know which function to look in).

How to define a function

A function has two parts: the header and the body.

def greet_customer():
    """Greets whoever comes to the till."""     # docstring: what the function does
    print("Good morning! What can I get you?")


print("1. Before calling the function")
greet_customer()                 # call: Python jumps into the body of the function...
print("2. After calling it")     # ...and when it finishes it comes back right here
greet_customer()                 # you can call it as many times as you like
print(greet_customer.__doc__)    # the docstring can be read too
  • The header starts with the keyword def, followed by the function name (in snake_case, like variables), the brackets (required even when empty) and ends with a colon :.
  • The body goes underneath, indented 4 spaces. Everything with that indentation belongs to the function.
  • The first line of the body is usually a docstring: text in triple quotes that explains what the function does. It doesn’t change what the function does, but editors show it as help when you type the name, and it can be read with __doc__.

Output:

1. Before calling the function
Good morning! What can I get you?
2. After calling it
Good morning! What can I get you?
Greets whoever comes to the till.

Look at the order. When Python runs def, it doesn’t run the body: it only notes “there is a function called greet_customer“. The body runs when you call it with greet_customer(). At that moment the program jumps into the function, runs its body and, when it finishes, comes back right after the call. That’s why the greeting appears between line 1 and line 2.

Writing greet_customer without brackets. There’s no error, but the function isn’t called either: you’re just naming the function object and nothing happens. To call it you need the brackets, even if they’re empty.

The diagram sums up the parts of a function and what happens in its call. At the top is the definition of charge; at the bottom, the call that hands out the values to the parameters and collects what return sends back:

Anatomy of a function and its callAt the top, the definition of charge(total, payment) with its header, docstring and body with return. Below, the call change = charge(3.70, 5.00): 3.70 goes into total, 5.00 into payment and return sends back 1.3, which is stored in change.The definitiondef charge(total, payment): """Returns the change.""" return round(payment – total, 2)header: name and parametersdocstring: what it doesindented body + returnThe callchange = charge(3.70, 5.00)total = 3.70payment = 5.00arguments → parametersreturn 1.3 → change is 1.3
The call's arguments fill the parameters in order; return brings the result back to where the function was called.

Type hints in the header

In the FP1 notes you’ll see headers like this one, with type annotations (type hints): after each parameter, a colon and the expected type; and before the final colon, an arrow -> with the type of what it returns.

def price_with_card(price: float, discount: float = 0.10) -> float:
    """Price after the student card discount (10 % unless another is given)."""
    return round(price * (1 - discount), 2)


print(price_with_card(2.50))        # 2.50 minus 10 %
print(price_with_card(2.50, 0.20))  # with 20 %
print(price_with_card("2.50"))      # type hints are NOT checked when running

Output:

2.25
2.0
Traceback (most recent call last):
  File "annotations.py", line 8, in <module>
    print(price_with_card("2.50"))      # type hints are NOT checked when running
          ~~~~~~~~~~~~~~~^^^^^^^^
  File "annotations.py", line 3, in price_with_card
    return round(price * (1 - discount), 2)
                 ~~~~~~^~~~~~~~~~~~~~~~
TypeError: can't multiply sequence by non-int of type 'float'

The first two calls work: 2.50 − 10 % = 2.25 and 2.50 − 20 % = 2.0. The third is the lesson: Python doesn’t check annotations when it runs. They’re information for whoever reads the code (and for tools that analyse code before running it), not a protection. We passed the text "2.50" and Python tried to multiply a text by a decimal, which is the TypeError you see.

Notice the traceback, too: it has two File blocks. The first is the line of the call (8) and the second, the line inside the function that failed (3). We’ll come back to this with the call stack.

Functions with parameters

Two words that get used as if they were the same thing, but aren’t:

  • Parameter (or formal parameter): the variable you write in the definition, inside the brackets. It’s a named slot waiting for a value.
  • Argument (or actual parameter): the value you pass in the call. It’s what fills the slot in that particular call.
def menu_price(sandwich, drink):
    """sandwich and drink are PARAMETERS: slots filled in on each call."""
    return round(sandwich + drink, 2)


# 2.50 and 1.50 are ARGUMENTS: the actual values of this call
print(menu_price(2.50, 1.50))                  # by position: 1st goes to 1st
print(menu_price(drink=1.50, sandwich=3.20))   # by name: the order doesn't matter
sandwich = 9.99                                # an outer variable with the same name...
print(menu_price(2.50, 1.20))                  # ...has nothing to do with the parameter
print(sandwich)                                # still 9.99

Output:

4.0
4.7
3.7
9.99

Line by line:

CallHow the values are handed outResult
menu_price(2.50, 1.50)by position: sandwich = 2.50, drink = 1.502.50 + 1.50 = 4.0
menu_price(drink=1.50, sandwich=3.20)by name: the order doesn’t matter3.20 + 1.50 = 4.7
menu_price(2.50, 1.20)by position2.50 + 1.20 = 3.7

The last line is the interesting one: before the third call we create, outside, a variable also called sandwich holding 9.99. The function neither sees it nor touches it: its parameter sandwich is a different, internal variable that is 2.50 in that call. That’s why print(sandwich) still gives 9.99. Sharing a name is pure coincidence.

In Python, parameters work as local variables of the function: they’re created when it’s called, with the value of the argument, and they disappear when it ends. We’ll look at it properly in the scope section.

How many and in what order

If the function has two parameters without default values, you must give it two arguments. If one is missing, Python doesn’t guess:

def menu_price(sandwich, drink):
    return round(sandwich + drink, 2)


print(menu_price(2.50))     # the second argument is missing
Traceback (most recent call last):
  File "missing_argument.py", line 5, in <module>
    print(menu_price(2.50))     # the second argument is missing
          ~~~~~~~~~~^^^^^^
TypeError: menu_price() missing 1 required positional argument: 'drink'

The message is one of the clearest you’ll ever see: missing 1 required positional argument: 'drink'.

Trickier is passing the right number of arguments in the wrong order, because there’s no error at all:

def charge(total, payment):
    return round(payment - total, 2)


print(charge(3.70, 5.00))                  # right: first the total, then what they pay
print(charge(5.00, 3.70))                  # the other way round: negative change and no error!
print(charge(payment=5.00, total=3.70))    # by name there is no possible confusion

Output:

1.3
-1.3
1.3

charge(5.00, 3.70) puts 5.00 in total and 3.70 in payment, so it calculates 3.70 − 5.00 = −1.3. For Python it’s a perfectly valid subtraction; for the till, a disaster. When a function has several parameters of the same type that are easy to mix up, passing them by name (payment=5.00, total=3.70) removes the problem.

In a call, keyword arguments must go after positional ones: charge(3.70, payment=5.00) is fine; charge(total=3.70, 5.00) is a SyntaxError.

Default parameters

A parameter can have a default value: if the call doesn’t give it a value, it uses that one. That makes the parameter optional.

def charge(total, payment, tip=0):
    """Returns the change; the tip is optional (0 if not given)."""
    return round(payment - total - tip, 2)


print(charge(3.70, 5.00))           # tip = 0, the default value
print(charge(3.70, 5.00, 0.30))     # tip = 0.30, by position
print(charge(3.70, 5.00, tip=1))    # tip = 1, by name (reads better)

Output:

1.3
1.0
0.3

Checking the sums: 5.00 − 3.70 − 0 = 1.3; 5.00 − 3.70 − 0.30 = 1.0; 5.00 − 3.70 − 1 = 0.3. When no tip is given, it’s 0 and it’s as if it didn’t exist.

The golden rule: parameters with default values go at the end. If you put an optional one before a required one, Python doesn’t even start running:

# A parameter with a default value BEFORE a required one
def charge(tip=0, total, payment):
    return round(payment - total - tip, 2)
  File "wrong_order.py", line 2
    def charge(tip=0, total, payment):
                      ^^^^^
SyntaxError: parameter without a default follows parameter with a default

The message says it literally: a parameter without a default follows a parameter with a default. Why the rule? Because in charge(3.70, 5.00) Python couldn’t know whether 3.70 is the tip or the total.

Using an empty list or dictionary as a default value (def record(order, items=[])). The default value is created only once, when the function is defined, and every call shares the same list. You’ll see it in detail when we reach lists; for now, use simple types for default values: numbers, text, True/False or None.

Functions with return

return does two things at once:

  1. It sends a value back to whoever called the function. That value replaces the call, as if you had written the number in its place.
  2. It ends the function right there. Whatever comes after return in that same branch never runs.
def price_with_card(price):
    """Returns the price with the 10 % student card discount."""
    return round(price * 0.90, 2)
    print("This line never runs")      # dead code: return has already left


coffee = price_with_card(1.20)                      # the returned value is stored...
print(coffee)
print(round(price_with_card(2.50) + coffee, 2))     # ...used in an expression...
print(price_with_card(price_with_card(10.00)))      # ...or in another call

Output:

1.08
3.33
8.1

Sums: 1.20 × 0.90 = 1.08; 2.50 × 0.90 = 2.25 and 2.25 + 1.08 = 3.33; 10.00 × 0.90 = 9.0 and 9.0 × 0.90 = 8.1. The print("This line never runs") keeps its word: it doesn’t appear in the output because it comes after return. Editors usually grey it out (dead code).

Look at the three ways of using what a function returns: storing it in a variable, using it inside an expression and passing it as an argument to another call (price_with_card(price_with_card(10.00)) applies the card twice). All three are possible only because there’s a return.

Several returns and several values

A function can have several return statements in different places; it leaves through the first one it reaches. And a return can send back several values separated by commas:

def order_type(total):
    """Classifies the order by what it costs: each branch has its own return."""
    if total < 2:
        return "small order"
    if total < 6:
        return "regular order"       # if it gets here, the line below is never checked
    return "large order"


def change_in_coins(change):
    """Returns TWO values at once: the whole euros and the cents."""
    cents_total = round(change * 100)
    return cents_total // 100, cents_total % 100


print(order_type(1.50), "/", order_type(3.70), "/", order_type(7.80))
euros, cents = change_in_coins(6.30)     # both values, each one into its variable
print(euros, "euros and", cents, "cents")

Output:

small order / regular order / large order
6 euros and 30 cents

In order_type(3.70), the first condition (3.70 < 2) is false, the second (3.70 < 6) is true and the function leaves with "regular order": the third line is never even checked. You don’t need elif, because each return already cuts the function short.

change_in_coins(6.30) turns the change into cents (630, with round to avoid decimal surprises) and returns 630 // 100 = 6 and 630 % 100 = 30. Python packs both values into a tuple (6, 30), and euros, cents = … unpacks it, each value into its own variable. You’ll study tuples calmly in their own topic; for now, remember that a function can return several things at once.

What happens without return?

Every Python function returns something. If you don’t write return (or write return with nothing after it), it returns None, the value that means “nothing”. Here’s the opening hook, taken apart:

def total_with_print(coffee, sandwich):
    print(coffee + sandwich)        # SHOWS the number on screen


def total_with_return(coffee, sandwich):
    return coffee + sandwich        # RETURNS the number to whoever called it


a = total_with_print(1.20, 2.50)    # 3.7 appears on screen...
b = total_with_return(1.20, 2.50)   # ...and nothing appears here
print("a is", a)
print("b is", b)
print(type(a), type(b))

Output:

3.7
a is None
b is 3.7
<class 'NoneType'> <class 'float'>

Both functions “calculate” 3.7, but only one hands it over:

  • total_with_print writes 3.7 on the screen (the first line of the output) and, since it has no return, returns None. The screen is a dead end: what goes there, you see, but the program can’t use it any more.
  • total_with_return writes nothing, but returns 3.7 to its caller, which stores it in b.
print versus returnTwo functions add 1.20 and 2.50. The one using print shows 3.7 on screen but returns None, so a is None. The one using return shows nothing, but returns 3.7, so b is 3.7.the callwhat you see on screenwhat comes backtotal_with_print(1.20, 2.50)3.7a = Noneprint showstotal_with_return(1.20, 2.50)(nothing)b = 3.7return hands overOnly what return hands over can be stored, added up or passed to another function.
Both «calculate» 3.7, but only the return one gives it to you: the print one shows it and returns None.

Look at the diagram: the dark column is what you see on screen and the right one, what comes back to the variable. The hook failed because total received the right-hand thing (None), even though 3.7 was on the screen.

Trusting what you see on screen to know what a function returns. If you’re going to use the result somewhere else (store it, add it, compare it, pass it to another function), the function needs return. print is only for showing.

A test question that goes around a lot is “which of these functions returns the number 10?”. Let’s answer it by running them:

def option_a():
    10                    # calculates 10... and throws it away


def option_b():
    print(10)             # shows it, but returns None


def option_c():
    return 10             # returns it


print("a:", option_a())
print("b:", option_b())
print("c:", option_c())

Output:

a: None
10
b: None
c: 10
  • option_a calculates 10 on a loose line and throws it away: it returns None.
  • option_b prints it (that’s why a loose 10 appears before b: None: Python runs the call to find out what to print, and the call does its print at that moment), but returns None.
  • option_c is the only one with return 10.

That test often has a fourth option, def my_function(10): return, which can’t even be defined: a parameter has to be a name, not a number, and Python raises SyntaxError before running anything.

Functions calling other functions

This is where it gets good. A function can call another, and that one another. Each does a small thing well, and together they solve the big problem. At the café till:

def price_with_card(price):
    """Level 3: applies the 10 % discount to a price."""
    return round(price * 0.90, 2)


def order_total(coffee, sandwich, with_card):
    """Level 2: adds up the order; with the card, discounts each item."""
    if with_card:
        coffee = price_with_card(coffee)        # calls level 3...
        sandwich = price_with_card(sandwich)    # ...twice
    return round(coffee + sandwich, 2)


def charge_order(coffee, sandwich, with_card, payment):
    """Level 1: works out the total and the change and prepares the message."""
    total = order_total(coffee, sandwich, with_card)    # calls level 2
    change = round(payment - total, 2)
    return f"Total {total} € · change {change} €"


print(charge_order(1.20, 2.50, False, 5.00))
print(charge_order(1.20, 2.50, True, 5.00))

Output:

Total 3.7 € · change 1.3 €
Total 3.33 € · change 1.67 €

Let’s follow the second call, charge_order(1.20, 2.50, True, 5.00):

StepWho’s workingWhat it doesReturns
1charge_ordercalls order_total(1.20, 2.50, True) and waits—
2order_totalsince with_card is True, calls price_with_card(1.20)—
3price_with_card1.20 × 0.901.08
4order_totalcalls price_with_card(2.50)—
5price_with_card2.50 × 0.902.25
6order_total1.08 + 2.253.33
7charge_order5.00 − 3.33 and builds the text"Total 3.33 € · change 1.67 €"

Each function waits for the one it called to finish and send back its result; then it carries on where it was. Python keeps track of “who’s waiting for whom” in a structure called the call stack: each call puts a box on top of the previous one, with its own variables, and removes it when it finishes.

Inside order_total we reassign the parameters (coffee = price_with_card(coffee)). That’s fine: they’re local variables of that call. The 1.20 outside doesn’t change.

The call stack when something fails

Where do you see the stack? In tracebacks. Look at what happens when table 3 asks for the bill without saying how many they are:

def share_per_person(total, people):
    """Level 3: splits the total between the people at the table."""
    return round(total / people, 2)


def table_total(coffees, sandwiches):
    """Level 2: adds up what the table ordered."""
    return round(coffees * 1.20 + sandwiches * 2.50, 2)


def table_bill(coffees, sandwiches, people):
    """Level 1: the table total and what each person pays."""
    total = table_total(coffees, sandwiches)
    return share_per_person(total, people)


print(table_bill(3, 2, 3))    # three friends
print(table_bill(2, 1, 0))    # table 3 didn't say how many they were

Output:

2.87
Traceback (most recent call last):
  File "pila_error.py", line 18, in <module>
    print(table_bill(2, 1, 0))    # table 3 didn't say how many they were
          ~~~~~~~~~~^^^^^^^^^
  File "pila_error.py", line 14, in table_bill
    return share_per_person(total, people)
  File "pila_error.py", line 3, in share_per_person
    return round(total / people, 2)
                 ~~~~~~^~~~~~~~
ZeroDivisionError: float division by zero

The first call works: 3 × 1.20 + 2 × 2.50 = 3.60 + 5.00 = 8.60, and 8.60 / 3 = 2.866…, rounded to 2.87. The second divides by zero deep down, and the traceback shows you the whole stack at that moment:

The call stack of pila_error.pyThree stacked boxes: at the bottom the main program, above it table_bill with its local variables and at the top share_per_person, where total / people divides by zero. On the right, the traceback lists them from the outside in and ends with ZeroDivisionError.The call stack at the moment of the errorWhat the traceback printsshare_per_persontotal = 4.9 · people = 0table_billcoffees = 2 · sandwiches = 1 · people = 0 · total = 4.9<module> (main program)print(table_bill(2, 1, 0))  total / people → division by zerotable_total already finished (returned 4.9): not listed3 · line 3, in share_per_person2 · line 14, in table_bill1 · line 18, in <module>ZeroDivisionError ← read it first
Each call puts a box (with its local variables) on top of the previous one; the traceback goes through them from the bottom of the stack up, and its last line says what failed.

How to read it, bottom up:

  1. The last line says what happened: ZeroDivisionError: float division by zero.
  2. The last File says where it blew up: line 3, inside share_per_person.
  3. The File lines above it say how you got there: from line 14 (in table_bill), which in turn came from line 18 (the main program, <module>).

table_total doesn’t appear because it had already finished (it returned 4.9) when the error came: the traceback only shows the functions that were waiting. We develop this way of reading tracebacks in Python debugging, and it’ll save you hours.

The error is usually in the function at the very bottom, but the cause may be higher up. Here share_per_person does its job fine; whoever passed it a 0 was the call on line 18.

The tray search, now with return

In the loops exercises we looked for the first chocolate croissant in the display trays and hit a problem: break only leaves the inner loop, and we needed a flag (found = True) to leave the outer one too. We promised that functions would bring a cleaner way. Here it is:

# The search from the loops exercises, now inside a function
def find_chocolate(trays):
    """Returns (tray, slot) of the FIRST chocolate croissant, or None if there is none."""
    num = 0
    for tray in trays:
        num += 1
        slot = 0
        for piece in tray:
            slot += 1
            if piece == "C":
                return num, slot    # leaves BOTH loops (and the function) at once
    return None                     # you only get here if there is none


print(find_chocolate(["NNC", "CNC", "NNN"]))
print(find_chocolate(["NNN", "NNN"]))
b, h = find_chocolate(["NNN", "NCN"])
print("Chocolate in tray", b, "slot", h)

Output:

(1, 3)
None
Chocolate in tray 2 slot 2

return num, slot doesn’t leave a loop: it leaves the function, and with it every loop inside, all at once. No flag and no break. With ["NNC", "CNC", "NNN"] the first chocolate is in tray 1, slot 3, and the function doesn’t even look at tray 2 (which also has chocolate). If it goes through everything without finding anything, it reaches the return None at the end.

PassnumpieceslotIs it "C"?
11"N"1no
21"N"2no
31"C"3yes → return 1, 3

Putting the return None inside the loop (for example, in an else). Then the function gives up on the first piece that isn’t chocolate. The exercises of this topic have a “What’s wrong here?” with exactly that bug.

Scope: variables inside functions

The scope of a variable is the part of the program where it exists. Two matter in FP1:

  • Local: variables created inside a function (including its parameters). They’re born when it’s called and disappear when it ends. Each call has its own.
  • Global: variables created outside any function, in the main program. They exist from when they’re created until the program ends.
def charge_coffee():
    price = 1.20              # LOCAL variable: born when the function is called...
    print("Inside:", price)


charge_coffee()               # ...and disappears when the function ends
print("Outside:", price)

Output:

Inside: 1.2
Traceback (most recent call last):
  File "local.py", line 7, in <module>
    print("Outside:", price)
                      ^^^^^
NameError: name 'price' is not defined

Inside the function, price exists and is 1.2. Outside, Python can’t find it: it was deleted when charge_coffee() ended. Hence the NameError.

The other way round does work: from inside a function you can read a global variable.

COFFEE_PRICE = 1.20        # GLOBAL constant (in capitals): it can be READ from inside


def coffees_price(amount):
    return round(amount * COFFEE_PRICE, 2)


print(coffees_price(3))
3.6

3 × 1.20 = 3.6. That’s normal with constants: values that don’t change, written in capitals (COFFEE_PRICE, VAT) by convention, so it’s obvious at a glance that nobody touches them.

The error that confuses everybody: UnboundLocalError

Reading a global from inside works. But what if you try to change it?

coffees_sold = 0                      # global counter of the till


def sell_coffee():
    coffees_sold = coffees_sold + 1   # by ASSIGNING it, Python decides it is local...


sell_coffee()                         # ...and tries to read it before it has a value

Output:

Traceback (most recent call last):
  File "unbound.py", line 8, in <module>
    sell_coffee()                             # ...and tries to read it before it has a value
    ~~~~~~~~~~~^^
  File "unbound.py", line 5, in sell_coffee
    coffees_sold = coffees_sold + 1   # by ASSIGNING it, Python decides it is local...
                   ^^^^^^^^^^^^
UnboundLocalError: cannot access local variable 'coffees_sold' where it is not associated with a value

Python’s rule is this: if a function assigns a variable (=, +=…) anywhere in its body, that variable is local in the whole function. Since sell_coffee has a coffees_sold = …, Python decides coffees_sold is local; and when it calculates coffees_sold + 1 it tries to read that local, which has no value yet. Hence the message: it cannot access the local variable coffees_sold because it isn’t associated with a value.

There are two fixes, and one is much better than the other:

def sell_coffee(coffees_sold):
    """Takes the counter and RETURNS the new value, without touching anything outside."""
    return coffees_sold + 1


coffees = 0
coffees = sell_coffee(coffees)      # what return sends back is stored in the variable
coffees = sell_coffee(coffees)
print("Coffees sold:", coffees)

# The other way out, the global keyword (it works, but use it sparingly)
counter = 0


def sell_with_global():
    global counter                  # "counter" is the outer one, not a new local
    counter += 1


sell_with_global()
sell_with_global()
print("With global:", counter)

Output:

Coffees sold: 2
With global: 2
  • The good one: the function receives the value as a parameter and returns the new one with return. The caller decides where to store it (coffees = sell_coffee(coffees)). Everything that goes into and out of the function is visible in the call.
  • The one that exists: the global keyword tells Python “this variable is the outer one”. It works, but any function can change the variable without you seeing it in the call, and when something goes wrong you don’t know who touched it.

In through parameters, out through return. Follow that rule and you’ll hardly ever need global, and your functions can be tested on their own, with whatever data you want.

When do I use what?

I want to…I use…Example
Reuse a calculationa function with returncharge(total, payment)
Make a value optionala default parameter, at the endtip=0
Avoid mixing up argumentskeyword argumentscharge(payment=5.00, total=3.70)
Show something to the userprint (inside a presentation function)show_ticket(...)
Use the result somewhere elsereturn, never just printtotal = order_total(...)
Return two valuesreturn a, b and unpackeuros, cents = change_in_coins(6.30)
Leave several loops at onceput the search in a function and returnfind_chocolate(trays)
Change an outer valuereceive it, return the new one and reassigncoffees = sell_coffee(coffees)
A fixed value for the whole programa global constant in capitals (read only)COFFEE_PRICE = 1.20

The most common mistakes with functions

Using print where return was needed. Symptom: a TypeError with 'NoneType' in the message, or a variable that is None “for no reason”.

Forgetting the brackets when calling (greet_customer instead of greet_customer()): no error, but the function doesn’t run.

Calling the function before defining it. Python reads from top to bottom: if the call is on line 2 and the def on line 10, you get a NameError. Define first, call later.

Arguments in the wrong order. No error, and the result comes out wrong (the negative change). Use names when in doubt.

Trying to change a global variable without declaring it (UnboundLocalError). Better to receive it and return it.

A return inside a loop that leaves too early (usually an else: return False). The “not found” goes after the loop.

Visualise it with Python Tutor

Python Tutor runs your code line by line and draws the variables. Functions are where it shines most, because it draws each call as a box (a frame) with its local variables. Choose Python 3, paste this program and step through with Next:

COFFEE_PRICE = 1.20


def price_with_card(price):
    discount = price * 0.10
    return round(price - discount, 2)


def order_total(coffees, with_card):
    total = coffees * COFFEE_PRICE
    if with_card:
        total = price_with_card(total)
    return round(total, 2)


def notify(total):
    print("To pay:", total)


t = order_total(3, True)
r = notify(t)
print(t, r)

What should come out at the end:

To pay: 3.24
3.24 None

What to watch step by step:

  1. At first, Python goes through the three def lines without entering them: the Global frame shows COFFEE_PRICE and three “function” entries, but no new boxes. Defining isn’t running.
  2. On the call order_total(3, True) a new box appears with coffees = 3 and with_card = True. When total is calculated, it’s 3.5999999999999996: that’s the decimal imprecision you saw in variables and data types, and the final round fixes it.
  3. Inside, the call to price_with_card stacks another box on top, with its own price and discount. That’s the call stack, live. When it reaches its return, you’ll see a Return value of 3.24 and the box disappears.
  4. On r = notify(t) look at the Return value of notify: it’s None. The console shows To pay: 3.24, but r receives None, the same effect as the opening hook.
  5. Notice that discount and total never appear in the Global frame: they’re local and die with their box.

Quick summary

ConceptThe essentials
def name(parameters):defines the function; doesn’t run it
name(arguments)calls it: jumps into the body and comes back after the call
Parameter / argumentslot in the definition / value in the call
By position / by namef(2.50, 1.50) / f(drink=1.50, sandwich=2.50)
Default valuedef f(a, b=0): optional ones at the end
return valuesends the value back and ends the function
No returnthe function returns None
print versus returnshowing versus handing over
return a, breturns several values (a tuple)
Call stackeach call stacks its box; the traceback shows it
Local / globalinside the function / outside; locals die when it ends
UnboundLocalErroryou assigned an outer variable inside: receive it and return it

Three ideas to take away:

  1. print shows, return hands over. If the result will be used somewhere else, you need return.
  2. In through parameters, out through return. That way each function can be understood and tested on its own.
  3. A traceback is the call stack: read it from the bottom up.

Now it’s time to practise: in the Python functions practice we build a temperature converter, a modular calculator and a rock, paper, scissors game, separating what calculates from what’s shown. And when you have lots of functions and want to use them in several programs without copying them, the next step is to keep them in files of their own: Python modules.

Next step: practicePython functions practice — a temperature converter, a modular calculator and rock, paper, scissors (and the menu that stopped asking for data)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *