Python Functions

Master Python functions: defining, return values, default parameters, *args, **kwargs, type hints, docstrings, and scope.

Beginner 14 min read 12 examples

Defining Functions

Functions are defined with the def keyword. They are objects and can be passed, returned, and assigned to variables.

Python
# Basic function definition
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")      # Hello, Alice!

# Function with docstring
def add(a, b):
    """Return the sum of a and b."""
    return a + b

result = add(3, 4)
print(result)       # 7
print(add.__doc__)  # Return the sum of a and b.

# Functions are objects
print(type(greet))          # 
my_func = greet             # assign to variable
my_func("Bob")              # Hello, Bob!

# Pass function as argument
def apply(func, value):
    return func(value)

print(apply(len, "hello"))  # 5
print(apply(str.upper, "hello"))    # HELLO

# Functions in a list
transforms = [str.upper, str.lower, str.title]
for fn in transforms:
    print(fn("hello world"))
# HELLO WORLD
# hello world
# Hello World

Return Values

Python
# Single return value
def square(x):
    return x ** 2

print(square(5))    # 25

# Functions without explicit return - return None implicitly
def show(x):
    print(x)        # no return statement

result = show(42)
print(result)       # None

# Early return
def is_valid_age(age):
    if age < 0:
        return False    # early exit
    if age > 150:
        return False
    return True

# Multiple return values - actually a tuple
def min_max(numbers):
    return min(numbers), max(numbers)   # returns (min, max) tuple

lo, hi = min_max([3, 1, 4, 1, 5, 9])
print(lo, hi)   # 1 9

result = min_max([3, 1, 4, 1, 5, 9])
print(result)           # (1, 9)
print(type(result))     # 

# Return dict for named results
def analyze(text):
    words = text.split()
    return {
        "word_count":  len(words),
        "char_count":  len(text),
        "avg_word_len": sum(len(w) for w in words) / len(words),
    }

stats = analyze("the quick brown fox")
print(stats["word_count"])  # 4

# Conditional return
def absolute(n):
    return n if n >= 0 else -n

print(absolute(-7))     # 7
print(absolute(5))      # 5

Default Parameters

Python
# Default parameter values
def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Alice")              # Hello, Alice!
greet("Bob", "Hi")          # Hi, Bob!
greet("Charlie", greeting="Hey")  # Hey, Charlie!

# Required parameters must come before defaults
def connect(host, port=5432, timeout=30):
    print(f"Connecting to {host}:{port} (timeout={timeout}s)")

connect("localhost")
connect("db.example.com", 3306)
connect("db.example.com", timeout=60)  # skip port, set timeout

# THE MUTABLE DEFAULT GOTCHA - very common bug
def append_to(element, to=[]):   # WRONG - shared list
    to.append(element)
    return to

print(append_to(1))     # [1]
print(append_to(2))     # [1, 2] - list persists across calls!
print(append_to(3))     # [1, 2, 3]

# CORRECT - use None, create inside
def append_to_fixed(element, to=None):
    if to is None:
        to = []         # new list every call
    to.append(element)
    return to

print(append_to_fixed(1))   # [1]
print(append_to_fixed(2))   # [2]  - fresh list

# Keyword arguments - call by name in any order
def create_user(name, age, email, role="user"):
    return {"name": name, "age": age, "email": email, "role": role}

user = create_user(
    email="alice@example.com",
    name="Alice",
    age=30,
    role="admin",
)
print(user)
Never use mutable defaults (list, dict, set)

Default argument values are evaluated once when the function is defined. Using def f(items=[]) means all calls share the same list object. Always use None as the default for mutable arguments and initialize inside the function body.

*args and **kwargs

Python
# *args - collect extra positional args as a tuple
def add_all(*numbers):
    return sum(numbers)

print(add_all(1, 2, 3))         # 6
print(add_all(1, 2, 3, 4, 5))   # 15
print(add_all())                 # 0

# Mixing required args with *args
def greet_all(greeting, *names):
    for name in names:
        print(f"{greeting}, {name}!")

greet_all("Hello", "Alice", "Bob", "Charlie")

# **kwargs - collect extra keyword args as a dict
def display(**info):
    for key, value in info.items():
        print(f"  {key}: {value}")

display(name="Alice", age=30, city="London")
# name: Alice
# age: 30
# city: London

# Combined: positional, *args, keyword, **kwargs
def func(a, b, *args, key="default", **kwargs):
    print(f"a={a}, b={b}")
    print(f"args={args}")
    print(f"key={key}")
    print(f"kwargs={kwargs}")

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

# Unpacking when calling a function
def add(x, y, z):
    return x + y + z

numbers = [1, 2, 3]
print(add(*numbers))        # same as add(1, 2, 3)

config = {"x": 1, "y": 2, "z": 3}
print(add(**config))        # same as add(x=1, y=2, z=3)

# Common pattern: pass-through wrapper
def log_call(func, *args, **kwargs):
    print(f"Calling {func.__name__}")
    result = func(*args, **kwargs)
    print(f"Done")
    return result

log_call(print, "hello", "world", sep=", ")

Keyword-Only Arguments

Parameters after * (or *args) can only be passed by keyword, never by position.

Python
# Parameters after bare * are keyword-only
def connect(host, port, *, timeout=30, ssl=False):
    print(f"host={host}, port={port}, timeout={timeout}, ssl={ssl}")

connect("localhost", 5432)                    # OK
connect("localhost", 5432, timeout=60)         # OK
connect("localhost", 5432, ssl=True)           # OK
# connect("localhost", 5432, 60)              # TypeError: too many positional arguments

# Positional-only parameters (Python 3.8+) - with /
def greet(name, /, greeting="Hello"):
    """name is positional-only, greeting can be positional or keyword."""
    print(f"{greeting}, {name}!")

greet("Alice")                  # OK
greet("Alice", "Hi")            # OK
greet("Alice", greeting="Hi")   # OK
# greet(name="Alice")           # TypeError: positional-only argument

# Combine: / before positional-only, * before keyword-only
def parse(source, /, sep=",", *, strip=True):
    """source is positional-only, sep is either, strip is keyword-only."""
    items = source.split(sep)
    return [x.strip() for x in items] if strip else items

print(parse("a, b, c"))             # ['a', 'b', 'c']
print(parse("a:b:c", ":"))          # ['a', 'b', 'c']
print(parse("a, b, c", strip=False)) # ['a', ' b', ' c']

Type Hints

Type hints are optional annotations that document parameter and return types. Python does not enforce them at runtime, but type checkers like mypy and IDEs use them.

Python
from typing import Optional, Union, List, Dict, Tuple

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

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

# Optional (can be the type OR None)
def find_user(user_id: int) -> Optional[str]:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)   # returns str or None

# Union (one of several types)
def process(value: Union[int, str]) -> str:
    return str(value)

# Python 3.10+ shorthand for Union
def process_new(value: int | str) -> str:
    return str(value)

# Collection types (Python 3.9+ - lowercase)
def average(numbers: list[float]) -> float:
    return sum(numbers) / len(numbers)

def lookup(data: dict[str, int], key: str) -> int:
    return data.get(key, 0)

# Return multiple values
def min_max(nums: list[int]) -> tuple[int, int]:
    return min(nums), max(nums)

# Complex signatures
def send_email(
    to: str,
    subject: str,
    body: str,
    cc: list[str] | None = None,
    attachments: list[str] | None = None,
) -> bool:
    """Send an email. Returns True on success."""
    # implementation
    return True

# Type hints do NOT enforce at runtime
def double(n: int) -> int:
    return n * 2

print(double("hi"))     # "hihi" - no error! hints are not checked
# Use mypy or pyright for static type checking

Scope and Closures

Python uses LEGB rule: Local -> Enclosing -> Global -> Built-in, for name lookup.

Python
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)    # local

    inner()
    print(x)        # enclosing

outer()
print(x)            # global

# global keyword - access and modify global variable
counter = 0

def increment():
    global counter      # declare we want the global
    counter += 1

increment()
increment()
print(counter)      # 2  (prefer returning values over global mutation)

# nonlocal keyword - modify enclosing scope variable
def make_counter():
    count = 0

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

    return increment

c = make_counter()
print(c())  # 1
print(c())  # 2
print(c())  # 3

# Closures - inner function captures outer variable
def make_adder(n):
    """Return a function that adds n to its argument."""
    def adder(x):
        return x + n    # n is captured from outer scope
    return adder

add5  = make_adder(5)
add10 = make_adder(10)
print(add5(3))      # 8
print(add10(3))     # 13
print(add5.__closure__[0].cell_contents)    # 5  (captured n)

Frequently Asked Questions

*args collects positional arguments into a tuple. **kwargs collects keyword arguments into a dictionary. In def f(a, *args, **kwargs), calling f(1, 2, 3, x=4) gives a=1, args=(2, 3), kwargs={"x": 4}. The names args and kwargs are just convention - the * and ** are the operators. You could write *rest or **options.

This is a classic Python gotcha. Default argument values are evaluated once when the function is defined, not each time it is called. If you use a mutable default like def f(items=[]):, all calls share the same list object. Appending to it persists between calls. The fix: use None as the default and create the mutable object inside the function: def f(items=None): if items is None: items = [].

Python uses "pass by object reference" (sometimes called "pass by assignment"). The function receives a reference to the same object. If the object is mutable (list, dict), changes to it inside the function affect the original. If the object is immutable (int, str, tuple), you cannot modify it - reassigning the parameter only rebinds the local name. Think of it as: you get a copy of the pointer, not a copy of the data.

A closure is a function that remembers the variables from its enclosing scope even after that scope has finished executing. When an inner function references a variable from an outer function, the outer variable is "captured" by the closure. Closures are the foundation of decorators, factory functions, and partial application.