Python Functions

Ka Kavitha V Updated 03 Oct 2026
26 min read

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:

  1. Python reads the def block and stores the function under the name show_message.
  2. The line show_message() calls the function.
  3. 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.
  • return sends 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
  • a and b must be positional.
  • x and y must 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:

SyntaxCollectsStored As
*argsAny number of positional argumentsA tuple
**kwargsAny number of keyword argumentsA 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:

  1. Regular parameters
  2. *args
  3. **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:

OrderScopeWhere
1LocalInside the current function
2EnclosingInside any enclosing (outer) functions
3GlobalAt the top level of the module
4Built-inPython'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:

  1. Functions are objects: they can be passed to other functions as arguments.
  2. 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_upper receives a function, func.
  • It defines wrapper(), which calls func() 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:

  1. add_prefix wraps username, producing a function that returns "Hi rohan".
  2. make_upper wraps 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 def when 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 def would 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:

  1. Base case — a condition that stops the recursion (here, num == 0).
  2. 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! = 1 and 0! = 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

returnyield
Ends the function completelyPauses the function and saves its state
Sends back one valueCan produce many values over time
The next call starts from the beginningThe 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

MistakeProblem
Calling a function before defining itNameError
Forgetting parentheses: greet instead of greet()The function is not called
Using print() when the caller needs the valueThe function returns None
Wrong number of argumentsTypeError
Mutable default parameters (items=[])Data leaks between calls; use None
Assigning to a global variable without globalCreates a local variable or raises UnboundLocalError
Recursion without a reachable base caseRecursionError
Iterating over a generator twiceThe second loop produces nothing
Forgetting functools.wraps in decoratorsFunction name and docstring are lost
  • Python Variables — global and local variables
  • Python Iterators — the protocol behind generators and for loops
  • Python Modules — organizing functions into reusable files
  • Python Classes and Objects — functions that belong to objects (methods)

0 Comments

Reviewed before they appear

No comments yet.

Python
Ask about this post
AI Ask about this post

Ask questions about Python Functions and get answers drawn from it.

Signed-in readers only.