Python Functions Tutorial: How to Write & Call Functions

Anastasios Antoniadis

Functions are a fundamental part of Python programming, allowing code reusability, modularity, and readability. This comprehensive guide explores Python functions in detail, from basics to advanced usage, including built-in functions, lambda functions, decorators, and best practices.

Understanding Functions in Python

A function is a block of reusable code designed to perform a specific task. It helps organize code better and avoids redundancy.

Why Use Functions?

  • Code Reusability: Write once, use multiple times.
  • Modularity: Divide complex problems into smaller, manageable pieces.
  • Improved Readability: Keeps the code clean and easier to understand.
  • Easier Debugging: Bugs can be isolated and fixed within functions.

Function Syntax

In Python, functions are defined using the def keyword:

def function_name(parameters):
    """Docstring explaining the function."""
    # Function body
    return result

Example:

def greet(name):
    """Returns a greeting message."""
    return f"Hello, {name}!"

print(greet("Alice"))  # Output: Hello, Alice!

Types of Functions

Built-in Functions

Python provides several built-in functions like print(), len(), type(), range(), etc.

Example:

print(len("Python"))  # Output: 6

User-Defined Functions

Custom functions defined by the user to perform specific tasks.

Example:

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

print(add(5, 3))  # Output: 8

2.3 Anonymous (Lambda) Functions

Lambda functions in Python are small, anonymous functions that are defined using the lambda keyword. They are useful when you need a short function for a limited scope and do not want to formally define a function using def.

Syntax of a Lambda Function

A lambda function has the following syntax:

lambda arguments: expression
  • lambda is the keyword used to define the function.
  • arguments are input parameters similar to regular function arguments.
  • expression is evaluated and returned (the function must contain a single expression).

Example:

square = lambda x: x ** 2
print(square(4))  # Output: 16

Lambda Functions with Multiple Arguments

You can pass multiple arguments to a lambda function.

add = lambda a, b: a + b
print(add(3, 4))  # Output: 7

Using Lambda Functions with Built-in Functions

Lambda functions are commonly used with functions like map(), filter(), and sorted().

Using lambda with map()

The map() function applies a function to all items in an iterable.

numbers = [1, 2, 3, 4]
squared_numbers = list(map(lambda x: x ** 2, numbers))
print(squared_numbers)  # Output: [1, 4, 9, 16]
Using lambda with filter()

The filter() function filters elements based on a condition.

numbers = [1, 2, 3, 4, 5, 6]
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(even_numbers)  # Output: [2, 4, 6]
Using lambda with sorted()

The sorted() function can use lambda functions for custom sorting.

names = ["Charlie", "Alice", "Bob"]
sorted_names = sorted(names, key=lambda name: len(name))
print(sorted_names)  # Output: ['Bob', 'Alice', 'Charlie']

When to Use Lambda Functions

  • When you need a short, simple function without formally defining it.
  • When using functions like map(), filter(), or sorted().
  • When writing quick and concise expressions.

When NOT to Use Lambda Functions

  • When the function requires multiple expressions or complex logic.
  • When the function needs to be reusable in different contexts.

Function Arguments and Parameters

Functions in Python can accept arguments, which are values passed to the function to perform operations.

Positional Arguments

Positional arguments are passed in the order they are defined in the function signature.

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

print(subtract(10, 5))  # Output: 5

Keyword Arguments

Keyword arguments are passed by explicitly specifying parameter names.

def power(base, exponent):
    return base ** exponent

print(power(exponent=3, base=2))  # Output: 8

Default Arguments

Provide default values for parameters.

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

print(greet())  # Output: Hello, Guest!

Variable-Length Arguments (*args and **kwargs)

*args for multiple positional arguments:

Allows passing an arbitrary number of positional arguments.

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

print(sum_all(1, 2, 3, 4))  # Output: 10

**kwargs for multiple keyword arguments:

Allows passing an arbitrary number of keyword arguments.

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

show_info(name="Alice", age=30)

Scope and Lifetime of Variables

Local Scope

Variables declared inside a function exist only within that function.

def local_example():
    x = 10  # Local variable
    print(x)

local_example()
# print(x)  # Error! x is not accessible here

Global Scope

Variables declared outside functions are globally accessible.

global_var = "Python"

def show_global():
    print(global_var)

show_global()  # Output: Python

Using global Keyword

Modify a global variable inside a function.

global_var = 5

def modify_global():
    global global_var
    global_var = 10

modify_global()
print(global_var)  # Output: 10

Nonlocal Scope

nonlocal keyword allows modifying variables in an enclosing scope.

def outer():
    x = 10
    
    def inner():
        nonlocal x
        x += 5
    
    inner()
    print(x)

outer()  # Output: 15

Higher-Order Functions

Higher-order functions are functions that take other functions as arguments or return functions as results. They allow for functional programming paradigms, making code more reusable and modular.

Passing Functions as Arguments

Python allows functions to be passed as arguments to other functions, enabling dynamic behavior.

def apply_function(func, value):
    return func(value)

print(apply_function(lambda x: x**2, 4))  # Output: 16

Returning Functions

A function can return another function, creating closures and encapsulating behavior.

def multiplier(n):
    def multiply(x):
        return x * n
    return multiply

double = multiplier(2)
print(double(5))  # Output: 10

Using map(), filter(), and reduce()

Using map()

The map() function applies a function to all items in an iterable.

numbers = [1, 2, 3, 4]
squared_numbers = list(map(lambda x: x**2, numbers))
print(squared_numbers)  # Output: [1, 4, 9, 16]

Using filter()

The filter() function filters elements based on a condition.

numbers = [1, 2, 3, 4, 5, 6]
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(even_numbers)  # Output: [2, 4, 6]

Using reduce()

The reduce() function applies a function cumulatively to the items of an iterable.

from functools import reduce

numbers = [1, 2, 3, 4]
product = reduce(lambda x, y: x * y, numbers)
print(product)  # Output: 24

Recursion

Recursion in Python is a technique where a function calls itself to solve a smaller instance of the same problem. It consists of two main parts:

  1. Base Case – A condition that stops the recursion to prevent infinite loops.
  2. Recursive Case – The function calls itself with a modified argument, working towards the base case.

Example: Factorial Function

A classic example of recursion is the factorial function, which is defined as:

[math]n! = \begin{cases} 1, & \text{if } n = 0 \\ n \times (n-1)!, & \text{if } n > 0 \end{cases}[/math]

Implementation in Python

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

print(factorial(5))  # Output: 120

How It Works:

  1. factorial(5) calls factorial(4), which calls factorial(3), and so on.
  2. Once factorial(0) is reached, it returns 1, stopping further recursion.
  3. The function then unwinds, multiplying each return value back up the chain.

Example: Fibonacci Sequence

Another common example is the Fibonacci sequence, where:

[math]F(n) = \begin{cases} 0, & \text{if } n = 0 \\ 1, & \text{if } n = 1 \\ F(n-1) + F(n-2), & \text{if } n \geq 2 \end{cases}[/math]

Recursive Implementation

def fibonacci(n):
    if n <= 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fibonacci(n-1) + fibonacci(n-2)  # Recursive calls

print(fibonacci(6))  # Output: 8

Recursion Depth and Performance Considerations

Python has a recursion limit (default ~1000) to prevent infinite recursion.

import sys
print(sys.getrecursionlimit())  # Output: 1000

Recursion can be inefficient for problems like Fibonacci since it repeats calculations. Use memoization (functools.lru_cache) to optimize recursion using caching:

from functools import lru_cache

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

When to Use Recursion

✅ When the problem can be naturally divided into smaller subproblems (e.g., factorial, Fibonacci, tree traversal).
✅ When using divide and conquer strategies (e.g., quicksort, mergesort).
❌ Avoid when iterative solutions are more efficient (e.g., Fibonacci sequence).

Decorators

Decorators are a powerful tool that allows you to modify or extend the behavior of functions or methods without changing their actual code. They are often used for logging, enforcing access control, instrumentation, caching, and more.

Basic Concept of Decorators

A decorator is a function that takes another function as an argument and returns a new function that enhances or modifies the behavior of the original function.

Basic Syntax

def decorator_function(original_function):
    def wrapper_function():
        print("Wrapper executed before", original_function.__name__)
        original_function()
        print("Wrapper executed after", original_function.__name__)
    return wrapper_function

You apply a decorator using the @decorator_name syntax:

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

say_hello()

Equivalent Manual Application

Without using @, you would do:

say_hello = decorator_function(say_hello)
say_hello()

Using *args and **kwargs for Generalization

To handle functions with different numbers of arguments, we use *args and **kwargs:

def decorator_function(original_function):
    def wrapper_function(*args, **kwargs):
        print(f"Wrapper executed before {original_function.__name__}")
        result = original_function(*args, **kwargs)
        print(f"Wrapper executed after {original_function.__name__}")
        return result
    return wrapper_function

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

greet("Alice")

Using Decorators with Return Values

If the decorated function returns a value, the wrapper must return it too:

def decorator_function(original_function):
    def wrapper_function(*args, **kwargs):
        print("Executing before function call")
        result = original_function(*args, **kwargs)
        print("Executing after function call")
        return result  # Ensure the return value is passed along
    return wrapper_function

@decorator_function
def add(x, y):
    return x + y

result = add(3, 5)
print("Result:", result)  # Output: 8

Using functools.wraps to Preserve Metadata

Without functools.wraps, decorators may alter function metadata (like __name__, __doc__).

from functools import wraps

def decorator_function(original_function):
    @wraps(original_function)  # Preserves metadata
    def wrapper_function(*args, **kwargs):
        print("Executing before function call")
        return original_function(*args, **kwargs)
    return wrapper_function

@decorator_function
def example():
    """This is an example function."""
    print("Example function running.")

print(example.__name__)  # Output: example
print(example.__doc__)   # Output: This is an example function.

Built-in Python Decorators

Python provides several built-in decorators:

  • @staticmethod – Defines a static method inside a class.
  • @classmethod – Defines a class method.
  • @property – Defines a getter method.

Example:

class Example:
    @staticmethod
    def static_method():
        print("This is a static method.")

    @classmethod
    def class_method(cls):
        print("This is a class method.")

    @property
    def instance_property(self):
        return "This is a property"

obj = Example()
obj.static_method()
obj.class_method()
print(obj.instance_property)

Chaining Multiple Decorators

You can apply multiple decorators to a function:

def decorator1(func):
    def wrapper(*args, **kwargs):
        print("Decorator 1")
        return func(*args, **kwargs)
    return wrapper

def decorator2(func):
    def wrapper(*args, **kwargs):
        print("Decorator 2")
        return func(*args, **kwargs)
    return wrapper

@decorator1
@decorator2
def my_function():
    print("Original function execution")

my_function()

Execution Order

The decorators are applied bottom to top, meaning decorator2 runs first, then decorator1, and finally the original function.

Conclusion

Functions in Python are powerful tools that allow for modular, efficient, and readable code. From basic syntax to advanced topics like decorators and recursion, mastering functions is crucial for Python development. Following best practices ensures code clarity and maintainability.

Anastasios Antoniadis
Find me on
Latest posts by Anastasios Antoniadis (see all)

Leave a Comment