diff --git a/swh/model/model.py b/swh/model/model.py
index 9f0307e1cccadedd28dfe8783d7fdd23377e63bc..136cf82b53f4d9dd6307f6c04e3f4079d6ab6cff 100644
--- a/swh/model/model.py
+++ b/swh/model/model.py
@@ -338,6 +338,7 @@ def optimize_all_validators(cls, old_fields):
 
 
 ModelType = TypeVar("ModelType", bound="BaseModel")
+HashableModelType = TypeVar("HashableModelType", bound="BaseHashableModel")
 
 
 class BaseModel:
@@ -359,6 +360,10 @@ class BaseModel:
         recursively builds the corresponding objects."""
         return cls(**d)
 
+    def evolve(self: ModelType, **kwargs) -> ModelType:
+        """Alias to call :func:`attr.evolve` on this object, returning a new object."""
+        return attr.evolve(self, **kwargs)  # type: ignore[misc]
+
     def anonymize(self: ModelType) -> Optional[ModelType]:
         """Returns an anonymized version of the object, if needed.
 
@@ -408,6 +413,18 @@ class BaseHashableModel(BaseModel, metaclass=ABCMeta):
             obj_id = self.compute_hash()
             object.__setattr__(self, "id", obj_id)
 
+    def evolve(self: HashableModelType, **kwargs) -> HashableModelType:
+        """Alias to call :func:`attr.evolve` on this object, returning a new object
+        with its ``id`` recomputed based on the content."""
+        if "id" in kwargs:
+            raise TypeError(
+                f"{self.__class__.__name__}.evolve recomputes the id itself; "
+                f"use attr.evolve to change the id manually."
+            )
+        obj = attr.evolve(self, **kwargs)  # type: ignore[misc]
+        new_hash = obj.compute_hash()
+        return attr.evolve(obj, id=new_hash)  # type: ignore[misc]
+
     def unique_key(self) -> KeyType:
         return self.id
 
diff --git a/swh/model/tests/test_model.py b/swh/model/tests/test_model.py
index e6182ee326bf2a92381dffe8420d0a282180aadf..11d0260fa0dcbfef14beeaf7edd1f225d00f851c 100644
--- a/swh/model/tests/test_model.py
+++ b/swh/model/tests/test_model.py
@@ -40,6 +40,7 @@ from swh.model.model import (
     Revision,
     SkippedContent,
     Snapshot,
+    SnapshotBranch,
     TargetType,
     Timestamp,
     TimestampWithTimezone,
@@ -885,6 +886,17 @@ def test_content_git_roundtrip(content):
     assert content.sha1_git == sha1_git
 
 
+@given(strategies.present_contents())
+def test_content_evolve(content):
+    content.check()
+
+    assert attr.evolve(content, sha1=b"\x00" * 20) == content.evolve(sha1=b"\x00" * 20)
+
+    assert attr.evolve(content, data=b"foo") == content.evolve(data=b"foo")
+
+    assert attr.evolve(content, data=None) == content.evolve(data=None)
+
+
 # SkippedContent
 
 
@@ -926,6 +938,15 @@ def test_skipped_content_swhid():
     )
 
 
+@given(strategies.skipped_contents())
+def test_skipped_content_evolve(content):
+    content.check()
+
+    assert attr.evolve(content, sha1=b"\x00" * 20) == content.evolve(sha1=b"\x00" * 20)
+
+    assert attr.evolve(content, sha1=None) == content.evolve(sha1=None)
+
+
 # Directory
 
 
@@ -953,17 +974,70 @@ def test_directory_raw_manifest(directory):
     raw_manifest = b"foo"
     id_ = hashlib.new("sha1", raw_manifest).digest()
 
+    # Forgot to update the id -> error
     directory2 = attr.evolve(directory, raw_manifest=raw_manifest)
     assert directory2.to_dict()["raw_manifest"] == raw_manifest
     with pytest.raises(ValueError, match="does not match recomputed hash"):
         directory2.check()
 
+    # id set to the right value -> ok
     directory2 = attr.evolve(directory, raw_manifest=raw_manifest, id=id_)
     assert directory2.id is not None
     assert directory2.id == id_ != directory.id
     assert directory2.to_dict()["raw_manifest"] == raw_manifest
     directory2.check()
 
+    # id implicitly set to the right value -> ok
+    directory3 = directory.evolve(raw_manifest=raw_manifest)
+    assert directory3.id is not None
+    assert directory3.id == id_ != directory.id
+    assert directory3.to_dict()["raw_manifest"] == raw_manifest
+    directory3.check()
+
+
+@given(strategies.directories(raw_manifest=none()))
+def test_directory_evolve(directory):
+    directory.check()
+
+    # Add an entry (while making sure it is not a duplicate)
+    longest_entry_name = max(
+        (entry.name for entry in directory.entries), key=len, default=b""
+    )
+    entries = (
+        *directory.entries,
+        DirectoryEntry(
+            name=longest_entry_name + b"x",
+            type="file",
+            target=b"\x00" * 20,
+            perms=0,
+        ),
+    )
+    directory2 = directory.evolve(entries=entries)
+    assert directory2.entries == entries
+    assert directory2.id != directory.id, "directory.evolve() did not update the id"
+    directory2.check()
+
+    with pytest.raises(TypeError, match="use attr.evolve"):
+        directory.evolve(id=b"\x00" * 20)
+
+    with pytest.raises(TypeError, match="unexpected keyword argument"):
+        directory.evolve(foo=b"")
+
+
+@given(strategies.directories(raw_manifest=none()))
+def test_directory_evolve_raw_manifest(directory):
+    directory2 = directory.evolve(raw_manifest=b"123")
+    assert directory2 == attr.evolve(directory, id=directory2.id, raw_manifest=b"123")
+
+    directory3 = directory2.evolve(entries=())
+    assert directory3.raw_manifest == directory2.raw_manifest
+    assert (
+        directory3.id == directory2.id
+    ), ".evolve() change the id despite raw_manifest being set"
+    assert directory3 == attr.evolve(
+        directory, id=directory2.id, entries=(), raw_manifest=b"123"
+    )
+
 
 def test_directory_entry_name_validation():
     with pytest.raises(ValueError, match="valid directory entry name."):
@@ -1203,6 +1277,46 @@ def test_release_target_swhid():
     )
 
 
+@given(strategies.releases(raw_manifest=none()))
+def test_release_evolve(release):
+    release.check()
+
+    message = (release.message or b"abc") + b"\n"
+    release2 = release.evolve(message=message)
+    assert release2.message == message
+    assert release2.id != release.id, "release.evolve() did not update the id"
+    release2.check()
+
+    release2 = release.evolve(message=None)
+    assert release2.message is None
+    if release.message is None:
+        assert release2.id == release.id, "no-op release.evolve() updated the id"
+    else:
+        assert release2.id != release.id, "release.evolve() did not update the id"
+    release2.check()
+
+    with pytest.raises(TypeError, match="use attr.evolve"):
+        release.evolve(id=b"\x00" * 20)
+
+    with pytest.raises(TypeError, match="unexpected keyword argument"):
+        release.evolve(foo=b"")
+
+
+@given(strategies.releases(raw_manifest=none()))
+def test_release_evolve_raw_manifest(release):
+    release2 = release.evolve(raw_manifest=b"123")
+    assert release2 == attr.evolve(release, id=release2.id, raw_manifest=b"123")
+
+    release3 = release2.evolve(message=None)
+    assert release3.raw_manifest == release2.raw_manifest
+    assert (
+        release3.id == release2.id
+    ), ".evolve() change the id despite raw_manifest being set"
+    assert release3 == attr.evolve(
+        release, id=release2.id, message=None, raw_manifest=b"123"
+    )
+
+
 # Revision
 
 
@@ -1501,6 +1615,72 @@ def test_snapshot_branch_swhids(snapshot_with_all_types):
     }
 
 
+@given(strategies.snapshots())
+def test_snapshot_evolve(snapshot):
+    snapshot.check()
+
+    # Add an entry (while making sure it is not a duplicate)
+    longest_branch_name = max(snapshot.branches, key=len, default=b"")
+    branches = {
+        **snapshot.branches,
+        longest_branch_name
+        + b"x": SnapshotBranch(
+            target_type=TargetType.RELEASE,
+            target=b"\x00" * 20,
+        ),
+    }
+    snapshot2 = snapshot.evolve(branches=branches)
+    assert snapshot2.branches == branches
+    assert snapshot2.id != snapshot.id, "snapshot.evolve() did not update the id"
+    snapshot2.check()
+
+    with pytest.raises(TypeError, match="use attr.evolve"):
+        snapshot.evolve(id=b"\x00" * 20)
+
+    with pytest.raises(TypeError, match="unexpected keyword argument"):
+        snapshot.evolve(foo=b"")
+
+
+@given(strategies.revisions(raw_manifest=none()))
+def test_revision_evolve(revision):
+    revision.check()
+
+    message = (revision.message or b"abc") + b"\n"
+    revision2 = revision.evolve(message=message)
+    assert revision2.message == message
+    assert revision2.id != revision.id, "revision.evolve() did not update the id"
+    revision2.check()
+
+    revision2 = revision.evolve(message=None)
+    assert revision2.message is None
+    if revision.message is None:
+        assert revision2.id == revision.id, "no-op revision.evolve() updated the id"
+    else:
+        assert revision2.id != revision.id, "revision.evolve() did not update the id"
+    revision2.check()
+
+    with pytest.raises(TypeError, match="use attr.evolve"):
+        revision.evolve(id=b"\x00" * 20)
+
+    with pytest.raises(TypeError, match="unexpected keyword argument"):
+        revision.evolve(foo=b"")
+
+
+@given(strategies.revisions(raw_manifest=none()))
+def test_revision_evolve_raw_manifest(revision):
+    revision2 = revision.evolve(raw_manifest=b"123")
+    assert revision2 == attr.evolve(revision, id=revision2.id, raw_manifest=b"123")
+
+    revision3 = revision2.evolve(message=None)
+    assert revision3.raw_manifest == revision2.raw_manifest
+    assert (
+        revision3.id == revision2.id
+    ), ".evolve() change the id despite raw_manifest being set"
+    assert revision3 == attr.evolve(
+        revision, id=revision2.id, message=None, raw_manifest=b"123"
+    )
+
+
 @given(strategies.objects(split_content=True))
 def test_object_type(objtype_and_obj):
     obj_type, obj = objtype_and_obj