Python has always been a language that prioritizes readability and developer happiness. Over the years, its standard library has grown with tools that help developers write more expressive, concise, and efficient code. One such area of the standard library that deserves attention—especially with Python 3.12 and beyond—is the functools module.
You might already know functools.partial. It’s been around for a while and allows partial function application—essentially “pre-loading” certain arguments of a function and creating a new callable. But Python 3.12 introduced a new, more flexible version: partial as a class. This opens the door to subclassing and some really powerful custom behaviors.
Alongside that, Python has gifted us another gem: functools.cache_property. If you’ve ever used @property in a class and found yourself wishing it could cache its result automatically—without having to use @functools.lru_cache hacks—this is exactly what you were dreaming of.
In this tutorial, we’ll dig deep into both these tools—step by step—and build up your intuition and your toolbox for writing more efficient, readable, and Pythonic code.
Part 1: Understanding partial (the new class-based version)
Let’s kick things off with a refresher.
A Quick Refresher on functools.partial
The classic partial function has been around since Python 2.5. It’s often used to freeze some portion of a function’s arguments and keywords, resulting in a new function.
Here’s a basic example:
from functools import partial
def multiply(x, y):
return x * y
double = partial(multiply, 2)
print(double(5)) # Output: 10Code language: Python (python)This is great for simplifying higher-order functions or preparing pre-filled versions of a function for reuse. But it was limited—partial was just a function, not something you could subclass or extend easily.
Enter functools.partial as a Class
As of Python 3.12, partial is now implemented as a class, not just a function.
So what does that mean for us?
- We can subclass it.
- We can customize its behavior.
- We can introspect and manipulate it more flexibly.
Let’s see how.
Creating Custom Partial Functions (Subclassing)
Let’s say you’re working in a large codebase and want to track all the calls made through a certain subset of pre-configured functions—maybe for logging or analytics.
You can now subclass partial like so:
from functools import partial
class LoggingPartial(partial):
def __call__(self, *args, **kwargs):
print(f"Calling {self.func.__name__} with {args} and {kwargs}")
return super().__call__(*args, **kwargs)
def greet(greeting, name):
return f"{greeting}, {name}!"
hi = LoggingPartial(greet, "Hi")
print(hi("Alice")) # Output includes loggingCode language: Python (python)This is powerful. You now have an easy way to inject cross-cutting concerns (like logging, timing, metrics, etc.) into your partials.
Want to track performance? Easy.
import time
class TimedPartial(partial):
def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = super().__call__(*args, **kwargs)
end = time.perf_counter()
print(f"{self.func.__name__} took {end - start:.4f} seconds")
return resultCode language: Python (python)This kind of subclassing was not possible before 3.12.
Exploring Attributes of partial
When you use the new class-based partial, you can inspect its arguments and the original function easily:
p = partial(multiply, 2)
print(p.func) # <function multiply>
print(p.args) # (2,)
print(p.keywords) # {}Code language: Python (python)This can be especially useful for debugging or building higher-order utilities that inspect and adapt function behavior dynamically.
Composition with partial
Ever needed to create factory-like behavior? Combine partial with higher-order functions:
def make_tag(tag_name):
def wrapper(content):
return f"<{tag_name}>{content}</{tag_name}>"
return wrapper
bold = partial(make_tag, "strong")
print(bold()("hello")) # <strong>hello</strong>Code language: Python (python)This is elegant and lends itself well to DSLs (Domain Specific Languages), web frameworks, and UI generation.
Partial in Functional Programming Pipelines
Let’s take a slightly more complex example—a data transformation pipeline.
from functools import partial
def transform(data, multiplier=1, offset=0):
return [x * multiplier + offset for x in data]
# Now build different pipelines
double_and_add_1 = partial(transform, multiplier=2, offset=1)
scale_down = partial(transform, multiplier=0.5)
data = [10, 20, 30]
print(double_and_add_1(data)) # [21, 41, 61]
print(scale_down(data)) # [5.0, 10.0, 15.0]Code language: Python (python)By preconfiguring partials, you can clean up the logic in your code, reduce duplication, and keep your business logic clean.
Part 2: functools.cache_property — Caching Made Beautiful
We all love @property for its elegant way of encapsulating logic behind attribute-like access. But when the computation is expensive and doesn’t need to be re-run every time, caching becomes necessary.
Historically, this meant using @functools.lru_cache, or writing your own boilerplate inside the getter. But no more.
Introducing @functools.cache_property
As of Python 3.12, we get a beautiful decorator: @cache_property.
Let’s see it in action.
from functools import cache_property
class DataFetcher:
def __init__(self, source):
self.source = source
@cache_property
def expensive_computation(self):
print("Running expensive computation...")
# Simulate some heavy lifting
return sum(i * i for i in range(100000))
df = DataFetcher("db")
print(df.expensive_computation) # Triggers computation
print(df.expensive_computation) # Cached result, no printCode language: Python (python)Boom. No need for custom caching code. It’s like @property, but with built-in memory.
How It Works Behind the Scenes
cache_property is basically a read-only, one-time-computed property.
- It’s non-overridable: You can’t assign to it later.
- It stores the value in the instance’s
__dict__the first time it’s called. - After that, accessing the property is as fast as a dictionary lookup.
You can verify that by checking __dict__:
print(df.__dict__)Code language: Python (python)You’ll see that expensive_computation is now just stored like any other attribute.
Comparison with @cached_property
Python 3.8 introduced @functools.cached_property. You might be wondering—how is cache_property different?
cached_propertyis writable. You can manually override or delete it.cache_propertyis strict: read-only after the first calculation.
This small difference can be crucial for making sure properties aren’t accidentally mutated in large codebases.
Example:
from functools import cached_property
class Example:
@cached_property
def val(self):
return 42
e = Example()
e.val = 100 # Allowed!
class Example2:
@cache_property
def val(self):
return 42
e2 = Example2()
e2.val = 100 # Raises AttributeErrorCode language: Python (python)If immutability matters to you, cache_property is the way to go.
Practical Use Cases
1. Expensive File Reads
class Config:
def __init__(self, path):
self.path = path
@cache_property
def contents(self):
with open(self.path, 'r') as f:
return f.read()Code language: Python (python)2. One-time Data Initialization
class User:
def __init__(self, user_id):
self.user_id = user_id
@cache_property
def profile(self):
return self._load_profile_from_db()
def _load_profile_from_db(self):
print("Fetching from DB")
return {"name": "Alice", "age": 30}Code language: Python (python)No more accidentally triggering multiple DB calls just because someone accessed user.profile more than once.
Use with Dataclasses and __post_init__
Want to pair cache_property with dataclasses?
from dataclasses import dataclass
from functools import cache_property
@dataclass
class Inventory:
items: list
@cache_property
def total_value(self):
print("Computing total value...")
return sum(item["price"] * item["quantity"] for item in self.items)Code language: Python (python)Real-World Strategy: Combining partial and cache_property
What happens when you combine both of these tools? Magic.
Imagine this scenario:
- You have an object with a
@cache_propertythat computes a value. - You use
partialto bind various configuration options to a generator of that value.
Here’s a toy version:
class Simulator:
def __init__(self, factor):
self.factor = factor
@cache_property
def base_data(self):
print("Generating base data...")
return [i * self.factor for i in range(1000)]
def run(self, transform):
return transform(self.base_data)
from functools import partial
def normalize(data, divisor):
return [x / divisor for x in data]
sim = Simulator(2)
normalize_half = partial(normalize, divisor=2)
print(sim.run(normalize_half)) # base_data computed once, reusedCode language: Python (python)You’ve now built a caching pipeline using native tools—minimal boilerplate, maximum readability.
Part 3: Deeper Patterns with partial and cache_property
Let’s stretch our thinking beyond simple wrappers and into more architectural use cases.
Pattern 1: Strategy Pattern with partial
You can use partial to implement the strategy pattern in a super clean and declarative way. Imagine you have different pricing strategies:
def base_price(product):
return product["price"]
def discount_price(product, discount):
return product["price"] * (1 - discount)
def premium_price(product, multiplier):
return product["price"] * multiplier
# Pre-configured strategies
standard = base_price
summer_sale = partial(discount_price, discount=0.2)
vip = partial(premium_price, multiplier=1.5)
def calculate_total(cart, strategy):
return sum(strategy(item) for item in cart)
cart = [{"price": 100}, {"price": 200}]
print(calculate_total(cart, summer_sale)) # Applies discountCode language: Python (python)This keeps your logic open for extension (add new strategies), but closed for modification. Just pass a different partial, no need to rewrite your function logic.
Pattern 2: Lazy Initialization with cache_property
Suppose you’re loading a large model or dataset. You can make it lazy and one-time with cache_property.
class MLModel:
@cache_property
def model(self):
print("Loading model...")
# Simulate heavy loading
return {"model": "Large model object"}
def predict(self, x):
return f"Predicted {x} using {self.model}"Code language: Python (python)This pattern is especially useful for keeping memory usage in check when working in data pipelines or services with a lot of optional heavy features.
Part 4: Edge Cases and Gotchas
You’ve seen how great these tools can be—but let’s talk about a few caveats that could trip you up.
1. partial Doesn’t Always Preserve Function Signatures
Here’s a subtle but important detail: a partial object doesn’t inherit the full signature of the original function.
from functools import partial
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
hello = partial(greet, greeting="Hi")
print(hello("Alice")) # Works
help(hello) # But won't show nice signatureCode language: Python (python)This makes things a little weird for introspection, help systems, or tools like FastAPI that rely on function signatures.
✅ Fix: Use functools.update_wrapper or use functools.wraps manually if wrapping a function.
2. cache_property is not thread-safe (by design)
In multi-threaded environments (like web servers), simultaneous first access to a cache_property could result in the property being computed multiple times. Example:
# Thread 1 and Thread 2 both hit this at the same time
@property
def computed_value(self):
print("Heavy computation happening twice!")
...Code language: Python (python)✅ Fix: Use locks or other thread-safe patterns if needed.
3. Be Careful With Side Effects in Cached Properties
Because cache_property runs once, any side effects you include (like logging, analytics, or resource access) will only happen on the first call. That might surprise you if you forget.
4. partial and Mutable Defaults
This old Python gotcha still applies:
def add_to_list(value, target=[]):
target.append(value)
return target
bad_partial = partial(add_to_list, 10)
print(bad_partial()) # [10]
print(bad_partial()) # [10, 10]Code language: PHP (php)✅ Fix: Avoid mutable defaults like the plague. You already knew that, but it’s worth a reminder.
Part 5: Testing Strategies
You’re probably thinking: “Okay, I love these tools, but how do I test them properly?”
Let’s look at testing best practices for both.
Testing partial Functions
A partial is just a callable, so you can test it directly.
from functools import partial
def multiply(x, y):
return x * y
double = partial(multiply, 2)
def test_double():
assert double(3) == 6Code language: Python (python)But what if you subclassed partial?
Then you may want to test internal attributes:
class LoggingPartial(partial):
def __call__(self, *args, **kwargs):
print(f"Calling {self.func.__name__} with {args} and {kwargs}")
return super().__call__(*args, **kwargs)
p = LoggingPartial(multiply, 3)
def test_logging_partial():
assert p.func == multiply
assert p.args == (3,)
assert p(4) == 12Code language: Python (python)Testing cache_property
You want to test both:
- The correctness of the computed result
- That it only computes once
class Demo:
def __init__(self):
self.counter = 0
@cache_property
def cached(self):
self.counter += 1
return 42
def test_cached_property():
d = Demo()
assert d.cached == 42
assert d.cached == 42
assert d.counter == 1 # Confirm it only ran onceCode language: Python (python)This is an excellent pattern for when you’re dealing with resource-heavy setups and want to be confident about memory and performance.
Part 6: Using These Tools in FastAPI
Use Case: Dependency Injection with partial
FastAPI relies heavily on dependency injection, and partial is a great tool for injecting preconfigured behavior.
from functools import partial
from fastapi import Depends, FastAPI
app = FastAPI()
def get_user_service(prefix: str):
def _get_user_service():
return {"prefix": prefix}
return _get_user_service
user_service_prod = partial(get_user_service, prefix="prod")
user_service_dev = partial(get_user_service, prefix="dev")
@app.get("/users")
def read_users(service=Depends(user_service_prod())):
return {"service": service}Code language: Python (python)This makes it super clean to switch environments, without rewriting dependencies.
Use Case: Lazy Loading in FastAPI with cache_property
For example, lazy loading a machine learning model:
from fastapi import FastAPI
app = FastAPI()
class Predictor:
@cache_property
def model(self):
print("Loading model...")
return {"loaded_model": True}
predictor = Predictor()
@app.get("/predict")
def predict():
model = predictor.model
return {"status": "ok"}Code language: Python (python)Even under load, model is only initialized once.
Part 7: Using These Tools in Django
In Django, cache_property can be used to improve ORM access efficiency, and partial is great for forms, views, or signal configuration.
cache_property in Django Models or Views
class MyView(View):
@cache_property
def expensive_lookup(self):
return SomeModel.objects.select_related("something").get(id=self.kwargs["id"])
def get(self, request, *args, **kwargs):
data = self.expensive_lookup # Only queried once
return JsonResponse({"name": data.name})Code language: Python (python)This avoids hitting the database multiple times in a single request.
partial in Django Form Factories
Want to preconfigure certain fields dynamically?
def make_custom_form(label_text):
class CustomForm(forms.Form):
name = forms.CharField(label=label_text)
return CustomForm
ShortForm = partial(make_custom_form, label_text="Short Name")
LongForm = partial(make_custom_form, label_text="Full Legal Name")Code language: Python (python)Now you have customizable forms without repeating yourself.
partial for Signal Handlers
Need signal handlers with custom behavior?
from django.db.models.signals import post_save
def notify_user(action, instance, **kwargs):
print(f"{action} -> {instance}")
notify_created = partial(notify_user, "created")
post_save.connect(notify_created, sender=MyModel)Code language: Python (python)Final Thoughts
With the upgrades in Python 3.12, functools.partial becomes subclassable, inspectable, and customizable—meaning it’s not just a shortcut anymore, it’s a real design tool. And cache_property offers a long-awaited, elegant solution to a common performance issue without hacks or third-party packages.
These aren’t flashy features—but they’re quietly powerful. They let you build faster, more expressive, and bug-resistant systems, whether you’re prototyping or scaling production services.
If you’re designing APIs, building ML pipelines, crunching data, or wrangling legacy codebases—these tools will make your life easier.
