Python allows thewith andasyncwith statements to handle multiplecontext managers in a single statement, so long as they are all respectivelysynchronous or asynchronous. When mixing synchronous and asynchronous contextmanagers, developers must use deeply nested statements or use risky workaroundssuch as overuse ofAsyncExitStack.
We therefore propose to allowwith statements to accept both synchronousand asynchronous context managers in a single statement by prefixing individualasync context managers with theasync keyword.
This change eliminates unnecessary nesting, improves code readability, andimproves ergonomics without making async code any less explicit.
Modern Python applications frequently need to acquire multiple resources, viaa mixture of synchronous and asynchronous context managers. While the all-syncor all-async cases permit a single statement with multiple context managers,mixing the two results in the “staircase of doom”:
asyncdefprocess_data():asyncwithacquire_lock()aslock:withtemp_directory()astmpdir:asyncwithconnect_to_db(cache=tmpdir)asdb:withopen('config.json',encoding='utf-8')asf:# We're now 16 spaces deep before any actual logicconfig=json.load(f)awaitdb.execute(config['query'])# ... more processing
This excessive indentation discourages use of context managers, despite theirdesirable semantics. See theRejected Ideas section for current workaroundsand commentary on their downsides.
With this PEP, the function could instead be written:
asyncdefprocess_data():with(asyncacquire_lock()aslock,temp_directory()astmpdir,asyncconnect_to_db(cache=tmpdir)asdb,open('config.json',encoding='utf-8')asf,):config=json.load(f)awaitdb.execute(config['query'])# ... more processing
This compact alternative avoids forcing a new level of indentation on everyswitch between sync and async context managers. At the same time, it usesonly existing keywords, distinguishing async code with theasync keywordmore precisely even than our current syntax.
We do not propose that theasyncwith 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 asynccontext manager.
Our proposal nonetheless permitswithasyncsome_ctx(), valuing consistentsyntax design over enforcement of a single code style which we expect will behandled by style guides, linters, formatters, etc.Seehere for further discussion.
These enhancements address pain points that Python developers encounter daily.We surveyed an industry codebase, finding more than ten thousand functionscontaining at least one async context manager. 19% of these also contained async context manager. For reference, async functions contain sync contextmanagers about two-thirds as often as they contain async context managers.
39% of functions with bothwith andasyncwith statements could switchimmediately to the proposed syntax, but this is a loose lowerbound due to avoidance of sync context managers and use of workarounds listedunder Rejected Ideas. Based on inspecting a random sample of functions, weestimate that between 20% and 50% of async functions containing any contextmanager would usewithasync if this PEP is accepted.
Across the ecosystem more broadly, we expect lower rates, perhaps in the5% to 20% range: the surveyed codebase uses structured concurrency with Trio,and also makes extensive use of context managers to mitigate the issuesdiscussed inPEP 533 andPEP 789.
Mixed sync/async context managers are common in modern Python applications,such as async database connections or API clients and synchronous fileoperations. The current syntax forces developers to choose between deeplynested code or error-prone workarounds likeAsyncExitStack.
This PEP addresses the problem with a minimal syntax change that builds onexisting patterns. By allowing individual context managers to be marked withasync, we maintain Python’s explicit approach to asynchronous code whileeliminating unnecessary nesting.
The implementation as syntactic sugar ensures zero runtime overhead – the newsyntax desugars to the same nestedwith andasyncwith statementsdevelopers write today. This approach requires no new protocols, no changesto existing context managers, and no new runtime behaviors to understand.
Thewith(...,async...): syntax desugars into a sequence of contextmanagers in the same way as current multi-contextwith statements,except that those prefixed by theasync keyword use the__aenter__ /__aexit__ protocol.
Only thewith statement is modified;asyncwithasyncctx(): is asyntax error.
Theast.withitem node gains a newis_async integer attribute,following the existingis_async attribute onast.comprehension.Forasyncwith statement items, this attribute is always1. For itemsin a regularwith statement, the attribute is1 when theasynckeyword is present and0 otherwise. This allows the AST to preciselyrepresent which context managers should use the async protocol whilemaintaining backwards compatibility with existing AST processing tools.
This change is fully backwards compatible: the only observable difference isthat certain syntax that previously raisedSyntaxError now executessuccessfully.
Libraries that implement context managers (standard library and third-party)work with the new syntax without modifications. Libraries and tools whichwork directly with source code will need minor updates, as for any new syntax.
We recommend introducing “mixed context managers” together with or immediatelyafterasyncwith. For example, a tutorial might cover:
with statementsasyncwithasync”as_acm() wrapperIt is easy to implement a helper function which wraps a synchronous contextmanager in an async context manager. For example:
@contextmanagerasyncdefas_acm(sync_cm):withsync_cmasresult:awaitsleep(0)yieldresultasyncwith(acquire_lock(),as_acm(open('file'))asf,):...
This is our recommended workaround for almost all code.
However, there are some cases where calling back into the async runtime (i.e.executingawaitsleep(0)) to allow cancellation is undesirable. On theother hand,omittingawaitsleep(0) would break the transitive propertythat a syntacticawait /asyncfor /asyncwith always calls backinto the async runtime (or raises an exception). While few codebases enforcethis property today, we have found it indispensable in preventing deadlocks,and accordingly prefer a cleaner foundation for the ecosystem.
AsyncExitStackAsyncExitStack offers a powerful, low-level interfacewhich allows for explicit entry of sync and/or async context managers.
asyncwithcontextlib.AsyncExitStack()asstack:awaitstack.enter_async_context(acquire_lock())f=stack.enter_context(open('file',encoding='utf-8'))...
However,AsyncExitStack introduces significant complexityand potential for errors - it’s easy to violate properties that syntactic useof context managers would guarantee, such as ‘last-in, first-out’ order.
AsyncExitStack-based helperWe could also implement amulticontext() wrapper, which avoids some of thedownsides of direct use ofAsyncExitStack:
asyncwithmulticontext(acquire_lock(),open('file'),)as(f,_):...
However, this helper breaks the locality ofas clauses, which makes iteasy to accidentally mis-assign the yielded variables (as in the code sample).It also requires either distinguishing sync from async context managers usingsomething like a tagged union - perhaps overloading an operator so that, e.g.,async_@acquire_lock() works - or else guessing what to do with objectsthat implement both sync and async context-manager protocols.Finally, it has the error-prone semantics around exception handling which ledcontextlib.nested() to be deprecated in favor of the multi-argumentwith statement.
asyncwithsync_cm,async_cm:An early draft of this proposal usedasyncwith for the entire statementwhen mixing context managers,if there is at least one async context manager:
# Rejected approachasyncwith(acquire_lock(),open('config.json')asf,# actually sync, surprise!):...
Requiring an async context manager maintains the syntax/scheduler link, but atthe cost of setting invisible constraints on future code changes. Removingone of several context managers could cause runtime errors, if that happenedto be the last async context manager!
Explicit is better than implicit.
withasync...Our proposed syntax could be restricted, e.g. to placeasync only as thefirst token of lines in a parenthesised multi-contextwith statement.This is indeed how we recommend it should be used, and we expect that mostuses will follow this pattern.
While an option to write eitherasyncwithctx(): orwithasyncctx():may cause some small confusion due to ambiguity, we think that enforcing apreferred style via the syntax would make Python more confusing to learn,and thus prefer simple syntactic rules plus community conventions on how touse them.
To illustrate, we do not think it’s obvious at what point (if any) in thefollowing code samples the syntax should become disallowed:
with(sync_context()asfoo,asynca_context()asbar,):...with(sync_context()asfoo,asynca_context()):...with(# sync_context() as foo,asynca_context()):...with(asynca_context()):...withasynca_context():...
Thanks to Rob Rolls forproposingwithasync. Thanks also to the manyother people with whom we discussed this problem and possible solutions at thePyCon 2025 sprints, on Discourse, and at work.
This document is placed in the public domain or under theCC0-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