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
- What a function is and why to use one
- Defining with
defand calling the function - Parameters: input data
return: handing back results (and theNonecase)- Docstrings: documenting the function
- Variable scope: local versus global
- 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:
Line-by-line breakdown:
def(for define) starts the definition.greetis 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
iforfor. greet()— with parentheses — calls (runs) the function. Without parentheses,greetmerely 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")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)titleandpriceare parameters: names the function uses internally."Hamlet"and9.95are 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 worth9.83, and you can assign it, print it or operate on it. - It ends the function immediately: the code after the executed
returnnever runs. That makes it the perfect emergency exit — remember the tip from module 2: wherebreakonly leaves the innermost loop,returnleaves the whole function, nested loops included. - A function may have several
returnstatements (for example, one inside anifand 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) # NoneThe 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 definedlineis 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 useline,iortotalwithout stepping on each other.STORE_NAMEis global: it was assigned at script level, and functions can read it without doing anything special. When Python doesn't findSTORE_NAMEin the local scope, it looks "one floor up".
The delicate subtlety arrives when you try to modify a global inside a function:
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:
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 siteSummary 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:
show_catalog()presents: it prints and returnsNone; thewhilestays readable — option 1 is now a single line that reads like a sentence.find_book()computes: it replaces thefor/break/elsepattern from the previous version with something even more direct —return icuts the search off as soon as there's a match (playing the role ofbreak) and the finalreturn Nonecovers the "not found" case (the role of the loop'selse). 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.if index is Nonedistinguishes "not found" from a valid result; careful,if not indexwould be a bug, because index0(The Odyssey) is also falsy — truthiness from module 2 in action.- The functions read the globals
catalog,pricesandstocksbut 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 itsdefraisesNameError. Practical convention: constants and data at the top, then thedefs, and the main program at the end. - Confusing
printwithreturn: iftotal = member_price(9.95)gives youNone, the function almost certainly prints instead of returning. Review this lesson's comparison table. - Code after the
return: any line after areturnthat always executes is dead code. VS Code dims it visually; delete it or rethink the logic. - Overusing
global: if you catch yourself writingglobalin 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 stockNote 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])) # 2Receiving 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
