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: 10
Code 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 logging
Code 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 result
Code 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 print
Code 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_property
is writable. You can manually override or delete it.cache_property
is 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 AttributeError
Code 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_property
that computes a value. - You use
partial
to 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, reused
Code 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 discount
Code 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 signature
Code 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) == 6
Code 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) == 12
Code 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 once
Code 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.