Skip to content

Properties🔗

This page covers deprecation of @property and @cached_property descriptors: decorator order rules, all-accessor wrapping, setter/deleter chains, and the dataclass field alias pattern. For function deprecation see Functions; for class deprecation see Classes.

Properties and cached properties🔗

@deprecated works with @property and @cached_property. The decorator only adds a FutureWarning at access time — it does not forward reads or writes to another property. For a getter-only property, either decorator order is valid. To add a warning to all three accessors (fget, fset, fdel) so that read, write, and delete each fire FutureWarning, place @deprecated on the outside (@deprecated @property order, or explicit deprecated(...)(property(fget, fset, fdel))). The inner-first order (@property @deprecated) only adds a warning to fget — apply @deprecated to setter and deleter separately if you also need them to warn.

from functools import cached_property

from deprecate import deprecated


class Config:
    @property
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    def timeout(self) -> int:
        return 30

    @cached_property
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    def base_url(self) -> str:
        return "https://example.com"


print(Config().timeout)
Output: Config().timeout
30

The FutureWarning fires on attribute access (obj.timeout), not on a call. For @cached_property, the warning fires on first access only — subsequent accesses return the cached value without emitting another warning. The cache is filled after the first read, so later reads of the same object never reach pyDeprecate and no num_warns value can raise the count. With num_warns=-1 each new object warns once, on its first access; the default num_warns=1 budget is shared by all objects, so only the first object to read the property warns.

Decorator order for getter-only properties

Either @property @deprecated (inner) or @deprecated @property (outer) order works for getter-only properties. Inner order is conventional — the deprecated decorator is closer to the def. For properties with a setter or deleter, use outer order; see the next section.

Deprecating a property with a setter or deleter🔗

When the property being deprecated has a setter or deleter, all three accessors (fget, fset, fdel) are wrapped automatically — each fires a FutureWarning. Both the chain-style decorator pattern and the explicit construction pattern work:

from deprecate import deprecated


class Config:
    def __init__(self) -> None:
        self._timeout: int = 30

    # Outer order required: @deprecated @property wraps fget, fset, and fdel
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    @property
    def timeout(self) -> int:
        return self._timeout

    @timeout.setter
    def timeout(self, value: int) -> None:
        self._timeout = value

    @timeout.deleter
    def timeout(self) -> None:
        del self._timeout


cfg = Config()
cfg.timeout = 10  # FutureWarning: write
print(cfg.timeout)  # FutureWarning: read; prints 10
del cfg.timeout  # FutureWarning: delete
Output: cfg.timeout
10

obj.timeout fires FutureWarning on read, obj.timeout = value fires on write, and del obj.timeout fires on delete.

Want only the getter to warn?

If you want the setter or deleter to remain silent, apply @deprecated directly to fget using inner order (@property @deprecated) instead of wrapping the full property object.

Testing each accessor independently

Each accessor (fget, fset, fdel) has its own warning counter — assert read, write, and delete warnings in separate pytest.warns blocks, or use num_warns=-1 to disable per-accessor deduplication.

The explicit property(fget, fset[, fdel]) construction also works:

from deprecate import deprecated


def _timeout_fget(self) -> int:
    return self._timeout


def _timeout_fset(self, value: int) -> None:
    self._timeout = value


def _timeout_fdel(self) -> None:
    del self._timeout


class Config:
    def __init__(self) -> None:
        self._timeout: int = 30

    timeout = deprecated(deprecated_in="1.0", remove_in="2.0")(property(_timeout_fget, _timeout_fset, _timeout_fdel))

Audit discoverability with explicit construction

find_deprecation_wrappers discovers explicit-construction properties via the accessor that carries __deprecation_config__ metadata. For setter-only properties (property(None, fset)), it discovers via fset; if fget is plain (not deprecated), it falls through to fset or fdel.

Strict mode: from deprecate import property🔗

The inner order @property @deprecated wraps only fget. If you later add a setter or deleter with @value.setter / @value.deleter, those accessors are rebuilt from the plain property base and are silently unprotected — writes and deletes never warn. This is easy to introduce by habit, because the standard library puts @property outermost.

To catch this at definition time, import the strict property replacement. It raises TypeError the moment a getter already carrying @deprecated metadata is handed to it — before any instance is created:

# phmdoctest:skip — TypeError raised at class-body time
from deprecate import deprecated, property  # `property` shadows the builtin in this module only


class Config:
    def __init__(self) -> None:
        self._timeout: int = 30

    # ! Raises TypeError at class-body evaluation time — inner order detected
    @property
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    def timeout(self) -> int:
        return self._timeout

The fix is to switch to the canonical outer order @deprecated(...) @property, which wraps every accessor and survives later setter/deleter additions. The outer order works unchanged with the strict property:

from deprecate import deprecated, property


class Config:
    def __init__(self) -> None:
        self._timeout: int = 30

    # NEW: outer order — strict `property` passes through, all accessors warn
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    @property
    def timeout(self) -> int:
        return self._timeout

The strict property is a subclass of the builtin, so isinstance(obj, property) checks and the audit scanner treat it transparently. Importing it shadows the builtin only in the importing module — modules that do not import it keep the builtin behaviour, so the strictness is purely opt-in.

Auditing existing code: the inner_order_property flag

Even without the strict import, find_deprecation_wrappers flags every inner-order @property: each DeprecationWrapperInfo carries inner_order_property=True when the wrapper is a plain property whose fget is deprecation-wrapped. The flag fires for the getter-only shape too, because the canonical form is the outer order. CI pipelines can reject any result with this flag set to eliminate the silent write/delete gap across a whole package. See the Audit guide for the full CI integration pattern.

Deprecated property alias on a dataclass🔗

When a dataclass field is renamed, define a property with the old name that delegates to the new field in its accessor body. @deprecated adds a FutureWarning to each accessor — the delegation itself is plain Python in the method body, not something the library provides.

Read-only alias (warns on read only):

from dataclasses import dataclass

from deprecate import deprecated


@dataclass
class Config:
    timeout_ms: int = 30_000  # renamed from `timeout`

    @property
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    def timeout(self) -> int:
        """Deprecated — use ``timeout_ms`` instead."""
        return self.timeout_ms // 1000


cfg = Config(timeout_ms=5_000)
print(cfg.timeout)  # FutureWarning fired; prints 5
Output: cfg.timeout
5

Read-write alias (warns on read and write): use the outer order and chain .setter:

from dataclasses import dataclass

from deprecate import deprecated


@dataclass
class Config:
    timeout_ms: int = 30_000  # renamed from `timeout`

    @deprecated(deprecated_in="1.0", remove_in="2.0")
    @property
    def timeout(self) -> int:
        return self.timeout_ms // 1000

    @timeout.setter
    def timeout(self, value: int) -> None:
        self.timeout_ms = value * 1000


cfg = Config(timeout_ms=5_000)
print(cfg.timeout)  # FutureWarning fired; prints 5
cfg.timeout = 10  # FutureWarning fired; sets timeout_ms = 10_000
print(cfg.timeout_ms)  # prints 10_000
Output: cfg.timeout; cfg.timeout_ms
5
10000

cfg.timeout fires FutureWarning (from @deprecated) and the getter body returns cfg.timeout_ms // 1000. cfg.timeout = 5 fires FutureWarning and the setter body assigns cfg.timeout_ms = 5000.

Do not shadow a dataclass field

Do not use the same name as an existing dataclass field for the deprecated property. The @dataclass-generated __init__ performs self.field = value, which conflicts with a property descriptor of the same name. Use a different name for the deprecated alias and keep the dataclass field under its new name.

The same pattern works on regular (non-dataclass) classes — replace field access with self._attr lookups in the accessor body. @deprecated only adds the warning in either case.

target=<callable> not supported on properties

@deprecated rejects target=<callable> on a property with TypeError. Properties have three independent accessors (fget, fset, fdel); there is no single callable to forward to. Delegate in each accessor body as shown above.

See also🔗

  • Use Cases overview — start here for a guided tour of all deprecation patterns
  • Functions — function and method deprecation patterns
  • Classes — class, Enum, dataclass, and instance deprecation
  • Async — async functions and async generators
  • Advanced — class/static methods and generator functions
  • Audit Tools — enforce removal deadlines in CI
  • Troubleshooting — common errors and fixes

Next: Async — deprecating async functions and async generators.