Defining Functions
Functions are defined with the def keyword. They are objects and can be passed, returned, and assigned to variables.
# 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
# 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
# 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)
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
# *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.
# 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.
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.
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)