-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
PEP 806: Mixed sync/async context managers with precise async marking #4581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Zac-HD
wants to merge
7
commits into
python:main
Choose a base branch
from
Zac-HD:with-async-pep
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
b8e82f8
PEP-9999: initial draft, `with async`
Zac-HD 8e7726b
PEP 806: fixes from initial review
Zac-HD 6859bbf
PEP 806: codeowners and minor clarification
Zac-HD 85be4c6
Apply suggestions from code review
Zac-HD 9472e58
PEP 806: add code sample
Zac-HD 1c7aed8
Apply suggestions from code review
Zac-HD 35832ed
PEP 806: fix filename
Zac-HD File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,326 @@ | ||
PEP: 806 | ||
Title: Mixed sync/async context managers with precise async marking | ||
Author: Zac Hatfield-Dodds <[email protected]> | ||
Sponsor: Jelle Zijlstra <[email protected]> | ||
Discussions-To: Pending | ||
Status: Draft | ||
Type: Standards Track | ||
Created: 05-Sep-2025 | ||
Python-Version: 3.15 | ||
Post-History: | ||
`22-May-2025 <https://discuss.python.org/t/92939/>`__, | ||
|
||
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 :class:`~contextlib.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": | ||
|
||
.. code-block:: python | ||
|
||
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: | ||
|
||
.. code-block:: python | ||
|
||
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 <ban-single-line-with-async>`__ 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 :class:`~contextlib.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 :class:`ast.withitem` node gains a new ``is_async`` integer attribute, | ||
following the existing ``is_async`` attribute on :class:`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 :exc:`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: | ||
|
||
1. **Basic context managers**: Start with single ``with`` statements | ||
2. **Multiple context managers**: Show the current comma syntax | ||
3. **Async context managers**: Introduce ``async with`` | ||
4. **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: | ||
|
||
.. code-block:: python | ||
|
||
@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, we have found it indispensable in preventing deadlocks. | ||
|
||
|
||
Workaround: using ``AsyncExitStack`` | ||
------------------------------------ | ||
|
||
:class:`~contextlib.AsyncExitStack` offers a powerful, low-level interface | ||
which allows for explicit entry of sync and/or async context managers. | ||
|
||
.. code-block:: python | ||
|
||
async with contextlib.AsyncExitStack() as stack: | ||
await stack.enter_async_context(acquire_lock()) | ||
f = stack.enter_context(open('file', encoding='utf-8')) | ||
... | ||
|
||
However, :class:`~contextlib.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 :class:`~contextlib.AsyncExitStack`: | ||
|
||
.. code-block:: python | ||
|
||
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. | ||
|
||
__ https://docs.python.org/2.7/library/contextlib.html#contextlib.nested | ||
|
||
|
||
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: | ||
|
||
.. code-block:: python | ||
|
||
# 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. | ||
|
||
|
||
.. _ban-single-line-with-async: | ||
|
||
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: | ||
|
||
.. code-block:: python | ||
|
||
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. | ||
|
||
__ https://discuss.python.org/t/92939/10 | ||
|
||
|
||
Copyright | ||
========= | ||
|
||
This document is placed in the public domain or under the | ||
CC0-1.0-Universal license, whichever is more permissive. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.