We closed module 2 with a clear diagnosis: the Papyrus code works, but it lives scattered around. The book search is buried inside menu.py, the member price calculation is repeated in member_sale.py and receipt.py, and every improvement forces Ana to touch several scripts. The promised solution is functions: named blocks of code that you define once and run as many times as you like. And here's the good news: you've been using them since day one — print(), len(), input(), enumerate() are functions that came ready-made. In this lesson you'll learn to create your own with def, to hand back results with return, to document them with docstrings, and to understand where their variables "live". As a project, we'll begin reorganizing menu.py by extracting its first two pieces: show_catalog() and find_book().

Contents

  1. What a function is and why to use one
  2. Defining with def and calling the function
  3. Parameters: input data
  4. return: handing back results (and the None case)
  5. Docstrings: documenting the function
  6. Variable scope: local versus global
  7. Project: first refactor of menu.py

What a function is and why to use one

A function is a block of code with a name, which optionally receives input data (parameters) and optionally hands back a result. Think of the photocopier at Papyrus: Ana doesn't rebuild the machine every time she needs a copy; she feeds in an original (input), presses a button (call) and collects the copy (result).

The three classic reasons to use functions:

Reason Without functions With functions
Reuse The member price calculation is copied across 3 scripts It's written once and called from wherever it's needed
Maintenance Changing the VAT means editing every copy (and missing one = bug) It's changed in a single place
Readability menu.py is a 35-line block you have to read in full show_catalog() explains itself by its name

Defining with def and calling the function

The minimal syntax:

def greet():
    print("Welcome to the Papyrus management system")

greet()
greet()

Line-by-line breakdown:

  • def (for define) starts the definition.
  • greet is the name: same rules as variables (snake_case, PEP 8). It's usually a verb, because a function does something.
  • The parentheses () will hold the parameters (here, none) and the colon : opens the block.
  • The body is indented, exactly as in if or for.
  • greet() — with parentheses — calls (runs) the function. Without parentheses, greet merely names the function without running it; that's a very common silent mistake.

One important detail: defining is not running. Python reads the def, memorizes the block under that name and moves on. The body only runs when someone calls the function. That's why this script prints in an order that surprises beginners:

def say_goodbye():
    print("2. Inside the function")

print("1. Before the call")
say_goodbye()
print("3. After the call")
1. Before the call
2. Inside the function
3. After the call
sequenceDiagram
    participant S as Main script
    participant F as say_goodbye()
    S->>S: print("1. Before the call")
    S->>F: call say_goodbye()
    F->>F: print("2. Inside the function")
    F-->>S: function ends (returns to the call site)
    S->>S: print("3. After the call")

Parameters: input data

A function with no inputs always does the same thing. Parameters make it flexible:

def print_label(title, price):
    print(f"{title:<12} {price:>6.2f} EUR")

print_label("Hamlet", 9.95)
print_label("Faust", 21.00)
  • title and price are parameters: names the function uses internally.
  • "Hamlet" and 9.95 are arguments: the concrete values you send in the call. The first goes to the first parameter, the second to the second (by position).
  • On each call, the parameters come to life with the values received, the function does its work, and when it finishes they vanish.

For now this basic form is all we need; lesson 03-02 is entirely devoted to the kinds of arguments (keyword, default values, variable counts...).

return: handing back results (and the None case)

print() shows, but it doesn't deliver. If you want to use a function's result in a later calculation, you need return:

def member_price(base):
    """Member price: 5% discount and then 4% VAT."""
    final = base * (1 - 0.05) * (1 + 0.04)
    return round(final, 2)

hamlet_price = member_price(9.95)
print(f"Luis pays {hamlet_price} EUR")          # Luis pays 9.83 EUR
print(f"Two copies: {member_price(9.95) * 2} EUR")

Key facts about return:

  • It delivers a value to the call site: the expression member_price(9.95) is worth 9.83, and you can assign it, print it or operate on it.
  • It ends the function immediately: the code after the executed return never runs. That makes it the perfect emergency exit — remember the tip from module 2: where break only leaves the innermost loop, return leaves the whole function, nested loops included.
  • A function may have several return statements (for example, one inside an if and another at the end); on each call at most one of them runs.

And what if a function has no return, or writes a bare return? It returns None, the "empty value" you met in module 1:

def warn_out_of_stock(title):
    print(f"'{title}' is out of stock.")

result = warn_out_of_stock("Faust")
print(result)        # None

The table that prevents 90% of early confusion:

print(x) inside the function return x
Does it show anything on screen? Yes No (unless the caller prints it)
Does the call have a useful value? No (it's None) Yes (it's x)
Can you keep operating on the result? No Yes
Typical use Informing the user Computing for other code

Practical rule for Papyrus: functions that compute (prices, searches) should hand back results with return; functions that present (showing the catalog) can stick to print() and return None.

Docstrings: documenting the function

The first line of the body, if it's a string, becomes the docstring: the function's official documentation. It's written between triple quotes:

def member_price(base):
    """Return the final price for Papyrus members.

    Applies the member discount (5%) and then the book VAT (4%),
    rounding to 2 decimal places.
    """
    return round(base * (1 - 0.05) * (1 + 0.04), 2)

help(member_price)      # shows the docstring in the REPL
  • It's not a decorative comment: help(), VS Code (when you hover over the call) and documentation tools all read it.
  • Convention: the first line is a summary that fits on one line; if more detail is needed, add a blank line and an explanatory paragraph.
  • # comments explain how a fragment works on the inside; the docstring explains what the function does and how to use it from the outside.

Variable scope: local versus global

Where does a variable "live"? It depends on where it's assigned:

STORE_NAME = "Papyrus"          # global scope (script level)

def create_receipt():
    line = f"Receipt from {STORE_NAME}"   # line: LOCAL scope
    return line

print(create_receipt())   # Receipt from Papyrus
print(line)               # NameError: name 'line' is not defined
  • line is local: it comes to life when the function is called and dies when it finishes. Outside, it doesn't exist. This is a huge advantage: two functions can both use line, i or total without stepping on each other.
  • STORE_NAME is global: it was assigned at script level, and functions can read it without doing anything special. When Python doesn't find STORE_NAME in the local scope, it looks "one floor up".

The delicate subtlety arrives when you try to modify a global inside a function:

till = 0.0

def charge(amount):
    till = till + amount    # UnboundLocalError

On seeing the assignment till = ..., Python decides that till is local to charge... and then till + amount tries to read a local that doesn't have a value yet. There is a keyword that "fixes" it:

def charge(amount):
    global till             # it works, but it's a bad idea
    till = till + amount

global till tells Python "when I assign till, use the global one". It works, but it's best avoided: a function that modifies globals has effects that are invisible from the call — reading charge(12.35), nobody suspects that till changed, and with several functions touching the same globals the program becomes undebuggable. The clean alternative is to receive and return:

def charge(till, amount):
    """Return the new till total after charging the amount."""
    return till + amount

till = 0.0
till = charge(till, 12.35)   # the change is VISIBLE at the call site

Summary of scope rules:

Operation inside the function Needs anything special? Recommended?
Reading a global (constants like BOOK_VAT) No Yes — it's the natural use of constants
Assigning a new variable No (it will be local) Yes
Modifying a global Yes, global name No — receive the data as a parameter and return the result

Project: first refactor of menu.py

Let's keep the promise from module 2. Refactoring means reorganizing code without changing what it does, and menu.py is crying out for it: we'll extract option 1 into show_catalog() and the heart of option 2 into find_book(). In the papyrus folder (with the .venv activated), the new version:

STORE_NAME = "Papyrus"
catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]
stocks = [4, 6, 8]


def show_catalog():
    """Print the table of titles, prices and stock."""
    print(f"\n{'Title':<12} {'Price':>8} {'Stock':>6}")
    print("-" * 28)
    for i, title in enumerate(catalog):
        print(f"{title:<12} {prices[i]:>6.2f} EUR {stocks[i]:>6}")


def find_book(wanted):
    """Return the index of the book whose title matches, or None if absent."""
    for i, title in enumerate(catalog):
        if title.lower() == wanted.lower():
            return i
    return None


print(f"Welcome to the {STORE_NAME} management system")

while True:
    print()
    print("1. View catalog")
    print("2. Look up a book")
    print("3. Exit")
    choice = input("Choose an option (1-3): ")

    match choice:
        case "1":
            show_catalog()
        case "2":
            wanted = input("Title to search for: ")
            if not wanted:
                print("You didn't type anything.")
                continue
            index = find_book(wanted)
            if index is None:
                print(f"'{wanted}' is not in the catalog.")
            else:
                print(f"'{catalog[index]}': {prices[index]:.2f} EUR, stock {stocks[index]}.")
        case "3":
            print(f"Closing {STORE_NAME}. See you tomorrow, Ana!")
            break
        case _:
            print("Invalid option. Choose 1, 2 or 3.")

Note the design decisions:

  1. show_catalog() presents: it prints and returns None; the while stays readable — option 1 is now a single line that reads like a sentence.
  2. find_book() computes: it replaces the for/break/else pattern from the previous version with something even more direct — return i cuts the search off as soon as there's a match (playing the role of break) and the final return None covers the "not found" case (the role of the loop's else). Returning the index instead of printing lets the caller decide what to do with it; we'll be glad of that in lesson 03-04.
  3. if index is None distinguishes "not found" from a valid result; careful, if not index would be a bug, because index 0 (The Odyssey) is also falsy — truthiness from module 2 in action.
  4. The functions read the globals catalog, prices and stocks but don't modify them: a legitimate use of global scope. When we extract these functions into a module of their own (03-04) you'll see how to remove that dependency too.

The menu behaves identically to the module 2 version — that's the whole point of refactoring — but now the search is reusable: receipt.py or member_sale.py will be able to use find_book() as soon as we know how to share code between files (lesson 03-04).

Common Mistakes and Tips

  • Calling without parentheses: show_catalog (without ()) runs nothing and raises no error; it simply names the function. If "the function does nothing", check the parentheses.
  • Using the function before defining it: Python reads the file top to bottom; calling find_book() on a line above its def raises NameError. Practical convention: constants and data at the top, then the defs, and the main program at the end.
  • Confusing print with return: if total = member_price(9.95) gives you None, the function almost certainly prints instead of returning. Review this lesson's comparison table.
  • Code after the return: any line after a return that always executes is dead code. VS Code dims it visually; delete it or rethink the logic.
  • Overusing global: if you catch yourself writing global in several functions, the design is limping. Pass the data as parameters and return the results.
  • Skipping the docstring: for project functions (the ones others — or you in a month — will reuse), a one-line docstring is the polite minimum.
  • Tip: a function should do one thing. If describing it requires the word "and" ("it shows the catalog and asks for a book and searches for it"), it's probably two or three functions.

Exercises

Exercise 1: a card with and without return

Write two functions: in_stock(units), which receives a number of units and returns True if it's greater than 0 and False otherwise; and show_availability(title, units), which uses the first one to print '<title>': available or '<title>': out of stock. Test them with Hamlet (6 units) and Faust (0 units). Add a docstring to both.

Exercise 2: counting sold-out books without touching globals

Given the global list stocks = [4, 6, 8, 0], write count_out_of_stock(stock_list) that receives a list of stock levels as a parameter and returns how many are 0 (the counter pattern from module 2). Call it with stocks and print Out-of-stock books: 1. Check that the function works the same with [0, 0, 3]. Why is receiving the list as a parameter better than reading the global directly?

Exercise 3: the scope detective

Without running it, predict what this script prints and why. Then run it and check your hypothesis:

message = "till closed"

def open_till():
    message = "till open"
    return message

print(open_till())
print(message)

Solutions

Exercise 1:

def in_stock(units):
    """Return True if there are units available."""
    return units > 0

def show_availability(title, units):
    """Print whether a title is available or out of stock."""
    if in_stock(units):
        print(f"'{title}': available")
    else:
        print(f"'{title}': out of stock")

show_availability("Hamlet", 6)   # 'Hamlet': available
show_availability("Faust", 0)    # 'Faust': out of stock

Note that return units > 0 directly returns the boolean from the comparison: there's no need for if units > 0: return True else: return False. And one function can call another: that's the foundation of building programs in layers.

Exercise 2:

stocks = [4, 6, 8, 0]

def count_out_of_stock(stock_list):
    """Return how many elements of the list are 0."""
    sold_out = 0
    for units in stock_list:
        if units == 0:
            sold_out += 1
    return sold_out

print(f"Out-of-stock books: {count_out_of_stock(stocks)}")    # 1
print(count_out_of_stock([0, 0, 3]))                          # 2

Receiving the list as a parameter makes the function reusable with any list (Papyrus's list today, another shop's tomorrow, a sample list in a test) and makes it clear at the call site which data it uses. The variable sold_out is local: it doesn't pollute the script.

Exercise 3: It prints till open and then till closed. The assignment message = "till open" inside the function creates a local variable that hides the global one while the function runs; the global never changes. For the function to modify the global you'd need global message — which, as we've seen, is better avoided by returning the value (exactly what the return does).

Conclusion

You now know how to create functions with def, distinguish parameters (input) from return (output), document them with docstrings and reason about the scope of their variables: locals that come and go with each call, globals that can be read (constants) but shouldn't be modified with global. With that, menu.py has taken its first step from "loose script" to "organized program": show_catalog() and find_book() have a name, a docstring and a single responsibility. But our functions are still rigid: find_book() receives a single positional argument and there's no way to give it options. In the next lesson we'll explore function arguments in depth — keyword arguments, default values, variable counts with *args and **kwargs — and settle an outstanding debt from module 2: truly understanding zip(), the tool that walks catalog, prices and stocks in parallel.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved