Python for AIPython 3.12 · Pydantic 2.12
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
43 small wins to finish your pathNext lesson →

Decorators

A decorator is a function that takes another function and returns a new one wrapping it, so you can add behaviour before or after the original runs, applied with the @ syntax above a definition.

Last updated: 13 Oct, 2026 · Python 3.12

The Iterators and generators lesson wrote functions that pause and resume. This lesson writes functions that take and return other functions, so one piece of behaviour, such as logging or a retry, can be added to many functions without rewriting each one.

A decorator is built in three small steps: a function is a value you can pass around, an inner function can remember that value even after the outer one returns (a closure), and a decorator is that closure pattern applied with @. The lesson walks the three in that order, with a main_welcome wrapper around a course_introduction function.

A function is a value you can pass around

A function name is only a variable that holds the function. You can give it a second name, pass it to another function, or return it. Here wel is made to point at the same function as welcome, and calling wel() runs it.

ExampleA function is a value, so it can be given another name
def welcome():
    return "Welcome to the advanced Python course"

wel = welcome        # a second name for the same function
print(wel())

wel = welcome did not call anything; it copied the reference, so wel and welcome are two names for the one function. Because a function can be passed around like this, another function can take one as an argument and build a new function around it.

An inner function that remembers: a closure

A closure is an inner function that remembers a value from the function that made it, even after that outer function has returned. main_welcome takes a function func, defines sub_welcome_method inside that calls func between two lines, and returns that inner function. The inner function keeps hold of func.

Closures, the step before a decorator · from Advance Python-Decorators Indepth Implementation · 4:58 to 7:44
From the video. The clip builds the closure in a Jupyter notebook: an outer main_welcome with an inner method that reaches the value around it. The page returns the inner function by name so you can call it yourself, which is the step right before the @.
ExampleA closure that wraps course_introduction, called by hand
def main_welcome(func):
    def sub_welcome_method():
        print("Welcome to the advanced Python course")
        func()
        print("Please learn these concepts properly")
    return sub_welcome_method        # return the function, not sub_welcome_method()

def course_introduction():
    print("This is an advanced Python course")

greet = main_welcome(course_introduction)   # build the wrapped function
greet()                                      # then call it

main_welcome(course_introduction) returned sub_welcome_method, which had captured func as course_introduction. Calling greet() ran the inner function: it printed a line, called the captured func, and printed another. This is a decorator built by hand; the @ is a shorthand for exactly this.

Applying it with @

Writing @main_welcome above a definition is shorthand for course_introduction = main_welcome(course_introduction): it passes the function to main_welcome and rebinds the name to the wrapped version. The manual greet = main_welcome(...) step disappears.

Defining a decorator with @ · from Advance Python-Decorators Indepth Implementation · 13:15 to 15:15
From the video. The clip writes the decorator form live: to define a decorator ... at the rate and we can write main_welcome ... this is how you manually create a decorator. The @main_welcome line does the same wrapping the hand-written step above did.
ExampleThe same wrapper, applied with @
# @main_welcome is the same as course_introduction = main_welcome(course_introduction)
@main_welcome
def course_introduction():
    print("This is an advanced Python course")

course_introduction()

@main_welcome replaced course_introduction with the wrapped version. Calling course_introduction() now runs sub_welcome_method: it prints the opening line, calls the original course_introduction in the middle, and prints the closing line. The original function was never edited.

A common mistake: returning the call, not the function

The one error everyone hits is returning sub_welcome_method() with parentheses instead of sub_welcome_method. That calls the inner function immediately, at the moment of decoration, and returns whatever it produced, often None. The decorated name then holds that value, not a function.

python
    return sub_welcome_method        # correct: hand back the function itself
    return sub_welcome_method()      # the bug: call it now and return its result
ExampleWhen main_welcome returns sub_welcome_method() instead of the function
# main_welcome here ends in return sub_welcome_method() by mistake
@main_welcome
def course_introduction():
    print("This is an advanced Python course")

print("course_introduction is now:", course_introduction)

The three lines printed while the file was being read, because @main_welcome ran sub_welcome_method() on the spot. It returned None, so course_introduction became None, not a function, and calling it would raise. The fix is one character: return sub_welcome_method, the function itself, not sub_welcome_method().

The general shape: wrapper(*args, **kwargs)

main_welcome wrapped a function that takes no arguments. To wrap any function, the inner function takes *args, **kwargs and passes them straight through, and it returns the original's result. This is the shape you will write most often.

python
def decorate(func):
    def wrapper(*args, **kwargs):
        # extra behaviour goes here
        return func(*args, **kwargs)
    return wrapper          # return the function, not wrapper()
A decorator wrapping a function. The function add(a, b) is decorated with @announce. The decorator returns a wrapper that runs extra lines around the original: it prints a line before, calls the original add(a, b) in the middle, then prints a line after and returns the original result. Calling the decorated add now runs the whole wrapper.

announce below wraps any function so it prints a line before and after the call. *args, **kwargs let the wrapper pass through whatever arguments the original takes, and return result hands back whatever the original returned.

ExampleA decorator that prints around every call and returns the result
def announce(func):
    def wrapper(*args, **kwargs):   # pass through whatever add takes
        print("calling", func.__name__)
        result = func(*args, **kwargs)
        print("done")
        return result
    return wrapper

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

print(add(2, 3))

@announce replaced add with the wrapper. Calling add(2, 3) ran the wrapper: it printed before, called the real add to get 5, printed after, and returned that 5. Because the wrapper takes *args, **kwargs, the same decorator works on a function with any arguments.

A decorator that takes arguments

To pass an argument to the decorator itself, add one more layer: a function that takes the argument and returns a decorator. repeat(3) returns a decorator that runs the wrapped function three times.

python
def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator
ExampleA decorator built from an argument
@repeat(3)
def ping():
    print("ping")

ping()

repeat(3) ran first and returned a decorator, which then wrapped ping. One call to ping() ran the body three times. The outer layer captured times as a closure, the same idea the lesson opened with.

A retry decorator

The pattern that earns its keep in AI code is a retry: call a function, and if it raises, try again up to a limit. The flaky function here fails twice and succeeds on the third try. The Timeouts and retries lesson takes this further for real model calls.

python
def retry(attempts):
    def decorator(func):
        def wrapper(*args, **kwargs):
            # return on the first call that does not raise; report each failure
            for attempt in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except ValueError as error:
                    print(f"attempt {attempt} failed: {error}")

            raise RuntimeError(f"gave up after {attempts} attempts")
        return wrapper
    return decorator
ExampleRetrying a call that fails twice before it works
calls = {"n": 0}            # count attempts; the first two fail

@retry(attempts=3)
def flaky_call():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ValueError("model timed out")
    return "model replied"

print(flaky_call())
  • attempt 1 failed and attempt 2 failed: the first two calls raised ValueError, which the wrapper caught and reported.
  • model replied: the third call succeeded, so wrapper returned its result and stopped retrying.
  • Had all three failed, the loop would have ended and the wrapper raised RuntimeError with the give-up message.

By hand vs with a decorator

written by handwith a decorator
Where the extra code livespasted into every functiononce, in the decorator
Changing the behaviouredit every functionedit the decorator
Reading the functionmixed with the extra codestays about its own job
Adding it to one more functioncopy the code againadd one @ line

Where decorators show up in AI code

  • Retrying a model or API call that fails now and then, the decorator built above, taken further in the Timeouts and retries lesson.
  • Timing or logging every call to a tool a model can use, without touching the tool's own code.
  • Registering a function as a tool or a route, which many frameworks do with a decorator you put above the function.
Watch out. Two traps. Return the inner function, such as wrapper or sub_welcome_method, never the call with (), or the decorated name becomes a value instead of a function. And give the wrapper *args, **kwargs, or it breaks the moment it wraps a function that takes arguments. The wrapper also takes over the original's __name__; functools.wraps in the standard library copies it back when that matters.
Try it yourself
  • Change @repeat(3) to @repeat(2) and run ping(); it prints twice.
  • Set @retry(attempts=2) and make flaky_call fail three times; the wrapper raises RuntimeError.
  • Return sub_welcome_method() from main_welcome and print the decorated course_introduction; it becomes None.

Every expert started right here.