Async Python Has an Executor Problem — So I Built ‘leasepool’
Async Python is excellent until the real world enters the room.
Your application is async. Your web server is async. Your database driver might be async. Your event loop is happy.
Then one dependency ruins the party.
A vendor SDK blocks.
A file operation takes longer than expected.
A legacy HTTP client has no async version.
A CPU-heavy function needs to run without freezing the event loop.
Python already gives us a tool for this: executors. In asyncio, we can send synchronous work to an executor using loop.run_in_executor(). The Python docs describe it directly: arrange for a callable to run in a specified executor, or use the default executor when None is passed.
That solves the first problem:
“How do I run this blocking function without blocking the event loop?”
But in production systems, that is not the only problem.
The harder questions are:
- Who owns the executor?
- Who is allowed to submit work to it?
- How much executor capacity is safe?
- What happens when too many requests want to offload at the same time?
- How do we stop raw executors from spreading through the codebase?
- How do we expire stale ownership?
- How do we scale executor capacity based on runtime demand?
That is the problem I built leasepool to solve.
leasepoolis executor leasing, backpressure, and adaptive CPU/I/O offloading for async Python. It helps async applications borrow local executor capacity safely instead of passing rawThreadPoolExecutororProcessPoolExecutorobjects around the codebase.
The core idea is simple:
async with await manager.acquire(owner="vendor-sdk-call") as lease:
result = await lease.run(sync_function, payload)The caller does not own the executor.
The manager owns it.
The caller only owns a lease.
That distinction is the whole library.
Project Details:
GITHUB: https://github.com/jackofsometrades99/leasepool/
PyPI: https://pypi.org/project/leasepool/
Official Documentation: https://leasepool.readthedocs.io/en/latest/
Why leasing?
A raw executor is powerful. Too powerful.
If I pass a raw executor into different parts of an application, every caller can submit work. Some caller might hold onto it forever. Some caller might shut it down. Some caller might submit an unexpected amount of work. And over time, executor usage becomes invisible infrastructure scattered across the service.
That is manageable in a script.
It becomes painful in an async service.
With leasepool, executor ownership is centralized in LeasedExecutorManager. Callers acquire temporary access through ExecutorLease. The lease is the right to submit work for a bounded period of time. When the lease is released, the executor returns to the manager.
This gives the system a control point.
Not a framework.
Not a distributed queue.
Not a broker.
Just a local control point around executor capacity.
And that control point matters.
Backpressure starts at acquisition
The important parameter is max_pools.
A pool here means an executor instance managed by one LeasedExecutorManager. If every executor is already leased, a new caller does not silently create infinite capacity. It must wait, timeout, or fail fast.
That is backpressure.
lease = await manager.acquire(owner="request-123", timeout=2.0)Or:
lease = await manager.acquire(owner="request-123", wait=False)If capacity is exhausted and wait=False, the caller can immediately choose a policy: return HTTP 503, retry later, enqueue somewhere else, or degrade gracefully.
This is where leasepool is intentionally different from “just use run_in_executor().”
Get Sambhu Nampoothiri G’s stories in your inbox
Join Medium for free to get updates from this writer.
run_in_executor() is a mechanism.
leasepool is a capacity policy.
A lease is not a raw executor
One design decision I care about a lot: lease.executor is not the raw executor.
It is a safe proxy.
The proxy supports submission, but it does not let the caller shut down the internal executor directly. The source implements this by handing callers a _LeasedExecutorProxy; submit() goes back through the manager, while shutdown() raises and tells the caller to release the lease instead.
That means the manager remains the lifecycle owner.
This is small, but it changes the shape of the system.
Instead of trusting every caller to use executors correctly, the library gives callers a smaller interface: submit work while your lease is valid, then return the lease.
That is the kind of abstraction I like: it does not hide Python’s executor model; it narrows it to the part application code should actually touch.
Lease expiry
Leases also have time boundaries.
Each lease has a soft lifetime and a grace period. After lease_seconds + lease_grace_seconds, new submissions through that lease are rejected. Existing work already submitted is left to the underlying executor semantics, which keeps the design aligned with Python’s executor behavior instead of pretending to be a task-killer. The README documents this behavior explicitly.
This matters because ownership should not be infinite by accident.
In a service, a forgotten object reference should not become a permanent right to submit work.
Threads, processes, and interpreters
leasepool is built around Python’s executor interface.
For blocking I/O, use the thread backend.
For CPU-heavy Python work, use the process backend.
For Python 3.14+, the interpreter backend is reserved for InterpreterPoolExecutor.
This maps naturally to Python’s own executor family. ThreadPoolExecutor, InterpreterPoolExecutor, and ProcessPoolExecutor all implement the executor interface, while ProcessPoolExecutor uses multiprocessing and has the usual pickling constraints.
That is why leasepool does not need to invent a new execution primitive.
It leases the ones Python already has.
What leasepool is not
This is important enough to say plainly.
leasepool is not Celery.
It is not a distributed task queue.
It does not provide persistence, remote workers, retries across machines, scheduling, or broker integration.
And that is the point.
Sometimes the problem is not distributed task execution.
Sometimes the problem is local executor discipline.
You have one Python process.
You have an async application.
You have blocking or CPU-heavy functions.
You need bounded offloading, lifecycle ownership, and backpressure.
That is the space leasepool is designed for.
The core design
At a high level:
async application
-> LeasedExecutorManager
-> ExecutorLease
-> ThreadPoolExecutor / ProcessPoolExecutor / InterpreterPoolExecutorThe manager owns executor capacity.
The lease gives temporary access.
The proxy keeps raw executor lifecycle private.
The acquisition path gives backpressure.
The expiry path prevents stale ownership.
Adaptive sizing lets capacity follow a runtime signal.
WorkGrinder can batch many async submitters into leased executor windows.
Process-log forwarding helps make process workers observable.
The library is small, but the goal is not small!
The goal is to make executor offloading in async Python feel controlled, explicit, and production-shaped.
Why I built it this way
The best abstractions are usually not the ones that do the most.
They are the ones that put a boundary around something dangerous.
Executors are useful, but raw executor access spreads responsibility everywhere. leasepool pulls that responsibility back into one place.
It asks application code to do one thing:
Borrow capacity, do the work, return the lease.
That is the whole philosophy.
And in the next part, I’ll go deeper into the most important piece of that design: the lease itself.








