Python Functions
Functions
A function is a named, reusable block of code that performs a specific task. A function runs only when it is called, and it can be called as many times as needed.
Functions help you:
- Avoid repeating code — write the logic once and reuse it
- Break large programs into smaller parts — each function handles one job
- Make code easier to read, test, maintain, and debug
You have already used many built-in functions, such as print(), len(), and type(). This lesson shows you how to create your own, and then covers arguments, return values, *args and **kwargs, variable scope, decorators, lambda functions, recursion, and generators.
Creating a Function
Functions are created with the def keyword, followed by the function name, parentheses, and a colon.
Syntax
def function_name():
# function body
Example: A Simple Function
def show_message():
print("Welcome to Python functions")
This defines a function named show_message. The indented code is the function body. Defining a function does not run it — nothing is printed yet.
Calling a Function
To run a function, call it by writing its name followed by parentheses.
def show_message():
print("Welcome to Python functions")
show_message()
Expected output:
Welcome to Python functions
What happens:
- Python reads the
defblock and stores the function under the nameshow_message. - The line
show_message()calls the function. - Python jumps into the function body, runs it, and then returns to the line after the call.
Note: A function must be defined before it is called. Calling it earlier raises a
NameError. Writing the name without parentheses (show_message) refers to the function object but does not call it.
Calling a Function Multiple Times
def greet_user():
print("Hello, user!")
greet_user()
greet_user()
greet_user()
Expected output:
Hello, user!
Hello, user!
Hello, user!
Each call runs the same block of code.
Function Naming Rules
Function names follow the same rules as variable names:
- They must start with a letter or an underscore (
_). - They can contain letters, numbers, and underscores.
- They are case-sensitive.
- They cannot be Python keywords.
Valid examples:
def calculate_total():
pass
def _display_result():
pass
def checkStatus1():
pass
All three are valid, but PEP 8 recommends snake_case for function names, such as calculate_total or check_status. Names should describe what the function does — often starting with a verb (get_, calculate_, send_, is_).
Why Use Functions?
Without functions, repeating the same logic leads to duplicated, error-prone code.
Without Functions
price1 = 100
total1 = price1 + (price1 * 0.18)
print(total1)
price2 = 250
total2 = price2 + (price2 * 0.18)
print(total2)
price3 = 80
total3 = price3 + (price3 * 0.18)
print(total3)
If the tax rate changes from 18% to 12%, you must edit three places — and missing one creates a bug.
With a Function
def calculate_price_with_tax(price):
return price + (price * 0.18)
print(calculate_price_with_tax(100))
print(calculate_price_with_tax(250))
print(calculate_price_with_tax(80))
Expected output:
118.0
295.0
94.4
The calculation is written once. To change the tax rate, you edit a single line.
Return Values
A function can send a result back to the caller with the return statement. When return runs, the function stops immediately and the value is passed back.
Example: Returning a Value
def get_status():
return "Task completed"
result = get_status()
print(result)
Expected output:
Task completed
The call get_status() is replaced by its return value, which is then stored in result.
Using a Returned Value Directly
def square_number(num):
return num * num
print(square_number(6))
Expected output:
36
return vs print
Beginners often confuse these:
print()displays a value on the screen. The program cannot use that value later.returnsends a value back so the program can store it, calculate with it, or pass it to another function.
def add_print(a, b):
print(a + b)
def add_return(a, b):
return a + b
result = add_return(2, 3) * 10
print(result)
Expected output:
50
add_return(2, 3) * 10 works because the function returns 5. The same expression with add_print() would fail, because add_print() returns None.
Functions Without a return Statement
If a function does not explicitly return a value, Python returns None.
def print_info():
print("This function has no return")
value = print_info()
print(value)
Expected output:
This function has no return
None
The first line is printed inside the function. The function then returns None, which is stored in value and printed.
The pass Statement in Functions
A function body cannot be empty. To define a function now and implement it later, use pass:
def future_feature():
pass
pass acts as a placeholder and prevents a syntax error during development.
Function Arguments
Arguments are values you pass into a function so it can work with different data. They are written inside the parentheses when calling the function, separated by commas.
A Function With One Argument
def greet_user(username):
print("Hello", username)
greet_user("Aarav")
greet_user("Meera")
greet_user("Karan")
Expected output:
Hello Aarav
Hello Meera
Hello Karan
On each call, username receives the value passed in.
Parameters vs Arguments
The two words are often used interchangeably, but they have a precise difference:
- A parameter is the variable name listed in the function definition.
- An argument is the actual value passed when calling the function.
def welcome(name): # name is a parameter
print("Welcome", name)
welcome("Ananya") # "Ananya" is an argument
Expected output:
Welcome Ananya
The Number of Arguments Must Match
By default, a function must be called with exactly as many arguments as it has parameters.
Correct Usage
def display_fullname(first, last):
print(first, last)
display_fullname("Ravi", "Sharma")
Expected output:
Ravi Sharma
Incorrect Usage
def display_fullname(first, last):
print(first, last)
display_fullname("Ravi")
Output:
TypeError: display_fullname() missing 1 required positional argument: 'last'
Default Parameter Values
A parameter can have a default value, which is used when no argument is provided.
def say_hello(name="Guest"):
print("Hello", name)
say_hello("Neha")
say_hello()
Expected output:
Hello Neha
Hello Guest
Example: Default Country
def introduce(country="India"):
print("I live in", country)
introduce("Japan")
introduce()
introduce("Canada")
Expected output:
I live in Japan
I live in India
I live in Canada
Parameters with defaults must come after parameters without defaults: def f(a, b=2) is valid, but def f(a=1, b) is a SyntaxError.
Avoid Mutable Default Values
A default value is created once, when the function is defined — not each time it is called. With a mutable default such as a list, the same list is reused across calls:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item(1))
print(add_item(2))
Expected output:
[1]
[1, 2]
The second call still sees the 1 from the first call. The standard fix is to use None as the default:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item(1))
print(add_item(2))
Expected output:
[1]
[2]
Keyword Arguments
With keyword arguments, you name the parameter when calling the function. The order no longer matters.
def pet_details(pet, name):
print("Pet:", pet)
print("Name:", name)
pet_details(name="Luna", pet="cat")
Expected output:
Pet: cat
Name: Luna
Keyword arguments also make calls self-explanatory: create_user(name="Asha", admin=True) is clearer than create_user("Asha", True).
Positional Arguments
Arguments passed without names are positional arguments. They are matched to parameters by position, so their order matters.
def pet_details(pet, name):
print("Pet:", pet)
print("Name:", name)
pet_details("dog", "Rocky")
Expected output:
Pet: dog
Name: Rocky
Changing the Order Changes the Result
pet_details("Rocky", "dog")
Expected output:
Pet: Rocky
Name: dog
Python cannot detect this logical mistake — it simply assigns values in order.
Mixing Positional and Keyword Arguments
You can combine both types, but positional arguments must come first.
def animal_info(animal, name, age):
print(name, "is a", age, "year old", animal)
animal_info("dog", name="Buddy", age=4)
Expected output:
Buddy is a 4 year old dog
Placing a positional argument after a keyword argument — animal_info(name="Buddy", "dog", 4) — is a SyntaxError.
Passing Different Data Types as Arguments
Any data type can be passed to a function.
Passing a List
def show_items(items):
for item in items:
print(item)
shopping_list = ["milk", "bread", "eggs"]
show_items(shopping_list)
Expected output:
milk
bread
eggs
Passing a Dictionary
def show_profile(profile):
print("Name:", profile["name"])
print("City:", profile["city"])
user = {"name": "Amit", "city": "Pune"}
show_profile(user)
Expected output:
Name: Amit
City: Pune
Note: When you pass a mutable object such as a list or dictionary, the function receives a reference to the same object. If the function modifies it (for example, with
append()), the change is visible outside the function too.
Returning Values of Different Types
Returning a Number
def add_numbers(a, b):
return a + b
total = add_numbers(8, 12)
print(total)
Expected output:
20
Returning a List
def get_colors():
return ["red", "green", "blue"]
colors = get_colors()
print(colors)
Expected output:
['red', 'green', 'blue']
Returning Multiple Values (a Tuple)
def get_dimensions():
return 1920, 1080
width, height = get_dimensions()
print("Width:", width)
print("Height:", height)
Expected output:
Width: 1920
Height: 1080
return 1920, 1080 packs the two values into a tuple, and width, height = ... unpacks them.
Positional-Only and Keyword-Only Parameters
Python lets you control how arguments may be passed.
Positional-Only Parameters (/)
Parameters before a / must be passed by position (available since Python 3.8).
def greet(name, /):
print("Hello", name)
greet("Rahul")
Expected output:
Hello Rahul
Calling greet(name="Rahul") raises a TypeError, because name is positional-only.
Keyword-Only Parameters (*)
Parameters after a * must be passed by keyword.
def greet(*, name):
print("Hello", name)
greet(name="Rahul")
Expected output:
Hello Rahul
Calling greet("Rahul") raises TypeError: greet() takes 0 positional arguments but 1 was given. Keyword-only parameters are useful for options that should always be named for clarity, such as sort(reverse=True).
Combining Both
def calculate(a, b, /, *, x, y):
return a + b + x + y
result = calculate(5, 10, x=15, y=20)
print(result)
Expected output:
50
aandbmust be positional.xandymust be keywords.
Arbitrary Arguments: *args and **kwargs
Sometimes you do not know in advance how many values a function will receive. Python provides two special parameter forms:
| Syntax | Collects | Stored As |
|---|---|---|
*args | Any number of positional arguments | A tuple |
**kwargs | Any number of keyword arguments | A dictionary |
The names args and kwargs are conventions; the * and ** are what matter.
Arbitrary Positional Arguments (*args)
def show_students(*names):
print("All students:", names)
print("First student:", names[0])
show_students("Arjun", "Neha", "Rohan")
Expected output:
All students: ('Arjun', 'Neha', 'Rohan')
First student: Arjun
Inside the function, names is a tuple containing every argument passed.
Inspecting *args
def inspect_args(*args):
print("Data type:", type(args))
for value in args:
print(value)
inspect_args(10, 20, 30, 40)
Expected output:
Data type: <class 'tuple'>
10
20
30
40
Using *args With Regular Parameters
Regular parameters come before *args.
def greet_users(message, *users):
for user in users:
print(message, user)
greet_users("Welcome", "Aman", "Kriti", "Sahil")
Expected output:
Welcome Aman
Welcome Kriti
Welcome Sahil
message receives "Welcome", and all remaining values go into users.
Practical Use: Adding Any Number of Values
def total_sum(*values):
result = 0
for v in values:
result += v
return result
print(total_sum(2, 4, 6))
print(total_sum(10, 20, 30, 40))
Expected output:
12
100
Practical Use: Finding the Largest Number
def find_largest(*numbers):
if not numbers:
return None
largest = numbers[0]
for n in numbers:
if n > largest:
largest = n
return largest
print(find_largest(5, 12, 3, 19, 7))
print(find_largest())
Expected output:
19
None
The if not numbers check handles a call with no arguments, where numbers is an empty tuple.
Arbitrary Keyword Arguments (**kwargs)
def show_profile(**info):
print("Profile data:", info)
show_profile(name="Riya", age=22, city="Jaipur")
Expected output:
Profile data: {'name': 'Riya', 'age': 22, 'city': 'Jaipur'}
Inside the function, info is a dictionary of the keyword arguments.
Accessing **kwargs Values
def user_details(**data):
print("Type:", type(data))
print("Name:", data["name"])
print("Country:", data["country"])
user_details(name="Kunal", country="India", role="Developer")
Expected output:
Type: <class 'dict'>
Name: Kunal
Country: India
Using **kwargs With Regular Parameters
Regular parameters come before **kwargs.
def account_info(username, **details):
print("Username:", username)
print("Other details:")
for key, value in details.items():
print(key, "=", value)
account_info("user_101", email="user@mail.com", status="active")
Expected output:
Username: user_101
Other details:
email = user@mail.com
status = active
Combining *args and **kwargs
The parameter order must be:
- Regular parameters
*args**kwargs
def log_data(title, *args, **kwargs):
print("Title:", title)
print("Positional data:", args)
print("Keyword data:", kwargs)
log_data("Employee Info", "Rahul", "IT", age=28, city="Noida")
Expected output:
Title: Employee Info
Positional data: ('Rahul', 'IT')
Keyword data: {'age': 28, 'city': 'Noida'}
Unpacking Arguments When Calling a Function
* and ** also work in the opposite direction: when calling a function, they unpack a collection into separate arguments.
Unpacking a List With *
def multiply(a, b, c):
return a * b * c
values = [2, 3, 4]
print(multiply(*values))
Expected output:
24
multiply(*values) is the same as multiply(2, 3, 4).
Unpacking a Dictionary With **
def welcome_user(first, last):
print("Welcome", first, last)
person_data = {"first": "Amit", "last": "Verma"}
welcome_user(**person_data)
Expected output:
Welcome Amit Verma
welcome_user(**person_data) is the same as welcome_user(first="Amit", last="Verma"). The dictionary keys must match the parameter names.
Variable Scope
A variable's scope is the region of the program where it can be accessed. Understanding scope prevents confusing bugs where a variable seems to "disappear" or does not change as expected.
Local Scope
A variable created inside a function is local to that function. It exists only while the function runs.
def calculate():
value = 150
print(value)
calculate()
Expected output:
150
Trying to use value outside the function raises NameError: name 'value' is not defined.
Enclosing Scope: Nested Functions
A function defined inside another function can read the outer function's variables.
def outer_function():
message = "Hello from outer function"
def inner_function():
print(message)
inner_function()
outer_function()
Expected output:
Hello from outer function
message belongs to the enclosing scope of inner_function(), so the inner function can read it.
Global Scope
A variable created outside all functions is global. It can be read from anywhere in the module.
count = 100
def show_count():
print(count)
show_count()
print(count)
Expected output:
100
100
Local and Global Variables With the Same Name
Assigning to a name inside a function creates a new local variable, even if a global variable with the same name exists.
number = 500
def update_number():
number = 300
print("Inside function:", number)
update_number()
print("Outside function:", number)
Expected output:
Inside function: 300
Outside function: 500
The two number variables are separate and do not affect each other.
The global Keyword
global lets a function create or modify a global variable.
Creating a Global Variable Inside a Function
def create_global():
global status
status = "Active"
create_global()
print(status)
Expected output:
Active
Modifying a Global Variable
score = 90
def update_score():
global score
score = 75
update_score()
print(score)
Expected output:
75
Without global, the assignment would create a local score, and the global value would stay 90.
The nonlocal Keyword
nonlocal lets a nested function modify a variable in its enclosing function (not the global scope).
def profile():
username = "Guest"
def update_name():
nonlocal username
username = "Admin"
update_name()
return username
print(profile())
Expected output:
Admin
Without nonlocal, update_name() would create its own local username, and profile() would return "Guest".
The LEGB Rule
When Python looks up a name, it searches four scopes in this order:
| Order | Scope | Where |
|---|---|---|
| 1 | Local | Inside the current function |
| 2 | Enclosing | Inside any enclosing (outer) functions |
| 3 | Global | At the top level of the module |
| 4 | Built-in | Python's built-in names, such as print and len |
Python uses the first match it finds.
value = "Global value"
def outer():
value = "Enclosing value"
def inner():
value = "Local value"
print("Inner:", value)
inner()
print("Outer:", value)
outer()
print("Global:", value)
Expected output:
Inner: Local value
Outer: Enclosing value
Global: Global value
Each print() finds the closest value in its own scope chain.
Decorators
A decorator adds extra behavior to a function without changing the function's code. Decorators are widely used for logging, timing, access control, input validation, and caching. Frameworks such as Flask and Django use them extensively (for example, @app.route("/")).
Decorators rely on two facts about Python functions:
- Functions are objects: they can be passed to other functions as arguments.
- Functions can be defined inside other functions and returned.
A decorator is therefore:
- A function
- That takes another function as input
- And returns a new, enhanced function
A Basic Decorator
def make_upper(func):
def wrapper():
return func().upper()
return wrapper
make_upperreceives a function,func.- It defines
wrapper(), which callsfunc()and converts its result to uppercase. - It returns
wrapper(without calling it).
Apply the decorator with the @ symbol:
@make_upper
def greet():
return "good morning"
print(greet())
Expected output:
GOOD MORNING
How It Works
The @make_upper line is shorthand for:
greet = make_upper(greet)
After decoration, the name greet refers to wrapper. Calling greet() runs wrapper(), which calls the original function and uppercases the result.
Reusing a Decorator
@make_upper
def welcome():
return "welcome user"
@make_upper
def alert():
return "system ready"
print(welcome())
print(alert())
Expected output:
WELCOME USER
SYSTEM READY
Decorating Functions With Arguments
If the original function takes arguments, the wrapper must accept and pass them on.
def emphasize(func):
def wrapper(name):
return func(name).upper()
return wrapper
@emphasize
def say_hello(name):
return "hello " + name
print(say_hello("Aarav"))
Expected output:
HELLO AARAV
Using *args and **kwargs in Decorators
To make a decorator work with any function signature, the wrapper should accept *args and **kwargs and forward them:
def emphasize(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@emphasize
def greet_user(name):
return "hello " + name
print(greet_user("Meera"))
Expected output:
HELLO MEERA
Decorators With Their Own Arguments
A decorator that takes its own parameters needs one extra level of nesting. The outer function — a decorator factory — receives the parameters and returns the actual decorator.
def change_case(style):
def decorator(func):
def wrapper():
text = func()
if style == "lower":
return text.lower()
return text.upper()
return wrapper
return decorator
@change_case("lower")
def message():
return "HELLO PYTHON"
print(message())
Expected output:
hello python
@change_case("lower") first calls change_case("lower"), which returns decorator; that decorator is then applied to message.
Stacking Multiple Decorators
Several decorators can be applied to one function. They are applied from bottom to top (the one closest to the function is applied first).
def add_prefix(func):
def wrapper():
return "Hi " + func()
return wrapper
def make_upper(func):
def wrapper():
return func().upper()
return wrapper
@make_upper
@add_prefix
def username():
return "rohan"
print(username())
Expected output:
HI ROHAN
Order of application:
add_prefixwrapsusername, producing a function that returns"Hi rohan".make_upperwraps that result, producing"HI ROHAN".
This is equivalent to username = make_upper(add_prefix(username)).
Preserving Function Metadata
Because a decorator replaces the original function with wrapper, the function's metadata — such as __name__ and __doc__ — is lost:
def make_upper(func):
def wrapper():
return func().upper()
return wrapper
@make_upper
def greet():
"""Returns a greeting message"""
return "hello"
print(greet.__name__)
print(greet.__doc__)
Expected output:
wrapper
None
This can confuse debugging tools and documentation generators.
Fixing Metadata With functools.wraps
The standard library's functools.wraps copies the original function's metadata onto the wrapper:
import functools
def make_upper(func):
@functools.wraps(func)
def wrapper():
return func().upper()
return wrapper
@make_upper
def greet():
"""Returns a greeting message"""
return "hello"
print(greet.__name__)
print(greet.__doc__)
Expected output:
greet
Returns a greeting message
Using @functools.wraps(func) in every decorator you write is a best practice.
Lambda Functions
A lambda function is a small, anonymous (unnamed) function created with the lambda keyword. It is used for short operations where defining a full function with def would be unnecessary.
A lambda can take any number of arguments but must consist of a single expression, whose result is returned automatically — no return keyword is used.
Syntax
lambda parameters: expression
A Simple Lambda
add_five = lambda x: x + 5
print(add_five(10))
Expected output:
15
This is equivalent to:
def add_five(x):
return x + 5
Style note: Assigning a lambda to a variable is shown here for learning. In real code, PEP 8 recommends using
defwhen a function needs a name. Lambdas are best used inline, as shown later in this section.
Lambdas With Multiple Arguments
multiply = lambda x, y: x * y
print(multiply(4, 7))
total = lambda x, y, z: x + y + z
print(total(3, 6, 9))
Expected output:
28
18
Returning a Lambda From a Function
Lambdas are handy for creating small functions on the fly. This function builds and returns a "multiplier" function:
def make_multiplier(n):
return lambda x: x * n
double = make_multiplier(2)
triple = make_multiplier(3)
quadruple = make_multiplier(4)
print(double(10))
print(triple(8))
print(quadruple(8))
Expected output:
20
24
32
Each call to make_multiplier() creates a new lambda that remembers its own value of n. A function that remembers values from the scope where it was created is called a closure.
Using Lambdas With Built-in Functions
Lambdas are most often passed directly to functions such as map(), filter(), and sorted().
map() — Transform Every Item
map() applies a function to each item of an iterable.
numbers = [2, 4, 6, 8]
squares = list(map(lambda x: x ** 2, numbers))
print(squares)
Expected output:
[4, 16, 36, 64]
map() returns a lazy map object, so list() is used to produce a list.
filter() — Keep Matching Items
filter() keeps only the items for which the function returns True.
values = [5, 12, 18, 7, 25]
greater_than_ten = list(filter(lambda x: x > 10, values))
print(greater_than_ten)
Expected output:
[12, 18, 25]
Note: List comprehensions often express the same idea more readably:
[x ** 2 for x in numbers]and[x for x in values if x > 10].
sorted() — Custom Sort Order
The key argument tells sorted() what to compare.
players = [
{"name": "Alex", "score": 82},
{"name": "Ben", "score": 75},
{"name": "Chris", "score": 90}
]
sorted_players = sorted(players, key=lambda x: x["score"])
print(sorted_players)
Expected output:
[{'name': 'Ben', 'score': 75}, {'name': 'Alex', 'score': 82}, {'name': 'Chris', 'score': 90}]
The lambda extracts each player's score, and the players are sorted by that value.
words = ["python", "java", "csharp", "go"]
sorted_words = sorted(words, key=lambda x: x[-1])
print(sorted_words)
Expected output:
['java', 'python', 'go', 'csharp']
The words are sorted by their last character: a, n, o, p.
When to Use Lambda Functions
Use a lambda when:
- The function is a simple, single expression.
- It is used once, usually as an argument to another function.
- A full
defwould add more noise than clarity.
Use def when the logic needs multiple statements, a docstring, or a meaningful name.
Recursion
Recursion is a technique in which a function calls itself to solve a problem by breaking it into smaller versions of the same problem. Each call works on a smaller input until a simple stopping point is reached.
Recursion can make some solutions — such as processing nested data or mathematical definitions — clean and elegant. Written carelessly, however, it can cause endless calls or high memory usage.
A Simple Recursive Function
This function prints a countdown from a given number to 1:
def print_reverse(num):
if num == 0:
print("Finished!")
return
print(num)
print_reverse(num - 1)
print_reverse(6)
Expected output:
6
5
4
3
2
1
Finished!
Each call prints its number and then calls itself with num - 1. When num reaches 0, the function prints "Finished!" and stops calling itself.
Base Case and Recursive Case
Every recursive function needs two parts:
- Base case — a condition that stops the recursion (here,
num == 0). - Recursive case — the function calls itself with an input that moves closer to the base case (here,
num - 1).
Without a base case, or if the input never approaches it, the function keeps calling itself until Python stops it with a RecursionError.
Example: Factorial
The factorial of n (written n!) is defined as:
n! = n × (n − 1)!1! = 1and0! = 1
def calculate_factorial(value):
# Base case
if value <= 1:
return 1
# Recursive case
return value * calculate_factorial(value - 1)
print(calculate_factorial(6))
Expected output:
720
How the calls unfold:
calculate_factorial(6) = 6 * calculate_factorial(5)
= 6 * 5 * calculate_factorial(4)
= 6 * 5 * 4 * calculate_factorial(3)
= 6 * 5 * 4 * 3 * calculate_factorial(2)
= 6 * 5 * 4 * 3 * 2 * calculate_factorial(1)
= 6 * 5 * 4 * 3 * 2 * 1
= 720
The calls go "down" until the base case returns 1, then each multiplication completes on the way back "up".
Example: Fibonacci Sequence
In the Fibonacci sequence, each number is the sum of the two before it:
0, 1, 1, 2, 3, 5, 8, 13, 21, ...
def get_fibonacci(position):
if position == 0:
return 0
if position == 1:
return 1
return get_fibonacci(position - 1) + get_fibonacci(position - 2)
print(get_fibonacci(8))
Expected output:
21
Counting from position 0, the number at position 8 is 21. This version has two base cases and makes two recursive calls per step.
Performance note: This recursive Fibonacci recalculates the same values many times, so it becomes very slow for larger positions (such as 35 or more). The generator version later in this lesson, or caching with
functools.lru_cache, is far more efficient.
Recursion With Lists
Recursion can process a list by handling the first item and recursing on the rest.
Sum of List Items
def total_sum(items):
if not items:
return 0
return items[0] + total_sum(items[1:])
values = [2, 4, 6, 8]
print(total_sum(values))
Expected output:
20
- Base case: an empty list sums to
0. - Recursive case: the first item plus the sum of the remaining items (
items[1:]).
Largest Number in a List
def largest_value(data):
if len(data) == 1:
return data[0]
remaining_max = largest_value(data[1:])
return data[0] if data[0] > remaining_max else remaining_max
numbers = [5, 12, 3, 21, 7]
print(largest_value(numbers))
Expected output:
21
These examples demonstrate the recursive technique. In practice, Python's built-in sum() and max() are simpler and faster.
The Recursion Depth Limit
Each function call uses memory on the call stack. To protect the program from crashing, Python limits how deep recursion can go — by default, the limit is usually 1000 calls. Exceeding it raises RecursionError: maximum recursion depth exceeded.
Checking the Current Limit
import sys
print(sys.getrecursionlimit())
Expected output (typical):
1000
Increasing the Limit (Use With Caution)
import sys
sys.setrecursionlimit(1500)
print(sys.getrecursionlimit())
Expected output:
1500
Raising the limit too high can crash the Python process. If a problem needs very deep recursion, a loop-based solution is usually the better choice.
Generators
A generator is a special kind of function that produces values one at a time, pausing between each one. Instead of building and returning a complete list, a generator creates each value only when it is requested. This makes generators very memory-efficient.
Calling a generator function does not run its body immediately. It returns a generator object, which you can loop over like any other iterable.
The yield Keyword
A generator uses yield instead of return.
def number_stream():
yield 10
yield 20
yield 30
for item in number_stream():
print(item)
Expected output:
10
20
30
Each time the loop asks for the next value, the function runs until it reaches a yield, hands back that value, and pauses.
How yield Differs From return
return | yield |
|---|---|
| Ends the function completely | Pauses the function and saves its state |
| Sends back one value | Can produce many values over time |
| The next call starts from the beginning | The next request resumes right after the yield |
Example: Countdown Generator
def countdown_generator(limit):
while limit > 0:
yield limit
limit -= 1
for value in countdown_generator(5):
print(value)
Expected output:
5
4
3
2
1
The local variable limit keeps its value between yields, because the function's state is preserved while it is paused.
Why Generators Save Memory
A list stores all of its values in memory at once. A generator stores only its current state and produces values on demand.
def huge_range(max_value):
for i in range(max_value):
yield i
gen = huge_range(1_000_000)
print(next(gen))
print(next(gen))
print(next(gen))
Expected output:
0
1
2
Even though the generator can produce a million values, only the values actually requested are created.
Using next() With Generators
The built-in next() function retrieves the next value from a generator manually.
def name_generator():
yield "Alice"
yield "Bob"
yield "Charlie"
g = name_generator()
print(next(g))
print(next(g))
print(next(g))
Expected output:
Alice
Bob
Charlie
StopIteration
When a generator has no more values, next() raises a StopIteration exception.
def small_gen():
yield 1
yield 2
g = small_gen()
print(next(g))
print(next(g))
print(next(g))
Output:
1
2
Traceback (most recent call last):
...
StopIteration
A for loop handles StopIteration automatically — that is how it knows when to stop. A generator can only be iterated once; after it is exhausted, create a new generator object to start again.
Generator Expressions
A generator expression looks like a list comprehension but uses parentheses. It produces values lazily instead of building a list.
# List comprehension: builds the whole list
squares_list = [n ** 2 for n in range(4)]
print(squares_list)
# Generator expression: produces values on demand
squares_gen = (n ** 2 for n in range(4))
print(squares_gen)
print(list(squares_gen))
Expected output (the memory address will differ):
[0, 1, 4, 9]
<generator object <genexpr> at 0x7f3a2c1b9a80>
[0, 1, 4, 9]
Printing the generator shows the object itself, not its values. list() consumes the generator and collects the values.
Generator Expressions With sum()
total = sum(n ** 2 for n in range(1, 11))
print(total)
Expected output:
385
This adds the squares of 1 to 10 without creating an intermediate list. When a generator expression is the only argument, the extra parentheses can be omitted.
Infinite Sequences: Fibonacci Generator
Because generators produce values on demand, they can represent infinite sequences.
def fibonacci_generator():
x, y = 0, 1
while True:
yield x
x, y = y, x + y
fib = fibonacci_generator()
for _ in range(10):
print(next(fib), end=" ")
Expected output:
0 1 1 2 3 5 8 13 21 34
while True would never end in a normal function, but here each yield pauses the loop. The caller decides how many values to take — in this case, ten.
Advanced Generator Methods
send()
send() passes a value into a paused generator; the value becomes the result of the yield expression.
def message_receiver():
while True:
msg = yield
print("Message received:", msg)
gen = message_receiver()
next(gen) # Start the generator and run to the first yield
gen.send("Hi")
gen.send("Python")
Expected output:
Message received: Hi
Message received: Python
The generator must first be advanced to a yield with next() before it can receive values.
close()
close() stops a generator. Any finally block inside the generator still runs, which is useful for cleanup.
def controlled_gen():
try:
yield "Start"
yield "Running"
finally:
print("Generator has stopped")
gen = controlled_gen()
print(next(gen))
gen.close()
Expected output:
Start
Generator has stopped
Common Mistakes
| Mistake | Problem |
|---|---|
| Calling a function before defining it | NameError |
Forgetting parentheses: greet instead of greet() | The function is not called |
Using print() when the caller needs the value | The function returns None |
| Wrong number of arguments | TypeError |
Mutable default parameters (items=[]) | Data leaks between calls; use None |
Assigning to a global variable without global | Creates a local variable or raises UnboundLocalError |
| Recursion without a reachable base case | RecursionError |
| Iterating over a generator twice | The second loop produces nothing |
Forgetting functools.wraps in decorators | Function name and docstring are lost |
Related Concepts
- Python Variables — global and local variables
- Python Iterators — the protocol behind generators and
forloops - Python Modules — organizing functions into reusable files
- Python Classes and Objects — functions that belong to objects (methods)