• Python
Python Fundamentals
  • Python Variables
  • Python Operators
  • Python Input-Output
  • Python Type Conversion
Python Data Types
  • Python Strings
  • Python List
  • Python Tuple
  • Python Dictionnaries
  • Python Sets
Python Flow Control
  • Python Conditions
  • Python For Loop
  • Python While Loop
  • Python Break and Continue
Python Functions
  • Python Functions
  • Python Arguments
  • Python Functions Scope
  • Python Recursion
Python Classes
  • Python Classes
  • Python Classes and Static Methods
  • Python Properties
  • Python Decorators
  • Python Error Handling

Create an Account

FREE

Join our community to access more courses.

Create Account
  • Pricing
  • Blog

On this page

Adding Functionality to Functions: Understanding DecoratorsFunctions Inside Functions: Local FunctionsFunctions as First-Class ObjectsType Hinting Functions: CallableWhat Is a Decorator?Using a Decorator: The @ SyntaxHow Decorators Work (Step by Step)Writing Your Own DecoratorDecorators with ArgumentsStacking Multiple DecoratorsAdvanced Type Hinting with ParamSpecWhy Use Decorators?Common Beginner MistakesSummary

Python Decorators

Adding Functionality to Functions: Understanding Decorators

In Python, you might want to add extra features to a function - like logging, timing, or checking permissions - without changing the function's code. Decorators make this possible. They let you "wrap" a function with another function to add new behavior in a clean, reusable way.

This article will show you what decorators are, how to use them, and how to write your own. Before we dive into decorators, let's cover some foundational concepts about functions in Python.

Functions Inside Functions: Local Functions

Python allows you to define functions inside other functions. These are called local functions or nested functions. They can access variables from the outer function's scope.

Example:

def outer_function():
    message = "Hello from the outer function!"

    def inner_function(): # This is a local function
        print(message) # inner_function can access message

    inner_function() # Call the local function from inside the outer function

outer_function() # Output: Hello from the outer function!
# inner_function() # ERROR: NameError - inner_function is not defined outside outer_function
  • inner_function exists only while outer_function is running.
  • Local functions are useful for tasks that are only needed within another function.

Functions as First-Class Objects

In Python, functions are first-class objects. This means you can treat functions like any other value (like a number or a string). You can:

  • Assign a function to a variable.
  • Pass a function as an argument to another function.
  • Return a function from another function.

Example: Passing a Function as an Argument

def execute_function(func):
    # execute_function takes a function 'func' as an argument
    func() # Call the function that was passed in

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

execute_function(say_hello) # Pass say_hello function as an argument
# Output: Hello!

Example: Returning a Function from Another Function

def create_greeter(greeting):
    def greeter(name): # Define a local function
        print(f"{greeting}, {name}!")
    return greeter # Return the local function

say_hi = create_greeter("Hi") # say_hi now holds the greeter function
say_hi("Alice") # Output: Hi, Alice!

say_yo = create_greeter("Yo") # say_yo holds a different greeter
say_yo("Bob")  # Output: Yo, Bob!

These abilities (local functions and treating functions as objects) are key to how decorators work.

Type Hinting Functions: Callable

When you use type hints, you can show that a variable or parameter is expected to be a function using the Callable type from the typing module.

Callable is used to specify the signature of the function (the types of its arguments and its return type).

Syntax: Callable[[ArgType1, ArgType2, ...], ReturnType]

  • [ArgType1, ArgType2, ...]: A list of the expected types of the function's arguments.
  • ReturnType: The expected return type of the function.
  • Use [] if the function takes no arguments.
  • Use Callable[..., ReturnType] if the function can take any arguments.

Example:

from typing import Callable

# Type hint for a function that takes a string and returns an integer
def process_text(func: Callable[[str], int], text: str) -> int:
    return func(text)

def string_length(s: str) -> int:
    return len(s)

# process_text expects a function that takes str and returns int
length = process_text(string_length, "hello")
print(length) # Output: 5

# Type hint for a function that takes no arguments and returns None
def run_task(task: Callable[[], None]) -> None:
    task()

def say_done() -> None:
    print("Done!")

run_task(say_done) # Output: Done!

Understanding Callable helps make your code clearer when working with functions that take or return other functions.

What Is a Decorator?

A decorator is a function that takes another function as input and returns a new function that usually adds something before or after the original function runs. Decorators are a powerful way to extend or modify the behavior of functions (and sometimes classes) without changing their code.

Decorators are often used for:

  • Logging
  • Timing how long a function takes
  • Checking user permissions
  • Repeating code before/after many functions

Using a Decorator: The @ Syntax

Python makes it easy to apply a decorator using the @decorator_name syntax, which is called a "pie" syntax because it looks like a pie.

Example: Using a Built-in Decorator

def shout(func: Callable[[], None]) -> Callable[[], None]:
    def wrapper() -> None:
        print("Before the function runs!")
        func()
        print("After the function runs!")
    return wrapper

@shout
def greet() -> None:
    print("Hello!")

greet()
# Output:
# Before the function runs!
# Hello!
# After the function runs!
  • @shout applies the shout decorator to greet.
  • When you call greet(), you actually call the wrapper function inside shout.

How Decorators Work (Step by Step)

  1. You define a decorator function that takes another function as an argument.
  2. Inside the decorator, you define a new function (the "wrapper") that does something extra and then calls the original function.
  3. The decorator returns the wrapper function.
  4. When you use @decorator, Python replaces the original function with the wrapper.

Writing Your Own Decorator

Example: A Simple Logging Decorator

from typing import Callable, Any

def log_call(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        print(f"Calling {func.__name__}()...")
        result = func(*args, **kwargs)
        print(f"Finished {func.__name__}()!")
        return result
    return wrapper

@log_call
def say_hi(name: str) -> None:
    print(f"Hi, {name}!")

say_hi("Alice")
# Output:
# Calling say_hi()...
# Hi, Alice!
# Finished say_hi()!

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

sum_result = add(5, 3)
print(sum_result)
# Output:
# Calling add()...
# Finished add()!
# 8

Decorators with Arguments

If your function takes arguments, the wrapper should accept them too, using *args and **kwargs. (This is already covered and improved in the previous section's example).

def repeat(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        func(*args, **kwargs)
        func(*args, **kwargs)
    return wrapper

@repeat
def greet(name: str) -> None:
    print(f"Hello, {name}!")

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

Stacking Multiple Decorators

You can apply more than one decorator to a function. They are applied from the closest to the function outward.

@decorator_one
@decorator_two
def my_func():
    pass
# This is the same as: my_func = decorator_one(decorator_two(my_func))

Advanced Type Hinting with ParamSpec

When you use type hints with decorators, especially for functions that take various arguments, the simple Callable[..., Any] can make you lose the specific type information of the original function's arguments. This is where ParamSpec comes in.

ParamSpec is used to forward the parameter types of one callable to another callable. This helps type checkers understand that the wrapper function in your decorator has the exact same arguments as the original function it wraps.

Example:

from typing import Callable, Any, ParamSpec

P = ParamSpec('P') # Define a ParamSpec variable
T = Any         # Define a type variable for return type (or use TypeVar)

# Now, we can hint the decorator more accurately
def simple_decorator(func: Callable[P, T]) -> Callable[P, T]:
    # The wrapper now explicitly takes the same parameters (P) and returns the same type (T)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        print("Something before!")
        result = func(*args, **kwargs)
        print("Something after!")
        return result
    return wrapper

@simple_decorator
def add_numbers(a: int, b: int) -> int:
    return a + b

@simple_decorator
def say_word(word: str) -> None:
    print(word)

# Type checkers now know the signatures are preserved:
sum_result = add_numbers(10, 20) # Type checker knows add_numbers takes two ints and returns int
print(sum_result) # Output: Something before!
# 30
# Something after!

say_word("hello") # Type checker knows say_word takes a string and returns None
# Output: Something before!
# hello
# Something after!
  • P = ParamSpec('P') creates a placeholder for parameter specification.
  • Callable[P, T] indicates a function that takes parameters specified by P and returns type T.
  • The wrapper function's signature (*args: P.args, **kwargs: P.kwargs) -> T ensures the type checker knows it matches the decorated function's signature.

Using ParamSpec provides more precise type hinting for complex decorators, improving static analysis and code clarity.

Why Use Decorators?

  • Code reuse: Add the same feature to many functions without repeating code.
  • Separation of concerns: Keep extra logic (like logging) out of your main function code.
  • Cleaner code: Make your code easier to read and maintain.

Common Beginner Mistakes

  • Forgetting to return the wrapper function from the decorator.
  • Not using *args and **kwargs in the wrapper, so the decorator only works for functions with no arguments.
  • Losing the original function's name and docstring (can be fixed with functools.wraps).

Example of a mistake:

from typing import Any

def bad_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        print("Something!")
    # Forgot to call func() and forgot to return wrapper
    return wrapper # Added return to make it a valid (though simple) decorator

@bad_decorator
def test_func(x: int) -> None:
    print(f"Original function called with: {x}")

test_func(10)
# Output:
# Something!
# (Original function is not called)

Summary

  • Decorators are functions that add extra behavior to other functions.
  • Use @decorator_name to apply a decorator.
  • Decorators help you reuse code and keep your functions clean.
  • Always use *args and **kwargs in your wrapper if the function takes arguments.

Continue Learning

Removing Duplicates

Popular

Personalized Recommendations

Log in to get more relevant recommendations based on your reading history.