I like immutable value objects because they make state changes explicit. The awkward part is that Python has historically offered several different ways to create a slightly modified version of one.
A dataclass has dataclasses.replace(). A named tuple has _replace(). A custom class usually needs its own helper. The operations are conceptually similar, but generic code has no single interface to target.
Python 3.13 adds copy.replace() to close that gap. It creates a new object of the same type while replacing selected fields, and it works with dataclasses, named tuples, and classes that implement __replace__().
Here’s the idea: instead of teaching application code how every immutable record wants to be updated, give those records one replacement protocol.
Start with a frozen dataclass
Suppose I represent deployment settings as an immutable dataclass:
from dataclasses import dataclass
@dataclass(frozen=True)
class DeployConfig:
region: str
replicas: int
debug: bool = False
config = DeployConfig(region="ap-southeast-1", replicas=2)Mutating config.replicas is intentionally forbidden. With Python 3.13, I can derive another value using copy.replace():
from copy import replace
scaled = replace(config, replicas=4)
print(config.replicas) # 2
print(scaled.replicas) # 4The original object stays untouched. More importantly, the call describes the state transition directly: this is the same logical configuration with one field changed.
For a dataclass alone, dataclasses.replace() already solves this problem. copy.replace() becomes more interesting when code handles several record types.
One operation across record types
Named tuples have long supported their own replacement method:
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: intThe same generic function now works for both Point and the dataclass above:
from copy import replace
point = Point(10, 20)
moved = replace(point, y=25)
config = DeployConfig("ap-southeast-1", 2)
scaled = replace(config, replicas=4)That consistency is the feature I care about. I can write a utility that expresses a replacement without importing a type-specific helper:
from copy import replace
def with_changes(value, **changes):
return replace(value, **changes)I would not add that exact wrapper in real code because it merely renames the standard function. But it shows why the protocol matters: callers no longer need branches for dataclasses, named tuples, and application-specific records.
Add replacement semantics to a custom class
copy.replace() is intentionally narrower than copy.copy() and copy.deepcopy(). It only handles supported record-like objects. Custom classes opt in with __replace__().
For example, I might have a small immutable URL configuration:
from copy import replace
class Endpoint:
__slots__ = ("scheme", "host", "port")
def __init__(self, scheme: str, host: str, port: int):
if scheme not in {"http", "https"}:
raise ValueError("unsupported scheme")
if not 1 <= port <= 65535:
raise ValueError("invalid port")
self.scheme = scheme
self.host = host
self.port = port
def __replace__(self, /, **changes):
allowed = {"scheme", "host", "port"}
unknown = changes.keys() - allowed
if unknown:
name = next(iter(unknown))
raise TypeError(f"unknown field: {name}")
return type(self)(
scheme=changes.get("scheme", self.scheme),
host=changes.get("host", self.host),
port=changes.get("port", self.port),
)
endpoint = Endpoint("https", "api.example.com", 443)
local = replace(endpoint, host="localhost", port=8443)There are two details here that I think are worth keeping.
First, __replace__() returns a new instance rather than modifying self. That is the contract callers expect from replacement.
Second, construction still goes through __init__(). Changing the port to 70000, for example, still fails validation. A replacement API should not quietly become a back door around the invariants of the type.
Replacement is not deep copying
The name lives in the copy module, so it is easy to mentally group replace() with deepcopy(). They solve different problems.
Consider this frozen dataclass:
from dataclasses import dataclass
from copy import replace
@dataclass(frozen=True)
class Job:
name: str
labels: list[str]
job = Job("backup", ["nightly"])
retry = replace(job, name="backup-retry")
print(job.labels is retry.labels) # TrueThe unchanged labels field is still the same list. replace() is about field replacement, not recursively cloning the object graph.
That can be exactly what I want when nested values are immutable. It can be surprising when they are mutable.
A frozen dataclass does not magically make every object stored inside it immutable. If I append to retry.labels, the change is visible through job.labels too:
retry.labels.append("manual")
print(job.labels) # ['nightly', 'manual']If independent nested state is required, model that state as immutable too or explicitly create the nested replacement you need. I often prefer tuples for labels in immutable records:
@dataclass(frozen=True)
class Job:
name: str
labels: tuple[str, ...]This makes the shallow nature of replacement much less dangerous.
Nested updates stay explicit
copy.replace() does not interpret dotted paths or recursively update nested fields. I consider that a good constraint.
Given two immutable records:
from dataclasses import dataclass
from copy import replace
@dataclass(frozen=True)
class Database:
host: str
port: int
@dataclass(frozen=True)
class Service:
name: str
database: Database
service = Service(
name="billing",
database=Database("db.internal", 5432),
)I can update the nested database in two clear steps:
updated = replace(
service,
database=replace(service.database, host="db-replica.internal"),
)It is a little more typing than a magic path such as database.host, but the object boundaries remain visible. Each type is responsible for its own replacement rules.
That becomes useful once validation or normalization exists at those boundaries.
Preserve invariants, not implementation accidents
A custom __replace__() deserves the same care as a constructor. The tempting implementation is to copy __dict__, merge changes, and somehow build another instance from the result.
I avoid that pattern.
Internal attributes are not necessarily public fields, and bypassing normal construction can preserve invalid cached state. Imagine a value object that stores both a URL and a parsed hostname. Blindly changing the URL while copying the old parsed hostname creates an inconsistent object.
I prefer rebuilding from the logical fields:
class RetryPolicy:
def __init__(self, attempts: int, base_delay: float):
if attempts < 1:
raise ValueError("attempts must be positive")
if base_delay <= 0:
raise ValueError("base_delay must be positive")
self.attempts = attempts
self.base_delay = base_delay
def __replace__(self, /, **changes):
allowed = {"attempts", "base_delay"}
unknown = changes.keys() - allowed
if unknown:
raise TypeError(f"unknown fields: {sorted(unknown)}")
return type(self)(
attempts=changes.get("attempts", self.attempts),
base_delay=changes.get("base_delay", self.base_delay),
)Now replacement and direct construction enforce the same rules.
To be fair, there are classes where calling the public constructor is expensive or impossible because instances are created by a factory. In those cases, __replace__() can use a different construction path. The important part is to deliberately restore every invariant rather than treating the object’s memory layout as its data model.
Be careful with subclasses
Using type(self)(...) is convenient because a replacement can preserve the runtime type. It also assumes subclasses accept the same constructor arguments and invariants.
That assumption may not hold:
class AuthenticatedEndpoint(Endpoint):
def __init__(self, scheme, host, port, token):
super().__init__(scheme, host, port)
self.token = tokenThe inherited Endpoint.__replace__() would try to construct AuthenticatedEndpoint without token.
There is no universal fix. I can make the base class final by convention, implement __replace__() in each subclass, or design a protected construction hook shared by the hierarchy. What I should not do is assume that type(self) automatically makes a replacement implementation subclass-safe.
This is one reason dataclasses are attractive for plain value objects: much of the replacement behavior is already defined consistently.
Unknown fields should fail loudly
A typo in a replacement is usually a programming error:
replace(config, replica=4) # typo: replica vs replicasSupported record implementations should reject fields they do not know. Custom __replace__() methods should do the same instead of silently ignoring them.
Failing early matters because replacement calls often sit in configuration pipelines. Silently dropping a requested change can leave an application running with a perfectly valid object containing the wrong value.
I also avoid treating arbitrary **changes as a generic patch document from an untrusted client. A Python replacement protocol is an application-level object operation, not authorization or input validation. If an HTTP request can modify only display_name, filter its payload before calling the domain model rather than assuming replace() defines what the caller is allowed to change.
Use it where values have identity by content
I find copy.replace() most natural for things such as configuration snapshots, coordinates, query options, retry policies, compiler settings, and domain value objects.
I am more cautious with entities whose identity and lifecycle matter independently of their fields. Replacing an in-memory User object does not update a database row, publish a domain event, acquire a lock, or check an optimistic-concurrency version.
For example, this looks neat:
updated_user = replace(user, email="new@example.com")But if user represents persisted state, the important operation may actually be:
repository.change_email(
user_id=user.id,
expected_version=user.version,
email="new@example.com",
)The second form exposes the side effects and concurrency boundary. copy.replace() is excellent for transforming values; it should not hide workflows.
A compatibility boundary for libraries
Because copy.replace() arrived in Python 3.13, libraries supporting older Python versions need a deliberate compatibility choice.
If the library already requires Python 3.13, importing it directly is simple:
from copy import replaceIf older runtimes are still supported, I would usually keep the existing type-specific operations until the minimum version moves forward. A compatibility shim can be written, but accurately reproducing the standard protocol across dataclasses, named tuples, and custom objects is more maintenance than a tiny-looking helper suggests.
For application code, the answer is easier: use the feature only after the deployed runtime has moved to 3.13 or newer.
Test replacement as part of the type contract
For custom records, I like a small set of behavioral tests rather than testing the internal implementation:
from copy import replace
import pytest
def test_endpoint_replacement_keeps_original():
original = Endpoint("https", "api.example.com", 443)
changed = replace(original, host="localhost")
assert changed is not original
assert original.host == "api.example.com"
assert changed.host == "localhost"
assert changed.port == 443
def test_endpoint_replacement_revalidates_fields():
endpoint = Endpoint("https", "api.example.com", 443)
with pytest.raises(ValueError):
replace(endpoint, port=70000)
def test_endpoint_replacement_rejects_unknown_fields():
endpoint = Endpoint("https", "api.example.com", 443)
with pytest.raises(TypeError):
replace(endpoint, timeout=5)For records containing mutable values, I add a test for aliasing too. Whether nested values are intentionally shared or independently copied should be a design decision, not something discovered after a production mutation.
In the end
copy.replace() is a small addition, but I like the abstraction it creates. Python already had good ways to derive modified dataclasses and named tuples; Python 3.13 gives those operations a common entry point and lets custom value objects participate through __replace__().
The useful mental model is simple: replacement creates another value with selected fields changed. It is not deep copying, persistence, authorization, or a general patch language.
When I keep that boundary clear, copy.replace() makes immutable data transformations easier to read without hiding the parts of an application where state changes actually have consequences.