Idea: an @experimental decorator for the standard library
TL/DR
Python has a standard, type-checker-aware way to mark the end of an API’s
life: warnings.deprecated (PEP 702), mirrored by
typing_extensions.deprecated. There is no equivalent for the beginning of
an API’s life — the period when something is published but explicitly unstable.
This proposes a small, symmetric @experimental decorator in warnings that:
- emits a warning (a new
ExperimentalWarning, subclass ofFutureWarning)
when the decorated object is used, - prepends a visible “Experimental” admonition to the object’s docstring, and
- records lightweight, optional metadata so tooling can discover staged APIs.
It can decorate functions, methods, classes, classmethod/staticmethod
descriptors, and coroutine functions, and it is a no-op for typing.Protocol
classes (it only annotates them) so isinstance/issubclass keep working.
A production-tested reference implementation already exists in Microsoft’s
Agent Framework (agent_framework._feature_stage,
https://github.com/microsoft/agent-framework/blob/main/python/packages/core/agent_framework/_feature_stage.py).
Lots more details...
Motivation
Library authors routinely publish an API before committing to its stability —
to gather feedback, to ship something useful while the design settles, or to
expose a feature built on an unstable upstream. Today there is no standard,
discoverable, machine-readable way to say “published but not stable yet.”
A key scenario is the stable (e.g. 1.x+) package that wants to add a new
feature that is not yet stable. Under semantic versioning, anything shipped as
part of the public, stable surface is covered by the package’s compatibility
guarantee — so iterating on or breaking that new feature later forces a major
version bump, even though the rest of the package is unchanged. Authors are
pushed toward bad options: hold the feature back entirely, ship it and freeze it
prematurely, or bump the major version for a change nobody depending on the
stable API cares about. A standard @experimental marker carves the new feature
out of the stability guarantee explicitly, so it can evolve or be removed in a
minor release without misleading users or burning a major bump.
What exists is a patchwork:
- PEP 411 (“provisional packages”) introduced the concept but is purely a
documentation convention: no runtime marker, no warning, no introspectable
attribute, and no signal that reaches the user who installed the package —
only readers of the docs. - The standard library uses ad-hoc approaches: prose notes, one-off
FutureWarnings,__all__omissions, leading underscores, or just shipping. - The ecosystem reinvents this constantly with bespoke
@experimental/
@beta/@previewdecorators and privateWarningsubclasses that disagree
on category, on warn-once vs warn-always, and on type-checker support.
The asymmetry is the core point: Python standardized the deprecation half of
the lifecycle (PEP 702) but left the experimental half to everyone’s
imagination. You can write from warnings import deprecated today; there is no
from warnings import experimental.
Why the standard library
The same reasoning that justified PEP 702 applies:
- A shared warning category is the whole value. Consumers can only filter a
category uniformly — CI failing on experimental usage, apps silencing it,
libraries re-emitting it — if everyone agrees on it. A hundred private
ExperimentalWarningclasses cannot be filtered together;
warnings.ExperimentalWarningcan. - Type checkers want one spelling.
@deprecatedworks in editors because it
is a single blessed marker checkers special-case. An editor diagnostic on use
of an experimental API needs the same. - It is small and self-contained: a pure-Python decorator, no grammar or
interpreter changes.
Concrete benefits
- A one-line, batteries-included way to ship unstable APIs with a user-visible,
filterable signal instead of a buried doc note. - Users can opt into
-W error::ExperimentalWarningto forbid experimental APIs
in production. - Tooling (linters, type checkers, doc generators, auditors) gets one thing to
look for instead of N project-specific conventions. - Lifecycle symmetry:
@experimental→ (stable) →@deprecated.
Prior art
- PEP 702 –
warnings.deprecated(shipped in 3.13): the direct model — a
decorator inwarnings, recognized by type checkers, that warns on use and is
back-ported viatyping_extensions. This is its mirror image. - PEP 411 – provisional packages: established the concept but provided no
runtime or tooling hook. This gives that concept teeth. FutureWarning: the existing default-visible category for “behavior that
will change.”ExperimentalWarningsubclassing it reuses that visibility.- Microsoft Agent Framework: a real implementation whose non-obvious details
(class-instantiation warnings via__new__, subclass warnings via
__init_subclass__, coroutine-function preservation, Protocol handling,
caller-frame attribution) come from meeting real-world edge cases.
Existing solutions in the Python ecosystem
The strongest argument is that Python projects already build this repeatedly,
incompatibly:
- scikit-learn ships its own
sklearn.exceptions.ExperimentalWarningand a
sklearn.experimentalpackage ofenable_*modules that you must import to
unlock a feature (from sklearn.experimental import enable_iterative_imputer)
— a hand-rolled opt-in gate plus a private warning category. - LangChain has a
@betadecorator (langchain_core) with its own
LangChainBetaWarning, mirrored by a@deprecatedwith
LangChainDeprecationWarning. - NumPy / SciPy / pandas use bespoke prose “experimental” doc notes and
ad-hocFutureWarnings for not-yet-stable surface, with no shared marker. - Standalone PyPI packages exist solely to fill this gap (e.g. an
experimentalpackage that emits aFutureWarningfrom an@experimental
decorator) — third parties are literally publishing the missing stdlib
feature.
Every one of these defines an incompatible warning class that consumers cannot
filter uniformly and no type checker recognizes. That is exactly the
fragmentation a single warnings.ExperimentalWarning + @experimental would
collapse — just as warnings.deprecated did for the deprecation half.
Several major languages already ship a first-class “this API is experimental /
opt-in / unstable” marker, which is evidence the need is real and the design
space well understood:
- C# / .NET —
[Experimental("DIAG_ID")]
(System.Diagnostics.CodeAnalysis.ExperimentalAttribute, .NET 8+). Using a
marked API produces a compile-time diagnostic (error by default) keyed by
the supplied ID; consumers explicitly opt in by suppressing that ID. Closest
analogue to this proposal, paired with C#’s existing[Obsolete]. - Kotlin —
@RequiresOptIn/@OptIn. An API marked with an opt-in
annotation forces callers to acknowledge it (compiler warning or error by
level), e.g. the standard@ExperimentalStdlibApi. - Rust — unstable APIs are gated behind
#![feature(...)]flags and are
only usable on nightly toolchains; stabilization removes the gate. - Scala 3 —
@experimental; using such a definition requires the caller to
also be@experimentalor to enable an experimental compiler mode. - Swift — experimental/underscored language and library features are gated
behind-enable-experimental-featureflags (and@_spifor unstable library
surface). - Java — JDK incubator modules (e.g.
jdk.incubator.*, JEP 11) and
preview features (JEP 12) isolate not-yet-final APIs and require an explicit
enable flag.
Common threads: an explicit, discoverable marker; a signal at use sites
(diagnostic and/or opt-in); and a defined path to stabilization — exactly what
@experimental would give Python, complementing @deprecated.
Add to warnings (back-ported via typing_extensions):
from warnings import experimental
@experimental("MyThing is experimental and may change without notice.")
def my_thing(...): ...
Signature
def experimental(
message: str,
/,
*,
feature_id: str | None = None,
category: type[Warning] | None = ExperimentalWarning,
stacklevel: int = 1,
) -> Callable[[T], T]: ...
Mirrors warnings.deprecated:
message— required explanation; also surfaced by type checkers.category— warning class;Nonedisables the runtime warning while keeping
the docstring/metadata behavior (useful forProtocols).stacklevel— attribute the warning to the caller.
With one optional extra:
feature_id— an optional, stable identifier for the feature a symbol
belongs to. A single experimental feature often spans several decorated
objects (a class, a few helper functions, a factory); giving them a shared
feature_idlets users filter all of them together. When set, the id is
embedded in the emitted message (e.g.[MY_FEATURE] ...), so the standard
message-regex filter can target one feature in concert —
warnings.filterwarnings("ignore", message=r"\[MY_FEATURE\]")— to silence or
error-promote a whole feature without affecting other experimental APIs. It
is purely additive: omit it and the decorator behaves exactly like
@deprecated.
A new exception class:
class ExperimentalWarning(FutureWarning): ...
FutureWarning is chosen so the warning is shown to end users by default
(unlike DeprecationWarning, hidden outside __main__/tests) — instability is
something an application’s end user should arguably be told about.
Runtime semantics
- Warns when the object is used (called, instantiated, or subclassed),
not at decoration time — relying on the standardwarningsfilter for
deduplication, exactly as@deprecateddoes. - Functions/methods are wrapped via
functools.wraps; coroutine functions
stay coroutine functions (inspect.markcoroutinefunctionon 3.12+, the
_is_coroutinemarker before). - Classes wrap
__new__(warn on instantiation of that exact class, not
subclasses) and__init_subclass__(warn on subclassing) — the easy-to-get-
wrong part. classmethod/staticmethoddescriptors are unwrapped, decorated, and
re-wrapped.typing.Protocolclasses skip runtime wrapping (annotate only), because
wrapping or adding attributes to aruntime_checkableProtocol breaks
isinstance/issubclasson older Pythons.
Docstring annotation
Prepends an admonition so generated docs visibly flag the API, preserving the
original body:
.. warning:: Experimental
This API is experimental and subject to change or removal
in future versions without notice.
Introspectable metadata
Sets optional dunder attributes for best-effort discovery (e.g.
__experimental__ = True and an optional message string). These are optional
metadata, not a stable contract: consumers should use
getattr(obj, "__experimental__", False) and must not assume the attribute
survives once the API stabilizes.
Static typing
By analogy with @deprecated, type checkers should recognize @experimental
and surface a diagnostic (off by default, opt-in) on use of an experimental
symbol. The decorator is identity-preserving (Callable[[T], T]), so it never
changes inferred signatures.
Effect on existing code
- Fully backward compatible: no new syntax/keywords, no change to existing
APIs. - Opt-in only: code is affected only if an author applies the decorator;
users then see aFutureWarning-class warning. - No global behavior change — the warning fires only from decorated objects.
- The stdlib could adopt it gradually for genuinely provisional APIs, but that
is a separate, incremental decision.
Anticipated objections
- “Just use
FutureWarning/ a private decorator.” That is the status quo,
and it yields N incompatible categories no checker recognizes. The value is a
single shared spelling — the same logic that standardized@deprecated. - “PEP 411 already covers this.” PEP 411 is package-level and documentation-only: it never
reaches the user at runtime and exposes nothing to tooling. - “This encourages shipping unstable APIs.” Authors already do; they just do
it silently. A standard marker makes instability explicit and filterable
(e.g.-W error::ExperimentalWarning). - “
warningsortyping?”warnings, next todeprecated, since the
primary behavior is a runtime warning. (Open question below.)
Open questions
- Module placement:
warnings.experimental(preferred) vs
typing.experimental. - Naming:
experimentalis used here for concreteness, but the spelling is
open —provisional(in line with PEP 411),alpha/beta,preview,
unstable, etc. are all candidates. This discussion is deferred to a potential
future PEP; the goal of this Idea is the mechanism, not the final name. - Warning base class:
FutureWarningvs a dedicated base vsUserWarning. - Type-checker semantics: opt-in diagnostic, and how to surface
message. - Metadata contract: exact attribute names and wording.
Reference implementation
`` in
https://github.com/microsoft/agent-framework/blob/main/python/packages/core/agent_framework/_feature_stage.py implements experimental (and
release_candidate), ExperimentalWarning, the class
__new__/__init_subclass__ wrapping, coroutine-function preservation,
Protocol handling, and caller-frame attribution — demonstrating the design is
implementable in pure Python and survives real-world usage across functions,
classes, descriptors, coroutines, and Protocols.