An `@experimental` decorator for the standard library

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:

  1. emits a warning (a new ExperimentalWarning, subclass of FutureWarning)
    when the decorated object is used,
  2. prepends a visible “Experimental” admonition to the object’s docstring, and
  3. 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 / @preview decorators and private Warning subclasses 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
    ExperimentalWarning classes cannot be filtered together;
    warnings.ExperimentalWarning can.
  • Type checkers want one spelling. @deprecated works 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::ExperimentalWarning to 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 in warnings, recognized by type checkers, that warns on use and is
    back-ported via typing_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.” ExperimentalWarning subclassing 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.ExperimentalWarning and a
    sklearn.experimental package of enable_* 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 @beta decorator (langchain_core) with its own
    LangChainBetaWarning, mirrored by a @deprecated with
    LangChainDeprecationWarning.
  • NumPy / SciPy / pandas use bespoke prose “experimental” doc notes and
    ad-hoc FutureWarnings for not-yet-stable surface, with no shared marker.
  • Standalone PyPI packages exist solely to fill this gap (e.g. an
    experimental package that emits a FutureWarning from 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 @experimental or to enable an experimental compiler mode.
  • Swift — experimental/underscored language and library features are gated
    behind -enable-experimental-feature flags (and @_spi for 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; None disables the runtime warning while keeping
    the docstring/metadata behavior (useful for Protocols).
  • 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_id lets 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

  1. Warns when the object is used (called, instantiated, or subclassed),
    not at decoration time — relying on the standard warnings filter for
    deduplication, exactly as @deprecated does.
  2. Functions/methods are wrapped via functools.wraps; coroutine functions
    stay coroutine functions (inspect.markcoroutinefunction on 3.12+, the
    _is_coroutine marker before).
  3. Classes wrap __new__ (warn on instantiation of that exact class, not
    subclasses) and __init_subclass__ (warn on subclassing) — the easy-to-get-
    wrong part.
  4. classmethod / staticmethod descriptors are unwrapped, decorated, and
    re-wrapped.
  5. typing.Protocol classes skip runtime wrapping (annotate only), because
    wrapping or adding attributes to a runtime_checkable Protocol breaks
    isinstance/issubclass on 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 a FutureWarning-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).
  • warnings or typing?” warnings, next to deprecated, since the
    primary behavior is a runtime warning. (Open question below.)

Open questions

  • Module placement: warnings.experimental (preferred) vs
    typing.experimental.
  • Naming: experimental is 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: FutureWarning vs a dedicated base vs UserWarning.
  • 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.

10 Likes

I get the motivation and it isn’t wrong, but I think the questions are whether the stdlib would use it and if not is it worth the support burden on us to take it (I don’t have good answers for either).

We have also come to view it as a bit of a failure and not a mechanism to be used going forward.

That looks like a Sphinx directive and not something for a docstring.

Do you mean “linters”? I don’t see how type checkers directly play into this.

1 Like

We have also come to view it as a bit of a failure and not a mechanism to be used going forward.

Yeah, I noticed that, but I think since there is a established way to indicate a package not being stable (semver), while this does not have a standardized approach in Python

That looks like a Sphinx directive and not something for a docstring.

The goal here is to auto-add some text to the docstring so that you don’t have to do that manually (and then forget to clean it up once you’re stable)

Do you mean “linters”? I don’t see how type checkers directly play into this.

yes, my mistake, similar experience as using a deprecated function, which are highlighted by most IDE’s

1 Like

That would be a very new thing to have the stdlib do, so I would view that as too magical to do to a docstring.

2 Likes

That’s a good point, I’ll remove that and let doc generation tools make choices there.