Chapter 10 of 14

Functions

Master Python functions — define, call, pass arguments, return values, and write clean reusable code.

Meritshot44 min read
PythonFunctionsArgumentsReturnLambdaScope
All Python Chapters

What Are Functions?

A function is a named, reusable block of code that performs a specific task. Instead of writing the same logic over and over, you write it once inside a function and then call that function whenever you need it.

Think of a function like a recipe. The recipe has a name ("Chocolate Cake"), a list of ingredients (parameters), a set of steps (the function body), and an end result (the return value). Every time you follow the recipe, you get a cake — without having to reinvent the process.

# Without functions — repetitive
print("=" * 40)
print("Welcome, Priya!")
print("=" * 40)

print("=" * 40)
print("Welcome, Rahul!")
print("=" * 40)

print("=" * 40)
print("Welcome, Sneha!")
print("=" * 40)

# With a function — clean and reusable
def welcome(name):
    print("=" * 40)
    print(f"Welcome, {name}!")
    print("=" * 40)

welcome("Priya")
welcome("Rahul")
welcome("Sneha")

Why Use Functions?

Functions are one of the most fundamental building blocks of programming. Here is why every Python developer relies on them:

BenefitExplanation
ReusabilityWrite once, call many times. Avoid duplicating code across your program.
ModularityBreak complex problems into smaller, manageable pieces. Each function handles one task.
ReadabilityA well-named function like calculate_tax() is easier to understand than 20 lines of math.
TestabilitySmall, focused functions are easy to test individually.
MaintainabilityWhen logic changes, you update it in one place instead of hunting through your entire codebase.
AbstractionCallers do not need to know how a function works — just what it does.
# Functions let you think at a higher level
def clean_data(raw_data):
    # ... 50 lines of cleaning logic ...
    return cleaned

def analyze(data):
    # ... 30 lines of analysis ...
    return results

def generate_report(results):
    # ... 40 lines of formatting ...
    return report

# The main flow reads like plain English
data = clean_data(raw_input)
results = analyze(data)
report = generate_report(results)

Defining and Calling Functions

The def Keyword

You create a function with the def keyword, followed by a name, parentheses (with optional parameters), and a colon. The indented block underneath is the function body.

def function_name(parameters):
    """Optional docstring explaining what the function does."""
    # function body — indented code
    return value  # optional

A Simple Function

def say_hello():
    print("Hello, World!")

# Calling the function
say_hello()

Output:

Hello, World!

The function say_hello takes no parameters and returns nothing explicitly. It simply performs an action (printing) when called.

A Function with Parameters

def greet(name):
    print(f"Hello, {name}!")

greet("Priya")
greet("Rahul")

Output:

Hello, Priya!
Hello, Rahul!

A Function That Returns a Value

def add(a, b):
    return a + b

result = add(5, 3)
print(result)  # 8

# You can use the return value directly
print(add(10, 20))  # 30

Functions Can Call Other Functions

def square(x):
    return x * x

def sum_of_squares(a, b):
    return square(a) + square(b)

print(sum_of_squares(3, 4))  # 9 + 16 = 25

Naming Conventions

Python functions follow these naming rules:

  • Use lowercase letters with underscores between words: calculate_tax, get_user_name
  • Names must start with a letter or underscore, not a digit
  • Avoid single-letter names except for very short lambdas or loop variables
  • Choose descriptive verb-based names: send_email(), validate_input(), parse_json()
# Good names
def calculate_average(numbers):
    return sum(numbers) / len(numbers)

def is_valid_email(email):
    return "@" in email and "." in email

def format_currency(amount, currency="INR"):
    return f"{currency} {amount:,.2f}"

# Poor names — avoid these
def func1(x):        # What does this do?
    return x * 2

def process(data):   # Too vague
    pass

Order Matters: Define Before You Call

A function must be defined before it is called. Python reads code top to bottom.

# This works
def greet():
    print("Hi!")

greet()

# This causes a NameError
# greet_v2()           # NameError: name 'greet_v2' is not defined
# def greet_v2():
#     print("Hi again!")

However, the order of definition does not matter as long as both functions are defined before the call site executes:

def main():
    greet("Priya")      # This is fine — helper() is defined before main() runs

def greet(name):
    print(f"Hello, {name}!")

main()  # Both functions exist by this point

Parameters and Arguments

These two terms are often used interchangeably, but they have a subtle difference:

  • Parameter — the variable name listed in the function definition
  • Argument — the actual value passed when you call the function
def greet(name):        # 'name' is a PARAMETER
    print(f"Hello, {name}!")

greet("Priya")          # "Priya" is an ARGUMENT

Positional Arguments

Positional arguments are matched to parameters based on their order:

def describe_pet(animal, name):
    print(f"I have a {animal} named {name}.")

describe_pet("dog", "Bruno")    # I have a dog named Bruno.
describe_pet("cat", "Whiskers") # I have a cat named Whiskers.

If you mix up the order, the meaning changes:

describe_pet("Bruno", "dog")    # I have a Bruno named dog. (wrong!)

Keyword Arguments

Keyword arguments are matched by name, so order does not matter:

describe_pet(name="Bruno", animal="dog")    # I have a dog named Bruno.
describe_pet(animal="cat", name="Whiskers") # I have a cat named Whiskers.

Mixing Positional and Keyword Arguments

You can combine both styles, but positional arguments must come first:

def create_user(name, age, city):
    print(f"{name}, age {age}, from {city}")

# Valid combinations
create_user("Priya", 22, "Mumbai")                # All positional
create_user("Priya", 22, city="Mumbai")            # Mixed
create_user("Priya", age=22, city="Mumbai")        # Mixed
create_user(name="Priya", age=22, city="Mumbai")   # All keyword

# INVALID — positional after keyword
# create_user(name="Priya", 22, "Mumbai")  # SyntaxError

Comparison: Positional vs Keyword Arguments

FeaturePositionalKeyword
Matched byOrder/positionParameter name
Order matters?YesNo
ReadabilityLower for many paramsHigher — self-documenting
When to useFew, obvious argumentsMany arguments or unclear meaning

Default Parameter Values

You can give parameters default values. If the caller omits that argument, the default is used automatically.

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Priya"))              # Hello, Priya!
print(greet("Priya", "Welcome"))   # Welcome, Priya!
print(greet("Priya", "Namaste"))   # Namaste, Priya!

Rules for Default Parameters

Parameters with defaults must come after parameters without defaults:

# Correct
def power(base, exponent=2):
    return base ** exponent

print(power(5))       # 25 (5^2)
print(power(5, 3))    # 125 (5^3)

# INCORRECT — non-default after default
# def power(base=2, exponent):  # SyntaxError
#     return base ** exponent

Multiple Default Parameters

def create_profile(name, age=25, city="Mumbai", country="India"):
    return {
        "name": name,
        "age": age,
        "city": city,
        "country": country,
    }

# Using all defaults
print(create_profile("Priya"))
# {'name': 'Priya', 'age': 25, 'city': 'Mumbai', 'country': 'India'}

# Overriding some defaults
print(create_profile("Rahul", city="Delhi"))
# {'name': 'Rahul', 'age': 25, 'city': 'Delhi', 'country': 'India'}

# Overriding all defaults
print(create_profile("Sneha", 30, "Bangalore", "India"))
# {'name': 'Sneha', 'age': 30, 'city': 'Bangalore', 'country': 'India'}

The Mutable Default Argument Gotcha

This is one of the most common Python traps. Default values are evaluated once when the function is defined, not each time the function is called. If the default is a mutable object (like a list or dict), it is shared across all calls.

# BUG — the list is shared!
def add_item(item, shopping_list=[]):
    shopping_list.append(item)
    return shopping_list

print(add_item("milk"))     # ['milk']
print(add_item("bread"))    # ['milk', 'bread']  ← Surprise!
print(add_item("eggs"))     # ['milk', 'bread', 'eggs']  ← Still accumulating!

The list [] is created once and reused. Each call mutates the same list object.

The Fix: Use None as a Sentinel

# CORRECT — use None and create a new list inside
def add_item(item, shopping_list=None):
    if shopping_list is None:
        shopping_list = []
    shopping_list.append(item)
    return shopping_list

print(add_item("milk"))     # ['milk']
print(add_item("bread"))    # ['bread']  ← Fresh list each time
print(add_item("eggs"))     # ['eggs']   ← Correct!

# You can still pass an existing list
my_list = ["butter"]
print(add_item("jam", my_list))  # ['butter', 'jam']

This pattern (using None as a sentinel) is the standard Python idiom for mutable default arguments. You will see it everywhere in professional Python code.

# Another example — dict default
def add_score(name, score, scoreboard=None):
    if scoreboard is None:
        scoreboard = {}
    scoreboard[name] = score
    return scoreboard

print(add_score("Priya", 95))    # {'Priya': 95}
print(add_score("Rahul", 88))    # {'Rahul': 88}  ← Independent dict

Variable-Length Arguments

Sometimes you do not know in advance how many arguments a function will receive. Python provides *args and **kwargs for this.

*args — Variable Positional Arguments

The *args syntax collects any number of positional arguments into a tuple:

def total(*numbers):
    print(f"Type: {type(numbers)}")  # <class 'tuple'>
    print(f"Values: {numbers}")
    return sum(numbers)

print(total(1, 2, 3))          # 6
print(total(10, 20, 30, 40))   # 100
print(total(5))                # 5
print(total())                 # 0

The name args is a convention — you can use any name after the *, but args is universally understood.

def print_all(*items):
    for item in items:
        print(item)

print_all("apple", "banana", "cherry")

Output:

apple
banana
cherry

**kwargs — Variable Keyword Arguments

The **kwargs syntax collects any number of keyword arguments into a dictionary:

def build_profile(**info):
    print(f"Type: {type(info)}")  # <class 'dict'>
    for key, value in info.items():
        print(f"  {key}: {value}")

build_profile(name="Priya", age=22, city="Mumbai")

Output:

Type: <class 'dict'>
  name: Priya
  age: 22
  city: Mumbai

Again, kwargs is a convention. Any name after ** works, but stick with kwargs for clarity.

def create_html_tag(tag, content, **attributes):
    attrs = " ".join(f'{k}="{v}"' for k, v in attributes.items())
    if attrs:
        return f"<{tag} {attrs}>{content}</{tag}>"
    return f"<{tag}>{content}</{tag}>"

print(create_html_tag("a", "Click here", href="https://example.com", target="_blank"))
# <a href="https://example.com" target="_blank">Click here</a>

print(create_html_tag("p", "Hello World"))
# <p>Hello World</p>

Combining All Parameter Types

Python enforces a strict order for parameter types:

def func(positional, default=value, *args, keyword_only, **kwargs):
def example(a, b, *args, key="default", **kwargs):
    print(f"a = {a}")
    print(f"b = {b}")
    print(f"args = {args}")
    print(f"key = {key}")
    print(f"kwargs = {kwargs}")

example(1, 2, 3, 4, 5, key="custom", x=10, y=20)

Output:

a = 1
b = 2
args = (3, 4, 5)
key = custom
kwargs = {'x': 10, 'y': 20}

The Complete Parameter Order

PositionTypeExampleDescription
1stPositionala, bRegular required arguments
2ndDefaultc=10Arguments with default values
3rd*args*argsCatches extra positional args
4thKeyword-onlykey=valMust be passed by name (after *args)
5th**kwargs**kwargsCatches extra keyword args

Unpacking Arguments with * and **

You can also use * and ** when calling a function to unpack sequences and dictionaries:

def add(a, b, c):
    return a + b + c

# Unpack a list or tuple with *
numbers = [10, 20, 30]
print(add(*numbers))   # 60

# Unpack a dictionary with **
params = {"a": 10, "b": 20, "c": 30}
print(add(**params))   # 60

This is especially useful when building function calls dynamically:

def greet(name, greeting, punctuation):
    return f"{greeting}, {name}{punctuation}"

args = ["Priya"]
kwargs = {"greeting": "Hello", "punctuation": "!"}
print(greet(*args, **kwargs))  # Hello, Priya!

*args vs **kwargs Comparison

Feature*args**kwargs
CollectsExtra positional argumentsExtra keyword arguments
Stored asTupleDictionary
Access byIndex (args[0])Key (kwargs["name"])
Common useFlexible positional inputConfiguration options
Unpacking operator***

Return Values

Functions can send data back to the caller using the return statement.

Single Return Value

def square(x):
    return x * x

result = square(7)
print(result)  # 49

Multiple Return Values

Python functions can return multiple values as a tuple. You can unpack them directly:

def min_max(numbers):
    return min(numbers), max(numbers)

# Tuple unpacking
low, high = min_max([4, 1, 8, 2, 9])
print(f"Min: {low}, Max: {high}")  # Min: 1, Max: 9

# You can also capture as a single tuple
result = min_max([4, 1, 8, 2, 9])
print(result)        # (1, 9)
print(type(result))  # <class 'tuple'>
def analyze_text(text):
    words = text.split()
    num_words = len(words)
    num_chars = len(text)
    avg_word_length = num_chars / num_words if num_words > 0 else 0
    return num_words, num_chars, round(avg_word_length, 2)

words, chars, avg = analyze_text("Python is amazing and powerful")
print(f"Words: {words}, Characters: {chars}, Avg length: {avg}")
# Words: 5, Characters: 30, Avg length: 6.0

Returning None Implicitly

If a function has no return statement, or if it reaches the end without returning, it returns None automatically:

def say_hello(name):
    print(f"Hello, {name}!")
    # No return statement

result = say_hello("Priya")
print(result)       # None
print(type(result)) # <class 'NoneType'>

A bare return (with no value) also returns None:

def check_age(age):
    if age < 0:
        return          # Returns None
    print(f"Age: {age}")

result = check_age(-5)
print(result)  # None

Early Returns

You can use return to exit a function early, which often makes code cleaner than deeply nested if statements:

# Without early return — deeply nested
def get_grade_nested(score):
    if score >= 0 and score <= 100:
        if score >= 90:
            return "A"
        else:
            if score >= 80:
                return "B"
            else:
                if score >= 70:
                    return "C"
                else:
                    return "F"
    else:
        return "Invalid"

# With early returns — flat and clean (guard clauses)
def get_grade(score):
    if score < 0 or score > 100:
        return "Invalid"
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    if score >= 70:
        return "C"
    return "F"

print(get_grade(95))   # A
print(get_grade(82))   # B
print(get_grade(65))   # F
print(get_grade(150))  # Invalid

Returning Different Types

Python allows a function to return different types, but this can make code harder to reason about. Use it sparingly:

def safe_divide(a, b):
    if b == 0:
        return None   # Signal failure
    return a / b

result = safe_divide(10, 3)
if result is not None:
    print(f"Result: {result:.2f}")  # Result: 3.33
else:
    print("Cannot divide by zero")

Scope and Lifetime

Scope determines where a variable can be accessed. Lifetime determines how long a variable exists in memory.

Local Scope

Variables created inside a function are local — they exist only while the function is executing and cannot be accessed from outside.

def my_function():
    x = 10          # Local variable
    print(f"Inside: x = {x}")

my_function()       # Inside: x = 10
# print(x)          # NameError: name 'x' is not defined

Each function call creates its own local scope:

def counter():
    count = 0
    count += 1
    return count

print(counter())  # 1
print(counter())  # 1  (fresh 'count' each time)
print(counter())  # 1

Global Scope

Variables created outside any function are global — they are accessible from anywhere in the module.

name = "Priya"     # Global variable

def greet():
    print(f"Hello, {name}!")   # Can READ global variables

greet()         # Hello, Priya!
print(name)     # Priya

Variable Shadowing

A local variable with the same name as a global variable shadows (hides) the global one inside the function:

x = 10              # Global x

def foo():
    x = 20          # Local x — shadows the global
    print(f"Inside foo: x = {x}")

foo()               # Inside foo: x = 20
print(f"Global: x = {x}")  # Global: x = 10  (unchanged)

The global Keyword

To modify a global variable from inside a function, you must declare it as global:

counter = 0

def increment():
    global counter
    counter += 1

increment()
increment()
increment()
print(counter)  # 3

Without global, assigning to counter inside the function creates a new local variable instead of modifying the global one:

counter = 0

def increment_broken():
    # counter += 1    # UnboundLocalError! Python sees the assignment and
    pass              # treats 'counter' as local, but it has no value yet.

# increment_broken()  # Would raise UnboundLocalError

Best practice: Avoid global. Pass values as arguments and return results instead. Global state makes code harder to test and debug.

# Instead of global...
def increment(counter):
    return counter + 1

count = 0
count = increment(count)  # 1
count = increment(count)  # 2
print(count)              # 2

The nonlocal Keyword

nonlocal is used in nested functions to modify a variable from an enclosing (but not global) scope:

def outer():
    count = 0

    def inner():
        nonlocal count
        count += 1
        print(f"Count: {count}")

    inner()   # Count: 1
    inner()   # Count: 2
    inner()   # Count: 3

outer()

Without nonlocal, the inner function would create its own local count:

def outer():
    count = 0

    def inner():
        count = 99       # This creates a new LOCAL variable
        print(f"Inner count: {count}")

    inner()              # Inner count: 99
    print(f"Outer count: {count}")  # Outer count: 0 (unchanged)

outer()

The LEGB Rule

When you reference a variable, Python searches for it in this order:

LevelScopeDescription
LLocalVariables defined in the current function
EEnclosingVariables in outer (enclosing) functions — for nested functions
GGlobalVariables defined at the module level
BBuilt-inPython's built-in names like print, len, True

Python stops at the first scope where it finds the name.

x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)     # Finds 'local' first (L)

    inner()

outer()  # local
# Demonstrating each level
x = "global"           # G

def outer():
    x = "enclosing"    # E

    def inner():
        # No local x   # L — not found, move to E
        print(x)

    inner()

outer()  # enclosing
# Built-in scope
# print is found in the Built-in scope
print(len("hello"))    # 5  — both 'print' and 'len' are built-ins

# Shadowing a built-in (don't do this!)
# len = 99
# print(len("hello"))  # TypeError: 'int' object is not callable

Scope Comparison

FeatureLocalGlobalnonlocal
Created inFunction bodyModule levelEnclosing function
Accessible fromThat function onlyAnywhere in moduleNested function
Keyword needed to modifyNoneglobalnonlocal
LifetimeFunction callProgram durationEnclosing function call
Best practicePreferredUse sparinglyUse for closures

Lambda Functions

A lambda is a small anonymous (unnamed) function defined with the lambda keyword. It can take any number of arguments but can only contain a single expression.

Syntax

lambda arguments: expression

The expression is evaluated and returned automatically — no return keyword needed.

# Regular function
def square(x):
    return x ** 2

# Lambda equivalent
square_lambda = lambda x: x ** 2

print(square(5))         # 25
print(square_lambda(5))  # 25
# Lambda with multiple arguments
add = lambda a, b: a + b
print(add(3, 7))   # 10

# Lambda with a conditional expression
classify = lambda x: "even" if x % 2 == 0 else "odd"
print(classify(4))  # even
print(classify(7))  # odd

Lambdas with sorted()

Lambdas shine when used as the key argument for sorting:

# Sort by second element of each tuple
pairs = [(1, "banana"), (3, "apple"), (2, "cherry")]
sorted_pairs = sorted(pairs, key=lambda x: x[1])
print(sorted_pairs)  # [(3, 'apple'), (1, 'banana'), (2, 'cherry')]

# Sort students by grade (descending)
students = [
    {"name": "Priya", "grade": 92},
    {"name": "Rahul", "grade": 85},
    {"name": "Sneha", "grade": 97},
]
by_grade = sorted(students, key=lambda s: s["grade"], reverse=True)
for s in by_grade:
    print(f"{s['name']}: {s['grade']}")

Output:

Sneha: 97
Priya: 92
Rahul: 85

Lambdas with map()

map() applies a function to every item in an iterable:

numbers = [1, 2, 3, 4, 5]

# Double each number
doubled = list(map(lambda x: x * 2, numbers))
print(doubled)  # [2, 4, 6, 8, 10]

# Convert to strings
str_numbers = list(map(lambda x: str(x), numbers))
print(str_numbers)  # ['1', '2', '3', '4', '5']

# Note: a list comprehension is often clearer
doubled_lc = [x * 2 for x in numbers]
print(doubled_lc)  # [2, 4, 6, 8, 10]

Lambdas with filter()

filter() keeps only items where the function returns True:

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# Keep even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4, 6, 8, 10]

# Keep strings longer than 3 characters
words = ["hi", "hello", "hey", "greetings", "yo"]
long_words = list(filter(lambda w: len(w) > 3, words))
print(long_words)  # ['hello', 'greetings']

When to Use Lambda vs def

Use Lambda When...Use def When...
Function is very short (one expression)Logic requires multiple statements
Used once as an argument to another functionFunction needs a descriptive name
Readability is not sacrificedFunction is used more than once
Inside sorted(), map(), filter()Function needs docstrings or type hints
# Good use of lambda
sorted_data = sorted(items, key=lambda x: x.date)

# Bad use of lambda — too complex, use def instead
# process = lambda x: x.strip().lower().replace(" ", "_") if x else ""
# Better:
def process(x):
    """Clean and normalize a string for use as an identifier."""
    if not x:
        return ""
    return x.strip().lower().replace(" ", "_")

Higher-Order Functions

A higher-order function is a function that does at least one of the following:

  1. Takes one or more functions as arguments
  2. Returns a function as its result

In Python, functions are first-class objects — they can be assigned to variables, stored in data structures, and passed around just like integers or strings.

Functions as Arguments

def apply(func, value):
    """Apply a function to a value and return the result."""
    return func(value)

def double(x):
    return x * 2

def negate(x):
    return -x

print(apply(double, 5))    # 10
print(apply(negate, 5))    # -5
print(apply(abs, -42))     # 42  (abs is a built-in function)

Functions as Return Values

def make_multiplier(factor):
    """Return a function that multiplies by the given factor."""
    def multiplier(x):
        return x * factor
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(10))   # 20
print(triple(10))   # 30
print(double(7))    # 14

Storing Functions in Data Structures

def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def multiply(a, b):
    return a * b

# Store functions in a dictionary
operations = {
    "+": add,
    "-": subtract,
    "*": multiply,
}

# Use the dictionary to dispatch operations
op = "+"
result = operations[op](10, 3)
print(f"10 {op} 3 = {result}")  # 10 + 3 = 13

op = "*"
result = operations[op](10, 3)
print(f"10 {op} 3 = {result}")  # 10 * 3 = 30

Built-in Higher-Order Functions

Python ships with several higher-order functions:

numbers = [3, 1, 4, 1, 5, 9, 2, 6]

# map — apply a function to each item
squares = list(map(lambda x: x ** 2, numbers))
print(squares)  # [9, 1, 16, 1, 25, 81, 4, 36]

# filter — keep items where function returns True
big = list(filter(lambda x: x > 4, numbers))
print(big)  # [5, 9, 6]

# sorted — sort using a key function
desc = sorted(numbers, key=lambda x: -x)
print(desc)  # [9, 6, 5, 4, 3, 2, 1, 1]

# min/max with key
words = ["banana", "apple", "cherry", "date"]
longest = max(words, key=len)
print(longest)  # banana (or cherry — both length 6)

Built-in Functions Reference

Python includes many built-in functions that you do not need to import. Here is a reference table of the most commonly used ones:

Numeric and Math Functions

FunctionDescriptionExampleResult
abs(x)Absolute valueabs(-7)7
round(x, n)Round to n decimal placesround(3.14159, 2)3.14
min(...)Smallest valuemin(3, 1, 4)1
max(...)Largest valuemax(3, 1, 4)4
sum(iterable)Sum of all itemssum([1, 2, 3])6
pow(base, exp)Power (base^exp)pow(2, 3)8
divmod(a, b)Quotient and remainderdivmod(17, 5)(3, 2)

Sequence and Iteration Functions

FunctionDescriptionExample
len(x)Length of a sequencelen("hello") returns 5
range(start, stop, step)Generate a range of numberslist(range(5)) returns [0, 1, 2, 3, 4]
enumerate(iterable)Pairs each item with its indexlist(enumerate(["a", "b"])) returns [(0, 'a'), (1, 'b')]
zip(iter1, iter2, ...)Combine iterables element-wiselist(zip([1,2], ["a","b"])) returns [(1, 'a'), (2, 'b')]
sorted(iterable)Return a new sorted listsorted([3, 1, 2]) returns [1, 2, 3]
reversed(seq)Reverse iteratorlist(reversed([1, 2, 3])) returns [3, 2, 1]
map(func, iterable)Apply function to each itemlist(map(str, [1, 2])) returns ['1', '2']
filter(func, iterable)Keep items where func is Truelist(filter(bool, [0, 1, "", "a"])) returns [1, 'a']

Boolean and Comparison Functions

FunctionDescriptionExampleResult
any(iterable)True if any item is truthyany([0, False, 3])True
all(iterable)True if all items are truthyall([1, True, "a"])True
isinstance(obj, type)Check typeisinstance(42, int)True

Type and Inspection Functions

FunctionDescriptionExample
type(obj)Return the type of an objecttype(42) returns <class 'int'>
id(obj)Return the memory addressid(42) returns an integer
dir(obj)List attributes and methodsdir([]) returns list methods
help(obj)Display documentationhelp(len) shows len's docstring

I/O Functions

FunctionDescriptionExample
print(*args)Output to consoleprint("Hello", "World")
input(prompt)Read user input as stringname = input("Name: ")
# Practical examples of built-in functions

# enumerate — get index and value
fruits = ["apple", "banana", "cherry"]
for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")
# 1. apple
# 2. banana
# 3. cherry

# zip — combine parallel lists
names = ["Priya", "Rahul", "Sneha"]
scores = [95, 87, 92]
for name, score in zip(names, scores):
    print(f"{name}: {score}")
# Priya: 95
# Rahul: 87
# Sneha: 92

# any and all
numbers = [2, 4, 6, 8]
print(all(x % 2 == 0 for x in numbers))  # True  (all are even)
print(any(x > 7 for x in numbers))       # True  (8 > 7)
print(any(x > 10 for x in numbers))      # False (none > 10)

Closures

A closure is a function that "remembers" variables from its enclosing scope, even after the outer function has finished executing. Closures are created whenever a nested function references a variable from its enclosing function.

How Closures Work

def make_greeter(greeting):
    """Return a function that uses the given greeting."""
    def greeter(name):
        return f"{greeting}, {name}!"     # 'greeting' is from the enclosing scope
    return greeter

hello = make_greeter("Hello")
namaste = make_greeter("Namaste")

print(hello("Priya"))     # Hello, Priya!
print(namaste("Rahul"))   # Namaste, Rahul!
print(hello("Sneha"))     # Hello, Sneha!

Here, greeter is a closure. It "closes over" the variable greeting from make_greeter. Even after make_greeter finishes, the inner function retains access to greeting.

Factory Functions

Closures are commonly used to create factory functions — functions that produce other functions with pre-configured behavior:

def make_power(exponent):
    """Create a function that raises numbers to the given power."""
    def power(base):
        return base ** exponent
    return power

square = make_power(2)
cube = make_power(3)

print(square(5))   # 25
print(cube(5))     # 125
print(square(10))  # 100
def make_validator(min_length, require_digit=False):
    """Create a password validator with custom rules."""
    def validate(password):
        if len(password) < min_length:
            return False, f"Must be at least {min_length} characters"
        if require_digit and not any(c.isdigit() for c in password):
            return False, "Must contain at least one digit"
        return True, "Valid password"
    return validate

# Create different validators for different contexts
basic_validator = make_validator(6)
strict_validator = make_validator(12, require_digit=True)

print(basic_validator("hello"))       # (False, 'Must be at least 6 characters')
print(basic_validator("hello!"))      # (True, 'Valid password')
print(strict_validator("shortpass"))  # (False, 'Must be at least 12 characters')
print(strict_validator("MySecurePass1"))  # (True, 'Valid password')

Counter Using a Closure

def make_counter(start=0):
    """Create a counter that remembers its state."""
    count = start

    def increment():
        nonlocal count
        count += 1
        return count

    def get():
        return count

    def reset():
        nonlocal count
        count = start
        return count

    # Return a dict of functions sharing the same closure
    return {"increment": increment, "get": get, "reset": reset}

counter = make_counter(10)
print(counter["increment"]())  # 11
print(counter["increment"]())  # 12
print(counter["increment"]())  # 13
print(counter["get"]())        # 13
print(counter["reset"]())      # 10

Inspecting Closure Variables

You can inspect what a closure has captured:

def make_adder(n):
    def adder(x):
        return x + n
    return adder

add_5 = make_adder(5)
print(add_5(10))  # 15

# Inspect the closure
print(add_5.__closure__)               # (<cell at 0x...>,)
print(add_5.__closure__[0].cell_contents)  # 5

Decorators (Introduction)

A decorator is a function that takes another function, extends or modifies its behavior, and returns the modified function. Decorators use the @ syntax in Python.

Understanding the Pattern

Before decorators, you would wrap functions manually:

def my_decorator(func):
    def wrapper():
        print("Something before the function.")
        func()
        print("Something after the function.")
    return wrapper

def say_hello():
    print("Hello!")

# Manual wrapping
say_hello = my_decorator(say_hello)
say_hello()

Output:

Something before the function.
Hello!
Something after the function.

The @ Syntax

The @decorator syntax is shorthand for the manual wrapping pattern:

def my_decorator(func):
    def wrapper():
        print("Before the function.")
        func()
        print("After the function.")
    return wrapper

@my_decorator        # Same as: say_hello = my_decorator(say_hello)
def say_hello():
    print("Hello!")

say_hello()

Output:

Before the function.
Hello!
After the function.

Decorators That Handle Arguments

To make a decorator work with any function (regardless of its parameters), use *args and **kwargs:

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_call
def add(a, b):
    return a + b

@log_call
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

add(3, 5)
greet("Priya", greeting="Namaste")

Output:

Calling add with args=(3, 5), kwargs={}
add returned 8
Calling greet with args=('Priya',), kwargs={'greeting': 'Namaste'}
greet returned Namaste, Priya!

Preserving Function Metadata with functools.wraps

Without functools.wraps, the decorated function loses its original name and docstring:

import functools

def log_call(func):
    @functools.wraps(func)      # Preserves func's name, docstring, etc.
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_call
def add(a, b):
    """Add two numbers and return the result."""
    return a + b

print(add.__name__)   # add  (without @wraps, this would be 'wrapper')
print(add.__doc__)    # Add two numbers and return the result.

Always use @functools.wraps(func) in your decorators.

Practical Example: Timing Decorator

import functools
import time

def timer(func):
    """Measure and print how long a function takes to execute."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper

@timer
def slow_sum(n):
    """Sum numbers from 1 to n with a deliberate pause."""
    total = 0
    for i in range(1, n + 1):
        total += i
    return total

result = slow_sum(1_000_000)
print(f"Sum: {result}")

Practical Example: Retry Decorator

import functools
import time

def retry(max_attempts=3, delay=1):
    """Retry a function up to max_attempts times on failure."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt} failed: {e}")
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise Exception(f"All {max_attempts} attempts failed")
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def unreliable_api_call():
    """Simulate an unreliable API."""
    import random
    if random.random() < 0.7:
        raise ConnectionError("Server unavailable")
    return {"status": "success"}

# This will retry up to 3 times before giving up
# result = unreliable_api_call()

Recursion

Recursion is when a function calls itself. Every recursive function needs:

  1. Base case — a condition that stops the recursion
  2. Recursive case — the function calls itself with a smaller/simpler input

Factorial — The Classic Example

def factorial(n):
    # Base case
    if n <= 1:
        return 1
    # Recursive case
    return n * factorial(n - 1)

print(factorial(5))   # 120
print(factorial(0))   # 1
print(factorial(1))   # 1
print(factorial(10))  # 3628800

How it works step by step:

factorial(5)
= 5 * factorial(4)
= 5 * 4 * factorial(3)
= 5 * 4 * 3 * factorial(2)
= 5 * 4 * 3 * 2 * factorial(1)
= 5 * 4 * 3 * 2 * 1
= 120

Fibonacci Sequence

def fibonacci(n):
    """Return the nth Fibonacci number (0-indexed)."""
    if n <= 0:
        return 0
    if n == 1:
        return 1
    return fibonacci(n - 1) + fibonacci(n - 2)

# First 10 Fibonacci numbers
for i in range(10):
    print(fibonacci(i), end=" ")
# 0 1 1 2 3 5 8 13 21 34

Warning: The naive recursive Fibonacci is very slow for large n because it recalculates the same values many times. We will fix this with memoization in the Practical Examples section.

Sum of a List (Recursion)

def recursive_sum(numbers):
    """Sum a list using recursion."""
    if len(numbers) == 0:    # Base case: empty list
        return 0
    return numbers[0] + recursive_sum(numbers[1:])  # First + rest

print(recursive_sum([1, 2, 3, 4, 5]))  # 15

Flatten a Nested List

def flatten(nested_list):
    """Recursively flatten a nested list."""
    result = []
    for item in nested_list:
        if isinstance(item, list):
            result.extend(flatten(item))   # Recurse into sublists
        else:
            result.append(item)
    return result

data = [1, [2, 3], [4, [5, 6]], 7, [8, [9, [10]]]]
print(flatten(data))  # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Recursion Depth Limit

Python has a default recursion limit (usually 1000) to prevent infinite recursion from crashing the program:

import sys

print(sys.getrecursionlimit())  # 1000 (default)

# You can increase it, but be cautious
# sys.setrecursionlimit(2000)

# Infinite recursion — hits the limit
def infinite():
    return infinite()

# infinite()  # RecursionError: maximum recursion depth exceeded

Recursion vs Iteration

FeatureRecursionIteration
MechanismFunction calls itselfUses loops (for, while)
TerminationBase caseLoop condition becomes False
MemoryEach call uses stack spaceConstant memory
ReadabilityElegant for tree/graph problemsBetter for linear sequences
PerformanceCan be slower (function call overhead)Generally faster
RiskStack overflow for deep recursionInfinite loops

When to use recursion:

  • Tree traversal (file systems, HTML/XML parsing)
  • Divide-and-conquer algorithms (merge sort, quicksort)
  • Problems with naturally recursive structure (fractals, permutations)

When to use iteration:

  • Simple counting or sequential processing
  • Performance-critical code
  • Very deep recursion (Python has a stack limit)
# Iterative factorial — more efficient in Python
def factorial_iterative(n):
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

print(factorial_iterative(5))   # 120
print(factorial_iterative(100)) # Works fine — no recursion limit

Function Annotations / Type Hints

Python 3.5+ supports type hints — annotations that indicate what types a function expects and returns. They do not enforce types at runtime but greatly improve code readability and enable tools like mypy to catch type errors before you run the code.

Basic Syntax

def greet(name: str) -> str:
    return f"Hello, {name}!"

def add(a: int, b: int) -> int:
    return a + b

def is_adult(age: int) -> bool:
    return age >= 18
  • name: str indicates name should be a string
  • -> str indicates the function returns a string

Default Values with Type Hints

def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()

print(greet("Priya"))       # Hello, Priya!
print(greet("Priya", 3))    # Hello, Priya! Hello, Priya! Hello, Priya!

Complex Types with typing Module

For more complex types, use the typing module (though Python 3.9+ supports many of these natively):

# Python 3.9+ — use built-in types directly
def process_scores(scores: list[int]) -> dict[str, float]:
    return {
        "average": sum(scores) / len(scores),
        "highest": max(scores),
        "lowest": min(scores),
    }

# Python 3.5-3.8 — use typing module
from typing import List, Dict, Tuple, Optional, Union

def process_scores_old(scores: List[int]) -> Dict[str, float]:
    return {
        "average": sum(scores) / len(scores),
        "highest": max(scores),
        "lowest": min(scores),
    }

Optional — Value or None

from typing import Optional

def find_user(user_id: int) -> Optional[dict]:
    """Return user dict if found, None if not."""
    users = {1: {"name": "Priya"}, 2: {"name": "Rahul"}}
    return users.get(user_id)  # Returns None if not found

result = find_user(1)    # {'name': 'Priya'}
result = find_user(99)   # None

Optional[dict] is equivalent to dict | None in Python 3.10+.

Union — Multiple Possible Types

from typing import Union

def double(value: Union[int, float, str]) -> Union[int, float, str]:
    if isinstance(value, str):
        return value * 2
    return value * 2

print(double(5))       # 10
print(double(3.14))    # 6.28
print(double("ha"))    # haha

# Python 3.10+ shorthand
def double_new(value: int | float | str) -> int | float | str:
    if isinstance(value, str):
        return value * 2
    return value * 2

Callable Type Hint

from typing import Callable

def apply_operation(func: Callable[[int, int], int], a: int, b: int) -> int:
    """Apply a function to two integers."""
    return func(a, b)

print(apply_operation(lambda x, y: x + y, 10, 5))  # 15
print(apply_operation(lambda x, y: x * y, 10, 5))  # 50

Type Hints Are Not Enforced at Runtime

def add(a: int, b: int) -> int:
    return a + b

# Python does NOT prevent this — type hints are purely informational
print(add("hello", " world"))  # hello world  (no error at runtime!)

Use tools like mypy to check types statically:

# Install mypy
# pip install mypy

# Run type checking
# mypy my_script.py

Docstrings

A docstring is a string literal that appears as the first statement in a function (or class, or module). It documents what the function does, what parameters it takes, and what it returns.

Basic Docstring

def add(a, b):
    """Add two numbers and return the result."""
    return a + b

Multi-line Docstrings

For more detailed documentation, use a multi-line docstring:

def calculate_bmi(weight_kg, height_m):
    """
    Calculate Body Mass Index (BMI).

    BMI is a measure of body fat based on height and weight.
    A BMI between 18.5 and 24.9 is considered normal.

    Args:
        weight_kg: Weight in kilograms (must be positive).
        height_m: Height in meters (must be positive).

    Returns:
        BMI as a float, rounded to one decimal place.

    Raises:
        ValueError: If weight or height is not positive.
    """
    if weight_kg <= 0 or height_m <= 0:
        raise ValueError("Weight and height must be positive")
    return round(weight_kg / (height_m ** 2), 1)

Google Style Docstrings

This is one of the most popular styles, used by Google and many open-source projects:

def fetch_data(url, timeout=30, retries=3):
    """Fetch data from a remote URL.

    Makes an HTTP GET request to the specified URL with
    configurable timeout and retry behavior.

    Args:
        url: The URL to fetch data from.
        timeout: Maximum seconds to wait for a response.
            Defaults to 30.
        retries: Number of retry attempts on failure.
            Defaults to 3.

    Returns:
        A dictionary containing the response data with keys:
            - 'status': HTTP status code (int)
            - 'body': Response body (str)
            - 'headers': Response headers (dict)

    Raises:
        ConnectionError: If all retry attempts fail.
        ValueError: If the URL is malformed.

    Example:
        >>> result = fetch_data("https://api.example.com/data")
        >>> print(result['status'])
        200
    """
    pass

NumPy Style Docstrings

Popular in the scientific Python community (NumPy, SciPy, pandas):

def calculate_statistics(data):
    """
    Calculate basic statistics for a dataset.

    Parameters
    ----------
    data : list of float
        A list of numerical values. Must not be empty.

    Returns
    -------
    dict
        A dictionary with keys 'mean', 'median', 'std_dev',
        each mapping to a float.

    Raises
    ------
    ValueError
        If the data list is empty.

    Examples
    --------
    >>> calculate_statistics([1, 2, 3, 4, 5])
    {'mean': 3.0, 'median': 3.0, 'std_dev': 1.414}
    """
    pass

Accessing Docstrings

def greet(name):
    """Greet a person by name with a friendly message."""
    return f"Hello, {name}!"

# Access the docstring programmatically
print(greet.__doc__)
# Greet a person by name with a friendly message.

# Or use help()
help(greet)
# Help on function greet in module __main__:
#
# greet(name)
#     Greet a person by name with a friendly message.

Docstring Best Practices

DoDon't
Start with a one-line summaryWrite the summary across multiple lines
Use imperative mood ("Calculate...", "Return...")Use descriptive mood ("This function calculates...")
Document parameters, return values, exceptionsLeave complex functions undocumented
Include examples for non-obvious behaviorInclude obvious details like "This is a function"
Keep the summary under 79 charactersWrite a novel in the first line

Practical Examples

Calculator with Dispatch Dictionary

def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def multiply(a, b):
    return a * b

def divide(a, b):
    if b == 0:
        return "Error: Division by zero"
    return a / b

def power(a, b):
    return a ** b

def modulo(a, b):
    if b == 0:
        return "Error: Division by zero"
    return a % b

# Dispatch dictionary — maps operator to function
operations = {
    "+": add,
    "-": subtract,
    "*": multiply,
    "/": divide,
    "**": power,
    "%": modulo,
}

def calculate(expression):
    """
    Evaluate a simple math expression.

    Args:
        expression: A string like "10 + 5" or "2 ** 8"

    Returns:
        The result of the calculation, or an error message.
    """
    parts = expression.split()
    if len(parts) != 3:
        return "Error: Use format 'a operator b'"

    try:
        a = float(parts[0])
        op = parts[1]
        b = float(parts[2])
    except ValueError:
        return "Error: Invalid numbers"

    if op not in operations:
        return f"Error: Unknown operator '{op}'"

    result = operations[op](a, b)
    return result

# Test the calculator
print(calculate("10 + 5"))     # 15.0
print(calculate("20 - 8"))     # 12.0
print(calculate("6 * 7"))      # 42.0
print(calculate("15 / 4"))     # 3.75
print(calculate("2 ** 10"))    # 1024.0
print(calculate("17 % 5"))     # 2.0
print(calculate("10 / 0"))     # Error: Division by zero

Password Validator

def validate_password(password, min_length=8, require_upper=True,
                      require_lower=True, require_digit=True,
                      require_special=True):
    """
    Validate a password against configurable rules.

    Args:
        password: The password string to validate.
        min_length: Minimum required length (default 8).
        require_upper: Require at least one uppercase letter.
        require_lower: Require at least one lowercase letter.
        require_digit: Require at least one digit.
        require_special: Require at least one special character.

    Returns:
        A tuple of (is_valid: bool, messages: list[str]).
    """
    special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
    errors = []

    if len(password) < min_length:
        errors.append(f"Must be at least {min_length} characters (got {len(password)})")

    if require_upper and not any(c.isupper() for c in password):
        errors.append("Must contain at least one uppercase letter (A-Z)")

    if require_lower and not any(c.islower() for c in password):
        errors.append("Must contain at least one lowercase letter (a-z)")

    if require_digit and not any(c.isdigit() for c in password):
        errors.append("Must contain at least one digit (0-9)")

    if require_special and not any(c in special_chars for c in password):
        errors.append("Must contain at least one special character (!@#$...)")

    is_valid = len(errors) == 0
    return is_valid, errors

# Test the validator
passwords = ["short", "alllowercase", "NoDigitsHere!", "Secure@Pass1"]

for pwd in passwords:
    valid, errors = validate_password(pwd)
    status = "PASS" if valid else "FAIL"
    print(f"\n[{status}] '{pwd}'")
    for error in errors:
        print(f"  - {error}")

Output:

[FAIL] 'short'
  - Must be at least 8 characters (got 5)
  - Must contain at least one uppercase letter (A-Z)
  - Must contain at least one digit (0-9)
  - Must contain at least one special character (!@#$...)

[FAIL] 'alllowercase'
  - Must contain at least one uppercase letter (A-Z)
  - Must contain at least one digit (0-9)
  - Must contain at least one special character (!@#$...)

[FAIL] 'NoDigitsHere!'
  - Must contain at least one digit (0-9)

[PASS] 'Secure@Pass1'

Text Analyzer

def analyze_text(text):
    """
    Perform a comprehensive analysis of a text string.

    Args:
        text: The input string to analyze.

    Returns:
        A dictionary with various statistics about the text.
    """
    words = text.split()
    sentences = [s.strip() for s in text.replace("!", ".").replace("?", ".").split(".") if s.strip()]

    # Count word frequencies
    word_freq = {}
    for word in words:
        cleaned = word.lower().strip(".,!?;:'\"")
        if cleaned:
            word_freq[cleaned] = word_freq.get(cleaned, 0) + 1

    # Find most common words
    sorted_words = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)

    return {
        "characters": len(text),
        "characters_no_spaces": len(text.replace(" ", "")),
        "words": len(words),
        "sentences": len(sentences),
        "paragraphs": text.count("\n\n") + 1,
        "average_word_length": round(sum(len(w) for w in words) / len(words), 2) if words else 0,
        "average_sentence_length": round(len(words) / len(sentences), 2) if sentences else 0,
        "most_common_words": sorted_words[:5],
        "unique_words": len(word_freq),
    }

# Test the text analyzer
sample = """Python is a powerful programming language. Python is used for web
development, data science, and automation. Many developers love Python
because it is easy to learn and has a great community."""

stats = analyze_text(sample)
for key, value in stats.items():
    print(f"{key}: {value}")

Output:

characters: 207
characters_no_spaces: 175
words: 31
sentences: 3
paragraphs: 1
average_word_length: 5.45
average_sentence_length: 10.33
most_common_words: [('python', 3), ('is', 3), ('a', 2), ('and', 2), ('powerful', 1)]
unique_words: 24

Fibonacci with Memoization

The naive recursive Fibonacci recalculates the same values over and over. Memoization stores results of previous calls to avoid redundant work.

# Naive — very slow for large n
def fibonacci_naive(n):
    if n <= 0:
        return 0
    if n == 1:
        return 1
    return fibonacci_naive(n - 1) + fibonacci_naive(n - 2)

# Memoized — fast!
def fibonacci_memo(n, cache=None):
    if cache is None:
        cache = {}
    if n in cache:
        return cache[n]
    if n <= 0:
        return 0
    if n == 1:
        return 1
    cache[n] = fibonacci_memo(n - 1, cache) + fibonacci_memo(n - 2, cache)
    return cache[n]

# Using a decorator for memoization (cleanest approach)
from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci_lru(n):
    if n <= 0:
        return 0
    if n == 1:
        return 1
    return fibonacci_lru(n - 1) + fibonacci_lru(n - 2)

# Test all three
import time

# fibonacci_naive(35) takes noticeable time
# fibonacci_memo(35)  is instant
# fibonacci_lru(35)   is instant

for n in [10, 20, 30, 35]:
    start = time.perf_counter()
    result = fibonacci_lru(n)
    elapsed = time.perf_counter() - start
    print(f"fibonacci({n}) = {result}  ({elapsed:.6f}s)")

Output:

fibonacci(10) = 55  (0.000010s)
fibonacci(20) = 6765  (0.000003s)
fibonacci(30) = 832040  (0.000002s)
fibonacci(35) = 9227465  (0.000002s)

Common Mistakes

1. Mutable Default Arguments

This is the most common function-related bug in Python. We covered it in detail earlier, but it bears repeating because it catches everyone at least once.

# BUG
def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to(1))  # [1]
print(append_to(2))  # [1, 2]  ← Unexpected!

# FIX
def append_to(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

print(append_to(1))  # [1]
print(append_to(2))  # [2]  ← Correct!

2. Forgetting return

If you forget return, the function returns None:

# BUG — missing return
def add(a, b):
    result = a + b     # Calculates but doesn't return!

total = add(5, 3)
print(total)           # None  ← Not what you expected

# FIX
def add(a, b):
    result = a + b
    return result      # Or simply: return a + b

3. Modifying a List Argument Unintentionally

Since lists are passed by reference, modifying them inside a function affects the original:

# Surprise mutation
def remove_negatives(numbers):
    for n in numbers[:]:   # Iterate over a copy
        if n < 0:
            numbers.remove(n)

my_list = [1, -2, 3, -4, 5]
remove_negatives(my_list)
print(my_list)  # [1, 3, 5] — original list is modified!

# Safer approach — return a new list
def remove_negatives_safe(numbers):
    return [n for n in numbers if n >= 0]

my_list = [1, -2, 3, -4, 5]
clean = remove_negatives_safe(my_list)
print(my_list)   # [1, -2, 3, -4, 5]  ← Original unchanged
print(clean)     # [1, 3, 5]

4. Infinite Recursion

Forgetting the base case or not making progress toward it causes infinite recursion:

# BUG — no base case
def countdown(n):
    print(n)
    countdown(n - 1)   # Never stops!

# countdown(5)  # RecursionError after ~1000 calls

# FIX — add a base case
def countdown(n):
    if n <= 0:
        print("Done!")
        return
    print(n)
    countdown(n - 1)

countdown(5)

Output:

5
4
3
2
1
Done!

5. Shadowing Built-in Names

Using built-in function names as variable or function names breaks them:

# BUG — shadowing built-in 'list'
list = [1, 2, 3]               # Now 'list' is a variable, not a function
# new_list = list("hello")     # TypeError: 'list' object is not callable

# BUG — shadowing 'print'
# print = "hello"
# print("test")                # TypeError: 'str' object is not callable

# FIX — use descriptive names
numbers_list = [1, 2, 3]
message = "hello"

6. Using == Instead of is for None Checks

# Not ideal
def process(data=None):
    if data == None:     # Works but not Pythonic
        data = []

# Better — use 'is'
def process(data=None):
    if data is None:     # Idiomatic Python
        data = []

is checks identity (same object), == checks equality (same value). None is a singleton, so is None is the correct check.


Practice Exercises

Exercise 1: Temperature Converter (Easy)

Write a function convert_temperature that takes a temperature value and the unit it is in ("C" for Celsius, "F" for Fahrenheit, "K" for Kelvin) and returns a dictionary with the temperature in all three units.

# Expected usage:
# convert_temperature(100, "C")
# Returns: {"celsius": 100, "fahrenheit": 212.0, "kelvin": 373.15}

Hint: C to F: (C * 9/5) + 32. C to K: C + 273.15

Exercise 2: Word Counter (Easy)

Write a function count_words that takes a string and returns a dictionary where keys are words (lowercase) and values are their frequencies. Ignore punctuation.

# Expected usage:
# count_words("the cat sat on the mat")
# Returns: {"the": 2, "cat": 1, "sat": 1, "on": 1, "mat": 1}

Exercise 3: Function Composition (Medium)

Write a function compose that takes two functions f and g and returns a new function that computes f(g(x)).

# Expected usage:
# double = lambda x: x * 2
# add_one = lambda x: x + 1
# double_then_add = compose(add_one, double)
# double_then_add(5)  # add_one(double(5)) = add_one(10) = 11

Exercise 4: Decorator — Call Counter (Medium)

Write a decorator count_calls that tracks how many times a decorated function has been called. Store the count as an attribute on the function.

# Expected usage:
# @count_calls
# def greet(name):
#     return f"Hello, {name}!"
#
# greet("Priya")
# greet("Rahul")
# print(greet.call_count)  # 2

Exercise 5: Recursive Palindrome Checker (Medium)

Write a recursive function is_palindrome that checks whether a string is a palindrome, ignoring case and non-alphanumeric characters.

# Expected usage:
# is_palindrome("racecar")              # True
# is_palindrome("A man, a plan, a canal: Panama")  # True
# is_palindrome("hello")                # False

Hint: Compare the first and last characters, then recurse on the middle.

Exercise 6: Memoization Decorator (Hard)

Write your own memoize decorator that caches function results based on arguments. Test it with the recursive Fibonacci function.

# Expected usage:
# @memoize
# def fibonacci(n):
#     if n <= 1:
#         return n
#     return fibonacci(n - 1) + fibonacci(n - 2)
#
# print(fibonacci(50))  # Should be fast: 12586269025

Hint: Use a dictionary to cache results. The key can be (args, tuple(sorted(kwargs.items()))).


Summary

In this chapter, you learned the full landscape of Python functions:

  • Defining functions with def and calling them by name
  • Parameters vs arguments — positional, keyword, default values
  • The mutable default argument gotcha — always use None as a sentinel for mutable defaults
  • *args and **kwargs for variable-length arguments, and */** unpacking
  • Return values — single, multiple (tuple unpacking), implicit None, early returns
  • Scope — local, enclosing, global, built-in (the LEGB rule), global and nonlocal keywords
  • Lambda functions — anonymous single-expression functions for sorted(), map(), filter()
  • Higher-order functions — passing and returning functions, dispatch dictionaries
  • Built-in functions — the essential toolkit Python gives you for free
  • Closures — functions that remember their enclosing scope, factory functions
  • Decorators — the @ syntax for wrapping functions, functools.wraps, practical uses
  • Recursion — base case + recursive case, factorial, fibonacci, recursion limits
  • Type hints — annotating parameter and return types for readability and static analysis
  • Docstrings — documenting functions with Google style and NumPy style

Functions are the foundation of writing clean, organized Python code. As you move forward, you will use them to build classes (in the OOP chapter), organize code into modules, and create complex programs that remain readable and maintainable.

Next up: Modules and Packages — learn how to organize your functions into reusable files and libraries.