Functions

Define and use functions with default arguments, *args, **kwargs, lambda, scope rules, and closures.

Beginner 35 min read 🐍 Python

Defining Functions

Functions are reusable blocks of code that perform a specific task. Instead of writing the same logic over and over, you define it once as a function and call it whenever you need it. This makes your code shorter, more organized, and easier to debug — if something's wrong, you fix it in one place.

In Python, you define a function with the def keyword, followed by a name, parentheses with optional parameters, and a colon. The function body is indented:

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

# Call the function
message = greet("Alice")
print(message)

# Functions without return give None
def say_hello(name):
    print(f"Hi, {name}!")

result = say_hello("Bob")
print(f"Return value: {result}")
Output
Hello, Alice!
Hi, Bob!
Return value: None

The triple-quoted string right after def is called a docstring. It describes what the function does and is accessible via help(greet) or greet.__doc__. Always write docstrings for public functions.

Functions are first-class objects

In Python, functions are objects just like integers or strings. You can assign them to variables, pass them as arguments, return them from other functions, and store them in data structures. This is the foundation of functional programming in Python.

Default Arguments

Give parameters default values so callers can omit them. Parameters with defaults must come after parameters without:

def power(base, exp=2):
    """Raise base to exp. Defaults to squaring."""
    return base ** exp

print(power(3))      # 9 (uses default exp=2)
print(power(3, 3))   # 27
print(power(2, 10))  # 1024
Output
9
27
1024

*args and **kwargs

Sometimes you don't know in advance how many arguments a function will receive. Python provides two special syntaxes: *args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dictionary.

Think of *args as "any number of values" and **kwargs as "any number of named values":

def describe(*args, **kwargs):
    print(f"Positional args: {args}")
    print(f"Keyword args: {kwargs}")

describe(1, 2, 3, name="Alice", age=30)
Output
Positional args: (1, 2, 3)
Keyword args: {'name': 'Alice', 'age': 30}

Practical Example: Flexible Logger

def log(message, *values, sep=", ", level="INFO"):
    """Log a message with optional values."""
    formatted = sep.join(str(v) for v in values)
    if formatted:
        print(f"[{level}] {message}: {formatted}")
    else:
        print(f"[{level}] {message}")

log("Starting app")
log("User scores", 95, 87, 92)
log("Error occurred", "file not found", level="ERROR")
Output
[INFO] Starting app
[INFO] User scores: 95, 87, 92
[ERROR] Error occurred: file not found
Key Takeaway: Parameter order matters: regular params, then *args, then keyword-only params, then **kwargs. Example: def f(a, b, *args, option=True, **kwargs)

Lambda Functions

Lambda functions are small, anonymous functions defined in a single line. They're useful when you need a quick function for a short-lived purpose — like a sort key or a filter condition. Think of them as throwaway functions:

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

# Same thing as a lambda
square = lambda x: x ** 2
print(square(5))  # 25

# Lambdas shine as arguments to other functions
numbers = [3, 1, 4, 1, 5, 9, 2, 6]

# Sort by absolute distance from 5
sorted_nums = sorted(numbers, key=lambda x: abs(x - 5))
print(sorted_nums)

# Filter: keep only even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)

# Map: double each number
doubled = list(map(lambda x: x * 2, numbers))
print(doubled)
Output
25
[5, 4, 6, 3, 2, 9, 1, 1]
[4, 2, 6]
[6, 2, 8, 2, 10, 18, 4, 12]

When to Use Lambda vs def

Use lambda for simple, one-expression functions passed as arguments (sort keys, map/filter). Use def for anything more than one expression, anything you'll reuse, or anything that needs a docstring. If a lambda is hard to read, refactor it to a named function.

Scope: The LEGB Rule

When Python sees a variable name, it searches for it in four places, in this order: Local (inside the current function), Enclosing (inside any enclosing functions), Global (module level), Built-in (Python's built-in names like print and len). This is called the LEGB rule.

x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(f"inner: {x}")

    inner()
    print(f"outer: {x}")

outer()
print(f"module: {x}")
Output
inner: local
outer: enclosing
module: global

Each function has its own local scope. Assigning to a variable inside a function creates a new local variable — it doesn't modify the outer one. If you need to modify an outer variable, use nonlocal (for enclosing scope) or global (for module scope), though these should be used sparingly.

Higher-Order Functions

A higher-order function is one that takes another function as an argument or returns a function. This is possible because Python functions are first-class objects. You've already seen this with sorted(key=...) and map():

def apply_twice(func, value):
    """Apply a function twice to a value."""
    return func(func(value))

def add_three(x):
    return x + 3

result = apply_twice(add_three, 7)
print(result)  # add_three(add_three(7)) = add_three(10) = 13

# Returning a function (closure)
def make_multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))   # 10
print(triple(5))   # 15
Output
13
10
15
🔍 Deep Dive: Closures

When make_multiplier(2) returns the inner multiply function, that function "remembers" that factor is 2 — even after make_multiplier has finished executing. This is called a closure: the inner function closes over (captures) variables from its enclosing scope. Closures are the foundation of decorators and many design patterns in Python.

⚠️ Common Mistake: Mutable Default Arguments

Wrong:

def add_item(item, lst=[]):
    lst.append(item)
    return lst

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b'] — Bug! List persists!

Why: Default mutable arguments (lists, dicts) are created once when the function is defined and shared across all calls.

Instead:

def add_item(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b'] — correct!

Quick Reference

ConceptSyntaxExample
Define functiondef name(params):def greet(name):
Default valueparam=valuedef f(x, y=10):
Variable positional*argsdef f(*args):
Variable keyword**kwargsdef f(**kwargs):
Lambdalambda params: exprlambda x: x * 2
Docstring"""text"""First line after def
Returnreturn valueNone if omitted