Skip to content

Commit 0f2d02b

Browse files
Zac-HDAA-TurnerhugovkLiam-DeVoe
authored
PEP 806: Mixed sync/async context managers with precise async marking (#4581)
Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Co-authored-by: Liam DeVoe <orionldevoe@gmail.com>
1 parent 3d72e07 commit 0f2d02b

File tree

2 files changed

+328
-0
lines changed

2 files changed

+328
-0
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,8 @@ peps/pep-0802.rst @AA-Turner
681681
peps/pep-0803.rst @encukou
682682
peps/pep-0804.rst @pradyunsg
683683
# ...
684+
peps/pep-0806.rst @JelleZijlstra
685+
# ...
684686
peps/pep-2026.rst @hugovk
685687
# ...
686688
peps/pep-3000.rst @gvanrossum

peps/pep-0806.rst

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
PEP: 806
2+
Title: Mixed sync/async context managers with precise async marking
3+
Author: Zac Hatfield-Dodds <zac@zhd.dev>
4+
Sponsor: Jelle Zijlstra <jelle.zijlstra@gmail.com>
5+
Discussions-To: Pending
6+
Status: Draft
7+
Type: Standards Track
8+
Created: 05-Sep-2025
9+
Python-Version: 3.15
10+
Post-History:
11+
`22-May-2025 <https://discuss.python.org/t/92939/>`__,
12+
13+
Abstract
14+
========
15+
16+
Python allows the ``with`` and ``async with`` statements to handle multiple
17+
context managers in a single statement, so long as they are all respectively
18+
synchronous or asynchronous. When mixing synchronous and asynchronous context
19+
managers, developers must use deeply nested statements or use risky workarounds
20+
such as overuse of :class:`~contextlib.AsyncExitStack`.
21+
22+
We therefore propose to allow ``with`` statements to accept both synchronous
23+
and asynchronous context managers in a single statement by prefixing individual
24+
async context managers with the ``async`` keyword.
25+
26+
This change eliminates unnecessary nesting, improves code readability, and
27+
improves ergonomics without making async code any less explicit.
28+
29+
30+
Motivation
31+
==========
32+
33+
Modern Python applications frequently need to acquire multiple resources, via
34+
a mixture of synchronous and asynchronous context managers. While the all-sync
35+
or all-async cases permit a single statement with multiple context managers,
36+
mixing the two results in the "staircase of doom":
37+
38+
.. code-block:: python
39+
40+
async def process_data():
41+
async with acquire_lock() as lock:
42+
with temp_directory() as tmpdir:
43+
async with connect_to_db(cache=tmpdir) as db:
44+
with open('config.json', encoding='utf-8') as f:
45+
# We're now 16 spaces deep before any actual logic
46+
config = json.load(f)
47+
await db.execute(config['query'])
48+
# ... more processing
49+
50+
This excessive indentation discourages use of context managers, despite their
51+
desirable semantics. See the `Rejected Ideas`_ section for current workarounds
52+
and commentary on their downsides.
53+
54+
With this PEP, the function could instead be written:
55+
56+
.. code-block:: python
57+
58+
async def process_data():
59+
with (
60+
async acquire_lock() as lock,
61+
temp_directory() as tmpdir,
62+
async connect_to_db(cache=tmpdir) as db,
63+
open('config.json', encoding='utf-8') as f,
64+
):
65+
config = json.load(f)
66+
await db.execute(config['query'])
67+
# ... more processing
68+
69+
This compact alternative avoids forcing a new level of indentation on every
70+
switch between sync and async context managers. At the same time, it uses
71+
only existing keywords, distinguishing async code with the ``async`` keyword
72+
more precisely even than our current syntax.
73+
74+
We do not propose that the ``async with`` statement should ever be deprecated,
75+
and indeed advocate its continued use for single-line statements so that
76+
"async" is the first non-whitespace token of each line opening an async
77+
context manager.
78+
79+
Our proposal nonetheless permits ``with async some_ctx()``, valuing consistent
80+
syntax design over enforcement of a single code style which we expect will be
81+
handled by style guides, linters, formatters, etc.
82+
See `here <ban-single-line-with-async>`__ for further discussion.
83+
84+
85+
Real-World Impact
86+
-----------------
87+
88+
These enhancements address pain points that Python developers encounter daily.
89+
We surveyed an industry codebase, finding more than ten thousand functions
90+
containing at least one async context manager. 19% of these also contained a
91+
sync context manager. For reference, async functions contain sync context
92+
managers about two-thirds as often as they contain async context managers.
93+
94+
39% of functions with both ``with`` and ``async with`` statements could switch
95+
immediately to the proposed syntax, but this is a loose lower
96+
bound due to avoidance of sync context managers and use of workarounds listed
97+
under Rejected Ideas. Based on inspecting a random sample of functions, we
98+
estimate that between 20% and 50% of async functions containing any context
99+
manager would use ``with async`` if this PEP is accepted.
100+
101+
Across the ecosystem more broadly, we expect lower rates, perhaps in the
102+
5% to 20% range: the surveyed codebase uses structured concurrency with Trio,
103+
and also makes extensive use of context managers to mitigate the issues
104+
discussed in :pep:`533` and :pep:`789`.
105+
106+
107+
Rationale
108+
=========
109+
110+
Mixed sync/async context managers are common in modern Python applications,
111+
such as async database connections or API clients and synchronous file
112+
operations. The current syntax forces developers to choose between deeply
113+
nested code or error-prone workarounds like :class:`~contextlib.AsyncExitStack`.
114+
115+
This PEP addresses the problem with a minimal syntax change that builds on
116+
existing patterns. By allowing individual context managers to be marked with
117+
``async``, we maintain Python's explicit approach to asynchronous code while
118+
eliminating unnecessary nesting.
119+
120+
The implementation as syntactic sugar ensures zero runtime overhead -- the new
121+
syntax desugars to the same nested ``with`` and ``async with`` statements
122+
developers write today. This approach requires no new protocols, no changes
123+
to existing context managers, and no new runtime behaviors to understand.
124+
125+
126+
Specification
127+
=============
128+
129+
The ``with (..., async ...):`` syntax desugars into a sequence of context
130+
managers in the same way as current multi-context ``with`` statements,
131+
except that those prefixed by the ``async`` keyword use the ``__aenter__`` /
132+
``__aexit__`` protocol.
133+
134+
Only the ``with`` statement is modified; ``async with async ctx():`` is a
135+
syntax error.
136+
137+
The :class:`ast.withitem` node gains a new ``is_async`` integer attribute,
138+
following the existing ``is_async`` attribute on :class:`ast.comprehension`.
139+
For ``async with`` statement items, this attribute is always ``1``. For items
140+
in a regular ``with`` statement, the attribute is ``1`` when the ``async``
141+
keyword is present and ``0`` otherwise. This allows the AST to precisely
142+
represent which context managers should use the async protocol while
143+
maintaining backwards compatibility with existing AST processing tools.
144+
145+
146+
Backwards Compatibility
147+
=======================
148+
149+
This change is fully backwards compatible: the only observable difference is
150+
that certain syntax that previously raised :exc:`SyntaxError` now executes
151+
successfully.
152+
153+
Libraries that implement context managers (standard library and third-party)
154+
work with the new syntax without modifications. Libraries and tools which
155+
work directly with source code will need minor updates, as for any new syntax.
156+
157+
158+
How to Teach This
159+
=================
160+
161+
We recommend introducing "mixed context managers" together with or immediately
162+
after ``async with``. For example, a tutorial might cover:
163+
164+
1. **Basic context managers**: Start with single ``with`` statements
165+
2. **Multiple context managers**: Show the current comma syntax
166+
3. **Async context managers**: Introduce ``async with``
167+
4. **Mixed contexts**: "Mark each async context manager with ``async``"
168+
169+
170+
Rejected Ideas
171+
==============
172+
173+
Workaround: an ``as_acm()`` wrapper
174+
-----------------------------------
175+
176+
It is easy to implement a helper function which wraps a synchronous context
177+
manager in an async context manager. For example:
178+
179+
.. code-block:: python
180+
181+
@contextmanager
182+
async def as_acm(sync_cm):
183+
with sync_cm as result:
184+
await sleep(0)
185+
yield result
186+
187+
async with (
188+
acquire_lock(),
189+
as_acm(open('file')) as f,
190+
):
191+
...
192+
193+
This is our recommended workaround for almost all code.
194+
195+
However, there are some cases where calling back into the async runtime (i.e.
196+
executing ``await sleep(0)``) to allow cancellation is undesirable. On the
197+
other hand, *omitting* ``await sleep(0)`` would break the transitive property
198+
that a syntactic ``await`` / ``async for`` / ``async with`` always calls back
199+
into the async runtime (or raises an exception). While few codebases enforce
200+
this property, we have found it indispensable in preventing deadlocks.
201+
202+
203+
Workaround: using ``AsyncExitStack``
204+
------------------------------------
205+
206+
:class:`~contextlib.AsyncExitStack` offers a powerful, low-level interface
207+
which allows for explicit entry of sync and/or async context managers.
208+
209+
.. code-block:: python
210+
211+
async with contextlib.AsyncExitStack() as stack:
212+
await stack.enter_async_context(acquire_lock())
213+
f = stack.enter_context(open('file', encoding='utf-8'))
214+
...
215+
216+
However, :class:`~contextlib.AsyncExitStack` introduces significant complexity
217+
and potential for errors - it's easy to violate properties that syntactic use
218+
of context managers would guarantee, such as 'last-in, first-out' order.
219+
220+
221+
Workaround: ``AsyncExitStack``-based helper
222+
-------------------------------------------
223+
224+
We could also implement a ``multicontext()`` wrapper, which avoids some of the
225+
downsides of direct use of :class:`~contextlib.AsyncExitStack`:
226+
227+
.. code-block:: python
228+
229+
async with multicontext(
230+
acquire_lock(),
231+
open('file'),
232+
) as (f, _):
233+
...
234+
235+
However, this helper breaks the locality of ``as`` clauses, which makes it
236+
easy to accidentally mis-assign the yielded variables (as in the code sample).
237+
It also requires either distinguishing sync from async context managers using
238+
something like a tagged union - perhaps overloading an operator so that, e.g.,
239+
``async_ @ acquire_lock()`` works - or else guessing what to do with objects
240+
that implement both sync and async context-manager protocols.
241+
Finally, it has the error-prone semantics around exception handling which led
242+
`contextlib.nested()`__ to be deprecated in favor of the multi-argument
243+
``with`` statement.
244+
245+
__ https://docs.python.org/2.7/library/contextlib.html#contextlib.nested
246+
247+
248+
Syntax: allow ``async with sync_cm, async_cm:``
249+
-----------------------------------------------
250+
251+
An early draft of this proposal used ``async with`` for the entire statement
252+
when mixing context managers, *if* there is at least one async context manager:
253+
254+
.. code-block:: python
255+
256+
# Rejected approach
257+
async with (
258+
acquire_lock(),
259+
open('config.json') as f, # actually sync, surprise!
260+
):
261+
...
262+
263+
Requiring an async context manager maintains the syntax/scheduler link, but at
264+
the cost of setting invisible constraints on future code changes. Removing
265+
one of several context managers could cause runtime errors, if that happened
266+
to be the last async context manager!
267+
268+
Explicit is better than implicit.
269+
270+
271+
.. _ban-single-line-with-async:
272+
273+
Syntax: ban single-line ``with async ...``
274+
------------------------------------------
275+
276+
Our proposed syntax could be restricted, e.g. to place ``async`` only as the
277+
first token of lines in a parenthesised multi-context ``with`` statement.
278+
This is indeed how we recommend it should be used, and we expect that most
279+
uses will follow this pattern.
280+
281+
While an option to write either ``async with ctx():`` or ``with async ctx():``
282+
may cause some small confusion due to ambiguity, we think that enforcing a
283+
preferred style via the syntax would make Python more confusing to learn,
284+
and thus prefer simple syntactic rules plus community conventions on how to
285+
use them.
286+
287+
To illustrate, we do not think it's obvious at what point (if any) in the
288+
following code samples the syntax should become disallowed:
289+
290+
.. code-block:: python
291+
292+
with (
293+
sync_context() as foo,
294+
async a_context() as bar,
295+
): ...
296+
297+
with (
298+
sync_context() as foo,
299+
async a_context()
300+
): ...
301+
302+
with (
303+
# sync_context() as foo,
304+
async a_context()
305+
): ...
306+
307+
with (async a_context()): ...
308+
309+
with async a_context(): ...
310+
311+
312+
Acknowledgements
313+
================
314+
315+
Thanks to Rob Rolls for `proposing`__ ``with async``. Thanks also to the many
316+
other people with whom we discussed this problem and possible solutions at the
317+
PyCon 2025 sprints, on Discourse, and at work.
318+
319+
__ https://discuss.python.org/t/92939/10
320+
321+
322+
Copyright
323+
=========
324+
325+
This document is placed in the public domain or under the
326+
CC0-1.0-Universal license, whichever is more permissive.

0 commit comments

Comments
 (0)