Skip to content

Commit 4bc7b20

Browse files
committed
PEP-9999: initial draft, with async
1 parent e16e261 commit 4bc7b20

File tree

1 file changed

+277
-0
lines changed

1 file changed

+277
-0
lines changed

peps/pep-9999.rst

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

0 commit comments

Comments
 (0)