Python Decorators Explained with Examples: Logging, Timing, Retry, Access Control and Caching

Have you ever seen a line like @app.route("/") in Flask or @login_required in Django and wondered what that little @ symbol actually does? That is a decorator, one of Python's most powerful features. A decorator lets you add extra behavior to a function, such as logging, timing, retrying or permission checks, without changing the function's own code.

In this tutorial, you will learn Python decorators step by step. We will start with the basic idea, then build practical decorators you can reuse in your own projects: a logger, a timer, a retry decorator with arguments, a role-based access check, and caching with functools.lru_cache. Every example is a complete script that was run on Python 3.13, and the output shown is the real output.

Requirements

  • Python 3.8 or newer (examples tested on Python 3.13).
  • Any code editor: VS Code, PyCharm, IDLE or even Notepad++.
  • Basic knowledge of Python functions.

Why Decorators Work: Functions Are Objects

In Python, a function is an object just like a number or a string. You can store it in a variable, pass it to another function, and return it from a function. A decorator is simply a function that takes a function and returns a new function, usually a "wrapper" that runs some code before and after calling the original.

Step 1: Your First Decorator

Create a file named basic_decorator.py. The shout decorator calls the original function and converts its result to uppercase:

  1. def shout(func):
  2.     """A simple decorator that upper-cases the result of a function."""
  3.     def wrapper():
  4.         result = func()
  5.         return result.upper()
  6.     return wrapper
  7.  
  8.  
  9. def greet():
  10.     return "hello, sourcecodester!"
  11.  
  12.  
  13. # 1) Decorating "by hand"
  14. loud_greet = shout(greet)
  15. print(loud_greet())
  16.  
  17.  
  18. # 2) The same thing using the @ syntax
  19. @shout
  20. def welcome():
  21.     return "welcome to python decorators"
  22.  
  23.  
  24. print(welcome())

Run it with python basic_decorator.py. Output:

HELLO, SOURCECODESTER!
WELCOME TO PYTHON DECORATORS

Both calls give the same kind of result. Writing @shout above def welcome() is just a shortcut for welcome = shout(welcome). Once you understand that one line, decorators stop being magic.

Step 2: Handling Arguments and Keeping the Function's Identity

The first wrapper only works for functions with no arguments. Real decorators should accept any arguments using *args and **kwargs, then pass them along. You should also use @functools.wraps(func). Without it, the decorated function would be renamed to wrapper and lose its docstring, which confuses debuggers, documentation tools and frameworks. Save this as wraps_example.py:

  1. import functools
  2.  
  3.  
  4. def log_calls(func):
  5.     @functools.wraps(func)  # keeps the original name and docstring
  6.     def wrapper(*args, **kwargs):
  7.         arg_list = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()]
  8.         print(f"Calling {func.__name__}({', '.join(arg_list)})")
  9.         result = func(*args, **kwargs)
  10.         print(f"{func.__name__} returned {result!r}")
  11.         return result
  12.     return wrapper
  13.  
  14.  
  15. @log_calls
  16. def add(a, b):
  17.     """Return the sum of two numbers."""
  18.     return a + b
  19.  
  20.  
  21. @log_calls
  22. def make_username(first, last, sep="_"):
  23.     """Build a lowercase username."""
  24.     return f"{first}{sep}{last}".lower()
  25.  
  26.  
  27. add(3, 4)
  28. make_username("Juan", "Cruz", sep=".")
  29. print("Function name:", add.__name__)
  30. print("Docstring:", add.__doc__)

Run it with python wraps_example.py. Output:

Calling add(3, 4)
add returned 7
Calling make_username('Juan', 'Cruz', sep='.')
make_username returned 'juan.cruz'
Function name: add
Docstring: Return the sum of two numbers.

The log_calls decorator works with positional arguments, keyword arguments and any return value. Thanks to functools.wraps, add.__name__ is still add and its docstring is preserved. Make it a habit to add @functools.wraps to every decorator you write.

Step 3: A Timer Decorator to Measure Slow Code

When a page or report feels slow, the first question is "which function is taking the time?" A timer decorator answers that without adding timing code inside each function. Save this as timer_decorator.py:

  1. import functools
  2. import time
  3.  
  4.  
  5. def timer(func):
  6.     @functools.wraps(func)
  7.     def wrapper(*args, **kwargs):
  8.         start = time.perf_counter()
  9.         result = func(*args, **kwargs)
  10.         elapsed = time.perf_counter() - start
  11.         print(f"{func.__name__} took {elapsed:.2f} seconds")
  12.         return result
  13.     return wrapper
  14.  
  15.  
  16. @timer
  17. def slow_report(rows):
  18.     time.sleep(0.5)  # pretend we are reading from a slow database
  19.     return f"Report with {rows} rows is ready"
  20.  
  21.  
  22. print(slow_report(120))

Run it with python timer_decorator.py. Output:

slow_report took 0.50 seconds
Report with 120 rows is ready

We used time.perf_counter() because it is the most precise clock for measuring short durations. The exact number on your computer may be slightly different, but it will be close to 0.50 seconds because of the time.sleep(0.5) call.

Step 4: Decorators with Arguments (Retry Example)

Sometimes you want to configure a decorator, like @retry(times=3). To do that, you need one more level: a decorator factory, a function that receives the settings and returns the actual decorator. This is very useful for network calls or APIs that fail now and then. Save this as retry_decorator.py:

  1. import functools
  2.  
  3.  
  4. def retry(times=3, exceptions=(Exception,)):
  5.     """Decorator factory: retry the function up to `times` attempts."""
  6.     def decorator(func):
  7.         @functools.wraps(func)
  8.         def wrapper(*args, **kwargs):
  9.             for attempt in range(1, times + 1):
  10.                 try:
  11.                     return func(*args, **kwargs)
  12.                 except exceptions as error:
  13.                     print(f"Attempt {attempt} failed: {error}")
  14.                     if attempt == times:
  15.                         raise
  16.         return wrapper
  17.     return decorator
  18.  
  19.  
  20. calls = {"count": 0}
  21.  
  22.  
  23. @retry(times=3, exceptions=(ConnectionError,))
  24. def fetch_exchange_rate():
  25.     calls["count"] += 1
  26.     if calls["count"] < 3:          # fail the first two times
  27.         raise ConnectionError("server not responding")
  28.     return 58.25
  29.  
  30.  
  31. print("Exchange rate (sample value):", fetch_exchange_rate())
  32.  
  33.  
  34. @retry(times=2, exceptions=(ValueError,))
  35. def parse_age(text):
  36.     return int(text)
  37.  
  38.  
  39. try:
  40.     parse_age("twenty")
  41. except ValueError:
  42.     print("Gave up: 'twenty' is not a number")

Run it with python retry_decorator.py. Output:

Attempt 1 failed: server not responding
Attempt 2 failed: server not responding
Exchange rate (sample value): 58.25
Attempt 1 failed: invalid literal for int() with base 10: 'twenty'
Attempt 2 failed: invalid literal for int() with base 10: 'twenty'
Gave up: 'twenty' is not a number

Here is what happens: retry(times=3, exceptions=(ConnectionError,)) runs first and returns decorator, which then wraps fetch_exchange_rate. Our fake API fails twice and succeeds on the third attempt. The second example shows that when every attempt fails, the decorator re-raises the last error so your program can still handle it. Notice that we only retry specific exceptions; retrying every possible error can hide real bugs.

Step 5: A Role-Based Access Decorator

Web frameworks use decorators to protect pages, for example @login_required. Let's build a simple version that checks a user's role before running a function. This pattern is handy for school systems, inventory apps and admin panels. Save it as role_required.py:

  1. import functools
  2.  
  3.  
  4. class PermissionDenied(Exception):
  5.     pass
  6.  
  7.  
  8. def role_required(*allowed_roles):
  9.     def decorator(func):
  10.         @functools.wraps(func)
  11.         def wrapper(user, *args, **kwargs):
  12.             if user["role"] not in allowed_roles:
  13.                 raise PermissionDenied(
  14.                     f"{user['name']} ({user['role']}) cannot run {func.__name__}"
  15.                 )
  16.             return func(user, *args, **kwargs)
  17.         return wrapper
  18.     return decorator
  19.  
  20.  
  21. @role_required("admin")
  22. def delete_student(user, student_id):
  23.     return f"Student #{student_id} deleted by {user['name']}"
  24.  
  25.  
  26. @role_required("admin", "teacher")
  27. def encode_grade(user, student_id, grade):
  28.     return f"Grade {grade} saved for student #{student_id} by {user['name']}"
  29.  
  30.  
  31. admin = {"name": "Maria", "role": "admin"}
  32. teacher = {"name": "Jose", "role": "teacher"}
  33.  
  34. print(delete_student(admin, 101))
  35. print(encode_grade(teacher, 101, 1.75))
  36. try:
  37.     delete_student(teacher, 101)
  38. except PermissionDenied as error:
  39.     print("Access denied:", error)

Run it with python role_required.py. Output:

Student #101 deleted by Maria
Grade 1.75 saved for student #101 by Jose
Access denied: Jose (teacher) cannot run delete_student

The permission rule now lives in one place. If you later decide that registrars can also encode grades, you only change the decorator line: @role_required("admin", "teacher", "registrar"). The functions themselves stay clean and focused on their real job.

Step 6: Built-in Caching and Stacking Decorators

Python already ships with useful decorators. functools.lru_cache remembers the results of previous calls, so an expensive function with the same arguments runs only once. You can also stack decorators; they are applied from the bottom up. Save this as cache_example.py:

  1. import functools
  2.  
  3.  
  4. @functools.lru_cache(maxsize=None)
  5. def fibonacci(n):
  6.     if n < 2:
  7.         return n
  8.     return fibonacci(n - 1) + fibonacci(n - 2)
  9.  
  10.  
  11. print("fibonacci(80) =", fibonacci(80))
  12. print(fibonacci.cache_info())
  13.  
  14.  
  15. def count_calls(func):
  16.     @functools.wraps(func)
  17.     def wrapper(*args, **kwargs):
  18.         wrapper.calls += 1
  19.         return func(*args, **kwargs)
  20.     wrapper.calls = 0
  21.     return wrapper
  22.  
  23.  
  24. @count_calls
  25. @functools.lru_cache(maxsize=None)
  26. def square(n):
  27.     return n * n
  28.  
  29.  
  30. for n in [2, 3, 2, 2, 3]:
  31.     square(n)
  32. print("square() was called", square.calls, "times")
  33. # functools.wraps exposes the inner (cached) function as __wrapped__
  34. print(square.__wrapped__.cache_info())

Run it with python cache_example.py. Output:

fibonacci(80) = 23416728348467685
CacheInfo(hits=78, misses=81, maxsize=None, currsize=81)
square() was called 5 times
CacheInfo(hits=3, misses=2, maxsize=None, currsize=2)

Without the cache, a recursive fibonacci(80) would take practically forever. With lru_cache, it only computed 81 unique values. In the stacked example, count_calls counted all 5 calls, but the cache shows only 2 misses, meaning the real calculation ran just twice. Because count_calls is on top, it wraps the cached function, and we reach the cache through __wrapped__, an attribute that functools.wraps adds for us.

Other built-in decorators you will meet often are @property, @staticmethod, @classmethod and @dataclasses.dataclass.

Common Decorator Mistakes

  • Forgetting to return the result. If the wrapper does not return func(*args, **kwargs), the decorated function will always return None.
  • Forgetting functools.wraps. Your function's name and docstring get lost, and some frameworks (like Flask routes) can even raise errors about duplicate endpoint names.
  • Calling the function instead of passing it. Use @shout, not @shout(), unless the decorator is a factory that takes arguments like @retry(times=3).
  • Hiding errors. A decorator that catches every exception and returns nothing makes bugs very hard to find. Catch only what you expect, and re-raise when appropriate.

Conclusion

A decorator is just a function that wraps another function, and the @ syntax is a clean shortcut for applying it. With the patterns in this tutorial, logging, timing, retrying, access checks and caching, you can remove a lot of repeated code from your projects and keep your functions short and readable. Try writing your own decorator next, for example one that validates that all arguments are positive numbers. You can download all six example scripts below.

Happy coding!