Functions.
Package logic once.
A function is a named block of code you can call from anywhere. Instead of copy-pasting the same ten lines into five places, you write them once in a function and call it five times. This is the single biggest jump in code quality you'll make in your first month. Once functions click, you stop writing scripts and start writing programs.
A Python function is defined with def name(params): and a body of indented code. Use return to send a value back to the caller — if you don't, the function returns None. Arguments can have default values, which must come after non-default ones. Call the function by name with parentheses: name(arg1, arg2). Functions are how you avoid repeating yourself.
Before this lesson: you should be comfortable with lists and loops. If not, read Lesson 2 and Lesson 3 first.
✨ Defining a function
Four parts: def, the name, the parentheses, the colon.
A function definition has a fixed shape. The def keyword, the function's name, parentheses for parameters (empty if none), and a colon. The body is indented — four spaces by convention. Nothing runs when you define a function; it only runs when you call it.
# Define a function that takes no arguments
def greet():
print("Hello!")
# Call it
greet() # Hello!
greet() # Hello! (you can call it as many times as you want)
# Define a function that takes one argument
def greet_name(name):
print(f"Hello, {name}!")
greet_name("Alice") # Hello, Alice!
greet_name("Bob") # Hello, Bob!
# Define a function that takes two arguments
def add(a, b):
print(a + b)
add(3, 5) # 8
add(10, 20) # 30
Name rules
Lowercase, underscores between words: calculate_total, not CalculateTotal or calculateTotal. That's the Python convention (PEP 8).
Indentation defines the body
Everything indented under def belongs to the function. When the indentation ends, the function ends. No braces, no end keyword.
Definition ≠ execution
Writing def greet(): doesn't run anything. The body only runs when you call greet(). This trips up beginners coming from scripting languages.
Docstrings
A triple-quoted string as the first line documents what the function does. Not required, but every function you'll ever read in production has one.
↩️ return — sending a value back
The difference between print and return is the biggest source of confusion for beginners.
print displays something on screen. return sends a value back to whatever called the function. A function without a return returns None — silently, quietly, and this is the #1 reason a function "doesn't work" when a beginner tries to use its result.
# print vs return
def add_print(a, b):
print(a + b)
def add_return(a, b):
return a + b
# print version: you see the result, but you can't use it
result1 = add_print(3, 5) # prints 8, but result1 is None
print(result1) # None
# return version: you get the value back
result2 = add_return(3, 5) # result2 is 8, nothing printed
print(result2) # 8
print(result2 * 2) # 16 — you can use the value
# return exits the function immediately
def check(x):
if x < 0:
return "negative"
if x == 0:
return "zero"
return "positive"
print(check(-5)) # negative
print(check(0)) # zero
print(check(42)) # positive
Rule of thumb: if you need the result to do something with it, use return. If the output itself is what you want (showing a message to a user), use print. In practice, 90% of functions should use return — the caller decides what to do with the result.
Implicit None. A function with no return returns None. A function with return but no value (return alone on a line) also returns None. A function with return inside a branch that doesn't execute falls through to the end and returns None. If your code is failing with 'NoneType' object has no attribute ..., the function returned nothing when you expected a value.
# Returning multiple values (actually a tuple)
def min_max(nums):
return min(nums), max(nums)
lo, hi = min_max([3, 1, 4, 1, 5])
print(lo, hi) # 1 5
# Or keep the tuple
result = min_max([3, 1, 4])
print(result) # (1, 4)
🎛️ Parameters — arguments with defaults
Optional arguments make functions flexible without making them complex.
A default parameter has a value assigned in the def line. If the caller doesn't pass it, the default is used. This makes functions callable in multiple ways without writing multiple versions. Rule: defaults must come after non-default parameters.
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice!
print(greet("Bob", "Hi")) # Hi, Bob!
print(greet("Charlie", greeting="Hey")) # Hey, Charlie! (keyword argument)
# Keyword arguments — clearer when there are many params
def create_user(name, age, role="user", active=True):
return {"name": name, "age": age, "role": role, "active": active}
create_user("Alice", 30) # uses defaults
create_user("Bob", 25, role="admin") # override role
create_user("Eve", 22, active=False, role="guest") # order doesn't matter for kwargs
The mutable default trap. def add(item, items=[]): — the empty list [] is created once, when the function is defined, and shared across every call. Add to it in one call, and the item is there in the next call too. Fix: use None as the default and create a new list inside: def add(item, items=None): if items is None: items = []. This is the #1 Python gotcha that catches even experienced developers.
Positional arguments
Passed in order. greet("Alice", "Hi") — "Alice" goes to name, "Hi" goes to greeting.
Keyword arguments
Named explicitly. greet(name="Alice", greeting="Hi"). Order doesn't matter, and the intent is clearer.
Default values
greeting="Hello" makes that argument optional. Must come after non-default parameters — def f(a="x", b): is a syntax error.
Never use mutable defaults
def f(items=[]): shares the same list across all calls. Use items=None and create inside. Same for {} and set().
📦 Scope — where variables live
Variables inside a function are invisible outside it. This is a feature.
When you define a variable inside a function, it exists only while that function is running. This is called local scope. Variables defined at the top of a file — outside any function — have module scope and are visible throughout the file. The separation prevents functions from accidentally overwriting each other's data.
# Module scope — visible everywhere in this file
greeting = "Hello"
def say_hi(name):
# Can read greeting from module scope
return f"{greeting}, {name}!"
print(say_hi("Alice")) # Hello, Alice!
# Local scope — invisible outside the function
def calculate():
result = 42 # local variable
return result
calculate()
# print(result) → NameError: name 'result' is not defined
# Each call gets a fresh local scope
def counter():
count = 0
count += 1
return count
print(counter()) # 1
print(counter()) # 1 — not 2, because count starts fresh each time
# To keep state across calls, use an argument
def increment(count):
return count + 1
n = 0
n = increment(n) # n is now 1
n = increment(n) # n is now 2
print(n) # 2
Passing values in and out is cleaner than global state. New programmers often reach for global to share a counter between functions. Don't. Pass the current value as an argument, return the updated value, and let the caller decide when to store it. This makes each function testable in isolation — no hidden dependencies.
🧩 Four patterns that cover 95% of functions
Recognize these shapes and you'll write better functions immediately.
1. Transform
Takes a value, returns a new value. Pure function — no side effects. def double(x): return x * 2. Easy to test, easy to reason about.
2. Predicate
Takes a value, returns True or False. Usually starts with is_ or has_. def is_even(n): return n % 2 == 0. Combine with if or filter().
3. Aggregate
Takes a collection, returns a single value. def average(nums): return sum(nums) / len(nums). The caller gets a summary instead of the raw data.
4. Validate
Takes data, returns it (or raises). def validate_email(email): if "@" not in email: raise ValueError(...); return email. Used to guard boundaries.
# Transform
def double(x):
return x * 2
# Predicate
def is_even(n):
return n % 2 == 0
# Aggregate
def average(nums):
if not nums: # guard against empty list
return 0
return sum(nums) / len(nums)
# Validate
def validate_email(email):
if "@" not in email:
raise ValueError(f"Invalid email: {email}")
return email
# Using them together
scores = [85, 92, 78, 96, 88]
print(average(scores)) # 87.8
print(is_even(42)) # True
print([double(x) for x in scores]) # [170, 184, 156, 192, 176]
💻 A complete example
Real code that combines everything from this lesson.
# grade_book.py — a mini grade calculator
def average(scores):
"""Return the mean of a list of scores."""
if not scores:
return 0
return sum(scores) / len(scores)
def letter_grade(avg):
"""Convert a numeric average to a letter grade."""
if avg >= 90:
return "A"
if avg >= 80:
return "B"
if avg >= 70:
return "C"
if avg >= 60:
return "D"
return "F"
def report(students, pass_mark=70):
"""Print a report for a dict of {name: [scores]}."""
for name, scores in students.items():
avg = average(scores)
grade = letter_grade(avg)
status = "PASS" if avg >= pass_mark else "FAIL"
print(f"{name:10} avg={avg:5.1f} grade={grade} {status}")
# Run it
students = {
"Alice": [92, 88, 95],
"Bob": [78, 82, 80],
"Charlie": [98, 94, 99],
"Diana": [65, 58, 72],
}
report(students)
# Output:
# Alice avg= 91.7 grade=A PASS
# Bob avg= 80.0 grade=B PASS
# Charlie avg= 97.0 grade=A PASS
# Diana avg= 65.0 grade=D FAIL
Notice how each function does one thing. average computes a mean. letter_grade maps a number to a letter. report orchestrates the other two. Each is testable in isolation, readable on its own, and reusable in a different context. That's what good functions look like.
🏆 Challenge
Three functions. Each teaches a pattern from this lesson.
Task 1 — is_palindrome (predicate)
Write a function is_palindrome(text) that returns True if the text reads the same forwards and backwards, ignoring case. Hint: lowercase the string, then compare it to its reverse (text[::-1]).
Test: is_palindrome("racecar") → True, is_palindrome("hello") → False, is_palindrome("Madam") → True.
Task 2 — count_vowels (aggregate)
Write a function count_vowels(text) that returns how many vowels are in the text. Vowels are aeiou (case-insensitive). Hint: loop over the text, check each character against the vowel set, count matches.
Test: count_vowels("hello world") → 3, count_vowels("PYTHON") → 1.
Task 3 — apply_to_list (transform)
Write a function apply_to_list(func, items) that takes another function and a list, and returns a new list where func has been applied to every item. This is what map() does — you're reimplementing it.
Test: apply_to_list(lambda x: x * 2, [1, 2, 3]) → [2, 4, 6]. apply_to_list(str.upper, ["a", "b"]) → ["A", "B"].
Then notice: [func(x) for x in items] does the same thing in one line. Both are valid Python.
⚠️ Common mistakes
The function bugs every beginner writes in their first month.
You write print(result) inside the function, then try to use the return value and get None. Fix: return result. This is the single most common beginner bug.
greet is the function object. greet() runs it. Writing x = greet assigns the function itself, not its result. Always add parentheses to execute.
def f(items=[]): — the list is shared across every call. Use def f(items=None): if items is None: items = []. Same trap for {} and set().
create_user(30, "Alice") when the signature is (name, age) — the number goes to name, the string to age. Python won't catch this. Fix: use keyword arguments (create_user(name="Alice", age=30)) for clarity.
Code after a return in the same branch is unreachable. Fix: return should be the last thing in a code path. If you need to do cleanup after the value is computed, do it before the return.
Assignment inside a function makes the variable local. To modify a module-level variable, you must declare global x — but you usually shouldn't. Fix: pass values in and return new values instead of mutating globals.
def f(a="x", b): is a SyntaxError. Non-default parameters must come before default ones. Fix: def f(b, a="x"):.
def list(items): shadows Python's built-in list. You lose access to it inside that scope and confuse readers. Avoid names like list, dict, sum, max, input, print.
➡️ What's next
You've completed the beginner tier. Here's where you go next.
❓ Frequently asked questions
How do I define a function in Python?
Use the def keyword, followed by the function name, parentheses for parameters, and a colon. The body is indented below. Example: def greet(name): return f"Hello, {name}". Call it with greet("Alice"). Function names use snake_case by convention — lowercase words separated by underscores.
What is the difference between print() and return in a function?
print() displays a value on screen and returns None. return sends a value back to whatever called the function and exits the function immediately. If a function has no return statement, it returns None by default. Use return when the caller needs the result; use print only when the output itself is the goal.
What is a default parameter in Python?
A default parameter has a value assigned in the def line: def greet(name="World"). If the caller doesn't pass that argument, the default is used. Defaults must come after non-default parameters. Never use a mutable default like [] or {} — use None and create inside the function instead.
What is the difference between arguments and parameters in Python?
Parameters are the names in the function definition: def add(a, b) — a and b are parameters. Arguments are the actual values passed when calling: add(3, 5) — 3 and 5 are arguments. The distinction is small but sometimes shows up in error messages and documentation.
How does scope work in Python?
Variables defined inside a function have local scope — they exist only while the function runs and disappear when it returns. Variables defined outside any function have module scope and are visible throughout the file. Inside a function, Python looks up names locally first, then in enclosing scopes, then globally, then in built-ins (LEGB rule).
Why does my function return None?
Because either the function has no return statement, or the return is unreachable (inside a branch that didn't execute), or you wrote return but forgot the value, or the function has print() where you meant return. The most common case: you wrote print(result) inside the function but not return result. Fix: add return at the end of the function.
Can a Python function return multiple values?
Yes — technically it returns a single tuple, which you can unpack into multiple variables. def min_max(nums): return min(nums), max(nums). Then lo, hi = min_max([3,1,4]) gives lo=1, hi=4. This is idiomatic Python and used constantly.
What is *args and **kwargs in Python?
*args collects extra positional arguments into a tuple: def add(*nums) accepts add(1, 2, 3) with nums=(1, 2, 3). **kwargs collects extra keyword arguments into a dictionary: def config(**opts) accepts config(color='red') with opts={'color': 'red'}. Use them when a function needs to accept a variable number of arguments — usually for wrappers, decorators, or APIs.