Decorator to Memoize Sync or Async Functions in Python
Tags:
Bottom Line: I wrote a simple decorator that can memoize a sync or async python function.
Many pythonistas will be familiar with the idea of the memoize
decorator; it’s essentially a decorator that keeps an internal dictionary
mapping the arguments used to call a function to the result of calling the
function with those arguments. When a function is decorated with this
decorator, calling the function results in a lookup in the dictionary. If the
dictionary already has a key:value
pair where the key
is a set of identical
arguments, it simply returns the associated value
. If that key:value
pair
does not exist, it runs the (potentially expensive) function, stores the
result in the dictionary for next time, then returns the value. Dan Bader has
done a great job going through this in greater detail at the link above. He
also notes that the standard library has a built-in tool that serves this
purpose, and is probably a better go-to than rolling your own decorator:
https://docs.python.org/3/library/functools.html#functools.cache.
One issue is that this @memoize
decorator only works for sync functions;
async functions immediately return an awaitable coroutine, and so this
decorator would store that instead of the result of awaiting the coroutine,
which is probably what a user intends.
In order to memoize an async function, one can use third-party libraries such as https://github.com/aio-libs/async-lru. Unfortunately, this can only be used with async functions; passing it a regular function results in an error.
I wanted to see if I could come up with a simple, stdlib-only memoize decorator that could work for both sync and async functions, and actually it wasn’t that difficult. Hopefully the code below is reasonably easy to understand and copy and paste into your own simple project.
Please note that there are probably lots of edge cases that will trip this
up, and for production code it’s probably a better idea to use
functools.cache
and alru_cache
to decorate your functions with the
applicable decorator. One big “gotcha” is that as written this code has
unlimited cache size and no simple method for cache invalidation, which could
lead to e.g. running out of memory if you were caching a large number of calls
with differing arguments, or functions that return memory-hungry objects.
However, for quick or simple projects, I’ve found it handy and simple to use.
import asyncio
import time
def memoize(func):
"""
(c) 2021 Nathan Henrie, MIT License
https://n8henrie.com/2021/11/decorator-to-memoize-sync-or-async-functions-in-python/
"""
cache = {}
async def memoized_async_func(*args, **kwargs):
key = (args, frozenset(sorted(kwargs.items())))
if key in cache:
return cache[key]
result = await func(*args, **kwargs)
cache[key] = result
return result
def memoized_sync_func(*args, **kwargs):
key = (args, frozenset(sorted(kwargs.items())))
if key in cache:
return cache[key]
result = func(*args, **kwargs)
cache[key] = result
return result
if asyncio.iscoroutinefunction(func):
return memoized_async_func
return memoized_sync_func
@memoize
async def asyncmain(foo, bar="bar"):
await asyncio.sleep(2)
return (42, bar)
@memoize
def syncmain(foo, bar="bar"):
time.sleep(2)
return (42, bar)
if __name__ == "__main__":
# Should print 3 times after 2 seconds; once for the initial call, twice
# more right afterwards with the cached result.
for _ in range(3):
print(asyncio.run(asyncmain(23)))
# Should print 3 times after 2 seconds; once for the initial call, twice
# more right afterwards with the cached result.
for _ in range(3):
print(syncmain(23))
# Should print immediately as it was cached above
print(asyncio.run(asyncmain(23)))
# Should print after 2 seconds due to the addition of a kwarg
print(asyncio.run(asyncmain(23, bar="bar")))
# Should print after 2 seconds due to the modification of a kwarg
print(asyncio.run(asyncmain(23, bar="qux")))