Python Functions — Core Concepts

Functions as First-Class Citizens

In Python, functions are objects — you can assign them to variables, pass them as arguments, and return them from other functions. This isn’t just a curiosity; it fundamentally changes how you can structure programs.

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

say_hello = greet       # Assign function to a variable
print(say_hello("Alice"))   # Hello, Alice!

Defining Functions

def function_name(parameters):
    # body
    return value  # optional

If there’s no return statement, the function returns None.

Docstrings

By convention, functions start with a docstring — a string literal describing what the function does:

def calculate_bmi(weight_kg, height_m):
    """Calculate Body Mass Index from weight and height.
    
    Args:
        weight_kg: Weight in kilograms
        height_m: Height in meters
    
    Returns:
        BMI as a float
    """
    return weight_kg / (height_m ** 2)

Docstrings are accessible at runtime via help(calculate_bmi) and are used by documentation generators. Writing them isn’t optional in real projects.

Argument Flexibility

Default Arguments

Parameters can have default values, making them optional:

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

greet("Alice")              # Hello, Alice!
greet("Bob", "Good morning") # Good morning, Bob!

Important: default values are evaluated once at function definition, not at call time. The mutable default argument bug (using [] or {} as defaults) comes from this. Use None as a sentinel instead.

Keyword Arguments

Callers can specify arguments by name, in any order:

def create_user(username, email, admin=False, active=True):
    ...

create_user(email="alice@example.com", username="alice")
create_user("bob", "bob@example.com", admin=True)

*args: Variable Positional Arguments

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

def total(*numbers):
    return sum(numbers)

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

**kwargs: Variable Keyword Arguments

**kwargs collects keyword arguments into a dictionary:

def print_info(**details):
    for key, value in details.items():
        print(f"{key}: {value}")

print_info(name="Alice", age=30, city="London")

Combining Them All

You can combine all argument types, but order matters:

def mixed(pos1, pos2, *args, kw_only, **kwargs):
    ...
  1. Regular positional first
  2. *args next
  3. Keyword-only arguments (after *args, must be passed by name)
  4. **kwargs last

Lambda Functions

lambda creates a small anonymous function in a single expression:

square = lambda x: x ** 2
square(5)   # 25

# Most common use: as arguments to other functions
numbers = [3, 1, 4, 1, 5, 9]
sorted_nums = sorted(numbers, key=lambda x: -x)  # Sort descending

Lambdas are limited to a single expression. For anything more complex, use a named function — it’s more readable and testable.

Higher-Order Functions

Functions that take or return other functions. Python has several built-in:

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

# map: apply a function to each element
doubled = list(map(lambda x: x * 2, numbers))
# [2, 4, 6, 8, 10, 12]

# filter: keep only elements that pass a test
evens = list(filter(lambda x: x % 2 == 0, numbers))
# [2, 4, 6]

In modern Python, list comprehensions are often preferred over map/filter:

doubled = [x * 2 for x in numbers]
evens = [x for x in numbers if x % 2 == 0]

Same result, more readable.

Closures

A closure is a function that “captures” variables from its enclosing scope:

def make_multiplier(factor):
    def multiply(x):
        return x * factor   # captures 'factor' from outer scope
    return multiply

triple = make_multiplier(3)
print(triple(10))   # 30
print(triple(7))    # 21

multiply keeps a reference to factor even after make_multiplier has returned. This is useful for creating specialized functions without classes.

Decorators

Decorators are a clean syntax for wrapping one function with another:

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

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

add(2, 3)
# Calling add
# Done: add

@log_calls is syntactic sugar for add = log_calls(add). The original function is wrapped — every call to add now goes through wrapper first. This pattern powers Flask routes, Django views, pytest fixtures, and most Python frameworks.

Common Misconception: Functions Always Need return

Functions without explicit return aren’t broken — they return None. This is valid:

def print_separator():
    print("-" * 40)

But if you write result = print_separator(), result is None. Watch for this when using functions in expressions.

One Thing to Remember

Python functions are objects — you can pass them around, store them in variables, and return them from other functions. This makes patterns like decorators, callbacks, and closures natural rather than awkward.

pythonfunctionsargumentsfirst-classlambdas

See Also

  • Python Async Await Async/await helps one Python program juggle many waiting jobs at once, like a chef who keeps multiple pots moving without standing still.
  • Python Basics Python is the programming language that reads like plain English — here's why millions of beginners (and experts) choose it first.
  • Python Booleans Make Booleans click with one clear analogy you can reuse whenever Python feels confusing.
  • Python Break Continue Make Break Continue click with one clear analogy you can reuse whenever Python feels confusing.
  • Python Closures See how Python functions can remember private information, even after the outer function has already finished.