Decorators are one of Python’s most powerful features, but many data scientists avoid them because they seem complex. In reality, decorators follow a simple pattern, and once you understand them, you’ll find dozens of practical uses: caching expensive computations, adding logging to functions, validating inputs, timing model training, and retry logic for API calls. This guide demystifies decorators with real data science examples.
How Decorators Work – The Core Pattern
A decorator is a function that takes another function as input, wraps it with extra behaviour, and returns a new function. Python’s @ syntax is just syntactic sugar for this pattern:
# These two are exactly equivalent:
@my_decorator
def my_function():
pass
# Is the same as:
def my_function():
pass
my_function = my_decorator(my_function)
Writing Your First Decorator
import functools
def timer(func):
@functools.wraps(func) # preserves the original function's name and docstring
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"[TIMER] {func.__name__} took {end - start:.4f}s")
return result
return wrapper
@timer
def train_model(X, y):
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(n_estimators=200)
model.fit(X, y)
return model
model = train_model(X_train, y_train)
# Output: [TIMER] train_model took 3.2145s
Caching with functools.lru_cache and cache
The most commonly useful decorator for data scientists is caching. When a function is expensive and you call it repeatedly with the same inputs:
from functools import lru_cache, cache
@cache # Python 3.9+ — unlimited cache
def get_embedding(text: str) -> list:
'''Call expensive embedding API.'''
response = embedding_api.encode(text)
return response.tolist()
# First call hits the API, subsequent calls return cached result instantly
emb1 = get_embedding("machine learning tutorial") # API call
emb2 = get_embedding("machine learning tutorial") # cached!
@lru_cache(maxsize=1000) # Limit cache to 1000 entries
def load_feature_store(date: str) -> dict:
return read_from_s3(f"features/{date}.parquet")
Retry Decorator for API Calls
import time, functools
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts - 1:
raise
wait = delay * (2 ** attempt) # exponential backoff
print(f"Attempt {attempt+1} failed: {e}. Retrying in {wait:.1f}s...")
time.sleep(wait)
return wrapper
return decorator
@retry(max_attempts=3, delay=1.0, exceptions=(requests.RequestException,))
def call_llm_api(prompt: str) -> str:
response = requests.post("https://api.example.com/generate", json={"prompt": prompt})
response.raise_for_status()
return response.json()["text"]
Validation Decorator
def validate_dataframe(required_cols):
def decorator(func):
@functools.wraps(func)
def wrapper(df, *args, **kwargs):
import pandas as pd
if not isinstance(df, pd.DataFrame):
raise TypeError(f"Expected DataFrame, got {type(df)}")
missing = set(required_cols) - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
return func(df, *args, **kwargs)
return wrapper
return decorator
@validate_dataframe(required_cols=['age', 'income', 'credit_score'])
def preprocess_features(df):
df['age_bin'] = pd.cut(df['age'], bins=[18, 25, 35, 50, 65, 100])
return df
Logging Decorator
import logging, functools
logging.basicConfig(level=logging.INFO)
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__} with args={args[:2]}, kwargs={list(kwargs.keys())}")
result = func(*args, **kwargs)
logging.info(f"{func.__name__} completed")
return result
return wrapper
@log_calls
@timer
def feature_engineering(df, target_col):
# Both decorators applied: first logs, then times
...
Class-Based Decorators
class RateLimiter:
def __init__(self, calls_per_second=1):
self.min_interval = 1.0 / calls_per_second
self.last_called = 0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - self.last_called
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self.last_called = time.time()
return func(*args, **kwargs)
return wrapper
@RateLimiter(calls_per_second=5)
def fetch_stock_price(ticker: str):
return yfinance.download(ticker, period="1d")
Conclusion
Decorators are not magic — they're just functions that wrap other functions. Once you see the pattern, you'll find yourself reaching for them constantly: caching expensive computations, adding retry logic to unreliable API calls, validating DataFrame schemas, and timing slow pipeline steps. The four lines of boilerplate (def decorator(func), @functools.wraps(func), def wrapper(*args, **kwargs), return wrapper) are worth memorising — they unlock a cleaner, more maintainable codebase.


