PEP 806 – Mixed sync/async context managers with precise async marking
- Author:
- Zac Hatfield-Dodds <zac at zhd.dev>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 05-Sep-2025
- Python-Version:
- 3.15
- Post-History:
- 22-May-2025, 25-Sep-2025
Abstract
Python allows the with and async with statements to handle multiple
context managers in a single statement, so long as they are all respectively
synchronous or asynchronous.  When mixing synchronous and asynchronous context
managers, developers must use deeply nested statements or use risky workarounds
such as overuse of AsyncExitStack.
We therefore propose to allow with statements to accept both synchronous
and asynchronous context managers in a single statement by prefixing individual
async context managers with the async keyword.
This change eliminates unnecessary nesting, improves code readability, and improves ergonomics without making async code any less explicit.
Motivation
Modern Python applications frequently need to acquire multiple resources, via a mixture of synchronous and asynchronous context managers. While the all-sync or all-async cases permit a single statement with multiple context managers, mixing the two results in the “staircase of doom”:
async def process_data():
    async with acquire_lock() as lock:
        with temp_directory() as tmpdir:
            async with connect_to_db(cache=tmpdir) as db:
                with open('config.json', encoding='utf-8') as f:
                    # We're now 16 spaces deep before any actual logic
                    config = json.load(f)
                    await db.execute(config['query'])
                    # ... more processing
This excessive indentation discourages use of context managers, despite their desirable semantics. See the Rejected Ideas section for current workarounds and commentary on their downsides.
With this PEP, the function could instead be written:
async def process_data():
    with (
        async acquire_lock() as lock,
        temp_directory() as tmpdir,
        async connect_to_db(cache=tmpdir) as db,
        open('config.json', encoding='utf-8') as f,
    ):
        config = json.load(f)
        await db.execute(config['query'])
        # ... more processing
This compact alternative avoids forcing a new level of indentation on every
switch between sync and async context managers.  At the same time, it uses
only existing keywords, distinguishing async code with the async keyword
more precisely even than our current syntax.
We do not propose that the async with statement should ever be deprecated,
and indeed advocate its continued use for single-line statements so that
“async” is the first non-whitespace token of each line opening an async
context manager.
Our proposal nonetheless permits with async some_ctx(), valuing consistent
syntax design over enforcement of a single code style which we expect will be
handled by style guides, linters, formatters, etc.
See here for further discussion.
Real-World Impact
These enhancements address pain points that Python developers encounter daily. We surveyed an industry codebase, finding more than ten thousand functions containing at least one async context manager. 19% of these also contained a sync context manager. For reference, async functions contain sync context managers about two-thirds as often as they contain async context managers.
39% of functions with both with and async with statements could switch
immediately to the proposed syntax, but this is a loose lower
bound due to avoidance of sync context managers and use of workarounds listed
under Rejected Ideas.  Based on inspecting a random sample of functions, we
estimate that between 20% and 50% of async functions containing any context
manager would use with async if this PEP is accepted.
Across the ecosystem more broadly, we expect lower rates, perhaps in the 5% to 20% range: the surveyed codebase uses structured concurrency with Trio, and also makes extensive use of context managers to mitigate the issues discussed in PEP 533 and PEP 789.
Rationale
Mixed sync/async context managers are common in modern Python applications,
such as async database connections or API clients and synchronous file
operations.  The current syntax forces developers to choose between deeply
nested code or error-prone workarounds like AsyncExitStack.
This PEP addresses the problem with a minimal syntax change that builds on
existing patterns. By allowing individual context managers to be marked with
async, we maintain Python’s explicit approach to asynchronous code while
eliminating unnecessary nesting.
The implementation as syntactic sugar ensures zero runtime overhead – the new
syntax desugars to the same nested with and async with statements
developers write today. This approach requires no new protocols, no changes
to existing context managers, and no new runtime behaviors to understand.
Specification
The with (..., async ...): syntax desugars into a sequence of context
managers in the same way as current multi-context with statements,
except that those prefixed by the async keyword use the __aenter__ /
__aexit__ protocol.
Only the with statement is modified; async with async ctx(): is a
syntax error.
The ast.withitem node gains a new is_async integer attribute,
following the existing is_async attribute on ast.comprehension.
For async with statement items, this attribute is always 1. For items
in a regular with statement, the attribute is 1 when the async
keyword is present and 0 otherwise. This allows the AST to precisely
represent which context managers should use the async protocol while
maintaining backwards compatibility with existing AST processing tools.
Backwards Compatibility
This change is fully backwards compatible: the only observable difference is
that certain syntax that previously raised SyntaxError now executes
successfully.
Libraries that implement context managers (standard library and third-party) work with the new syntax without modifications. Libraries and tools which work directly with source code will need minor updates, as for any new syntax.
How to Teach This
We recommend introducing “mixed context managers” together with or immediately
after async with.  For example, a tutorial might cover:
- Basic context managers: Start with single withstatements
- Multiple context managers: Show the current comma syntax
- Async context managers: Introduce async with
- Mixed contexts: “Mark each async context manager with async”
Rejected Ideas
Workaround: an as_acm() wrapper
It is easy to implement a helper function which wraps a synchronous context manager in an async context manager. For example:
@contextmanager
async def as_acm(sync_cm):
    with sync_cm as result:
        await sleep(0)
        yield result
async with (
    acquire_lock(),
    as_acm(open('file')) as f,
):
    ...
This is our recommended workaround for almost all code.
However, there are some cases where calling back into the async runtime (i.e.
executing await sleep(0)) to allow cancellation is undesirable.  On the
other hand, omitting await sleep(0) would break the transitive property
that a syntactic await / async for / async with always calls back
into the async runtime (or raises an exception).  While few codebases enforce
this property today, we have found it indispensable in preventing deadlocks,
and accordingly prefer a cleaner foundation for the ecosystem.
Workaround: using AsyncExitStack
AsyncExitStack offers a powerful, low-level interface
which allows for explicit entry of sync and/or async context managers.
async with contextlib.AsyncExitStack() as stack:
    await stack.enter_async_context(acquire_lock())
    f = stack.enter_context(open('file', encoding='utf-8'))
    ...
However, AsyncExitStack introduces significant complexity
and potential for errors - it’s easy to violate properties that syntactic use
of context managers would guarantee, such as ‘last-in, first-out’ order.
Workaround: AsyncExitStack-based helper
We could also implement a multicontext() wrapper, which avoids some of the
downsides of direct use of AsyncExitStack:
async with multicontext(
    acquire_lock(),
    open('file'),
) as (f, _):
    ...
However, this helper breaks the locality of as clauses, which makes it
easy to accidentally mis-assign the yielded variables (as in the code sample).
It also requires either distinguishing sync from async context managers using
something like a tagged union - perhaps overloading an operator so that, e.g.,
async_ @ acquire_lock() works - or else guessing what to do with objects
that implement both sync and async context-manager protocols.
Finally, it has the error-prone semantics around exception handling which led
contextlib.nested() to be deprecated in favor of the multi-argument
with statement.
Syntax: allow async with sync_cm, async_cm:
An early draft of this proposal used async with for the entire statement
when mixing context managers, if there is at least one async context manager:
# Rejected approach
async with (
    acquire_lock(),
    open('config.json') as f,  # actually sync, surprise!
):
    ...
Requiring an async context manager maintains the syntax/scheduler link, but at the cost of setting invisible constraints on future code changes. Removing one of several context managers could cause runtime errors, if that happened to be the last async context manager!
Explicit is better than implicit.
Syntax: ban single-line with async ...
Our proposed syntax could be restricted, e.g. to place async only as the
first token of lines in a parenthesised multi-context with statement.
This is indeed how we recommend it should be used, and we expect that most
uses will follow this pattern.
While an option to write either async with ctx(): or with async ctx():
may cause some small confusion due to ambiguity, we think that enforcing a
preferred style via the syntax would make Python more confusing to learn,
and thus prefer simple syntactic rules plus community conventions on how to
use them.
To illustrate, we do not think it’s obvious at what point (if any) in the following code samples the syntax should become disallowed:
with (
    sync_context() as foo,
    async a_context() as bar,
): ...
with (
    sync_context() as foo,
    async a_context()
): ...
with (
    # sync_context() as foo,
    async a_context()
): ...
with (async a_context()): ...
with async a_context(): ...
Acknowledgements
Thanks to Rob Rolls for proposing with async.  Thanks also to the many
other people with whom we discussed this problem and possible solutions at the
PyCon 2025 sprints, on Discourse, and at work.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
Source: https://github.com/python/peps/blob/main/peps/pep-0806.rst
Last modified: 2025-09-27 10:52:42 GMT