From 8d1c673466bcabff500edc6c7724e5a20f6df3de Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Tue, 1 Sep 2026 21:59:50 +0200 Subject: [PATCH 1/2] Fixed #37309 -- Used _is_pk_set() in Model.__eq__. Composite and db_default primary keys are not None when unset, so the old pk is None check treated distinct unsaved instances as equal. --- django/db/models/base.py | 5 ++--- docs/ref/models/instances.txt | 15 ++++++++++++--- docs/releases/6.2.txt | 6 ++++++ tests/basic/tests.py | 4 ++++ tests/composite_pk/tests.py | 12 ++++++++++++ 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/django/db/models/base.py b/django/db/models/base.py index a2dae61b999e..347f2fcc2664 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -647,10 +647,9 @@ def __eq__(self, other): return NotImplemented if self._meta.concrete_model != other._meta.concrete_model: return False - my_pk = self.pk - if my_pk is None: + if not self._is_pk_set(): return self is other - return my_pk == other.pk + return self.pk == other.pk def __hash__(self): if not self._is_pk_set(): diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index bfe90671c1c5..3b31d503cc79 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -790,9 +790,18 @@ For example:: The equality method is defined such that instances with the same primary key value and the same concrete class are considered equal, except that -instances with a primary key value of ``None`` aren't equal to anything except -themselves. For proxy models, concrete class is defined as the model's first -non-proxy parent; for all other models it's simply the model's class. +instances without a set primary key aren't equal to anything except +themselves. Whether the primary key is set is determined by +:meth:`~django.db.models.Model._is_pk_set`. For proxy models, concrete class +is defined as the model's first non-proxy parent; for all other models it's +simply the model's class. + +.. versionchanged:: 6.2 + + Equality now uses :meth:`~django.db.models.Model._is_pk_set` instead of + comparing ``pk is None``. Unsaved instances with a composite primary key + or a ``db_default`` primary key are compared by identity, matching simple + ``None`` primary keys. For example:: diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index 69d5b3b69fc3..99609fce7fa5 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -286,6 +286,12 @@ backends. :meth:`~django.contrib.gis.gdal.Field.as_datetime` is now a ``c_float`` rather than a ``c_int``. +Models +------ + +* Unsaved instances with a composite primary key or a ``db_default`` primary + key no longer compare equal to other instances. + Tests ----- diff --git a/tests/basic/tests.py b/tests/basic/tests.py index 2a8e596bf3c2..c407f371b08e 100644 --- a/tests/basic/tests.py +++ b/tests/basic/tests.py @@ -491,6 +491,10 @@ def test_eq(self): self.assertEqual(a, a) self.assertEqual(a, mock.ANY) self.assertNotEqual(Article(), a) + # DatabaseDefault is not None, but the pk is unset. + db_default = PrimaryKeyWithDbDefault() + self.assertEqual(db_default, db_default) + self.assertNotEqual(PrimaryKeyWithDbDefault(), db_default) def test_hash(self): # Value based on PK diff --git a/tests/composite_pk/tests.py b/tests/composite_pk/tests.py index 3384ba157940..3b48a61a3605 100644 --- a/tests/composite_pk/tests.py +++ b/tests/composite_pk/tests.py @@ -70,6 +70,18 @@ def test_pk_not_set_db_default(self): self.assertIsNotNone(post.id) self.assertIs(post._is_pk_set(), False) + def test_eq(self): + self.assertEqual(User(pk=(1, 2)), User(pk=(1, 2))) + self.assertEqual(User(tenant_id=2, id=3), User(tenant_id=2, id=3)) + self.assertNotEqual(User(pk=(1, 2)), User(pk=(1, 3))) + self.assertNotEqual(User(pk=(1, 2)), object()) + unset = User() + self.assertEqual(unset, unset) + self.assertNotEqual(User(), unset) + self.assertNotEqual(User(tenant_id=1), User(tenant_id=1)) + self.assertNotEqual(User(id=1), User(id=1)) + self.assertNotEqual(User(tenant_id=1), User(pk=(1, 2))) + def test_hash(self): self.assertEqual(hash(User(pk=(1, 2))), hash((1, 2))) self.assertEqual(hash(User(tenant_id=2, id=3)), hash((2, 3))) From 6126f5d2fe0f22c0d2d4888de8c94b91a9795bb6 Mon Sep 17 00:00:00 2001 From: Denny Biasiolli Date: Tue, 1 Sep 2026 21:59:58 +0200 Subject: [PATCH 2/2] Refs #37309 -- Used _is_pk_set() in the XML serializer. Unsaved composite and db_default primary keys are not None, so the old pk is None check emitted a dummy pk attribute in XML dumps. --- django/core/serializers/xml_serializer.py | 3 +-- tests/composite_pk/tests.py | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py index b3725e97486d..629ca2cad887 100644 --- a/django/core/serializers/xml_serializer.py +++ b/django/core/serializers/xml_serializer.py @@ -59,8 +59,7 @@ def start_object(self, obj): self.indent(self.indent_level) attrs = {"model": str(obj._meta)} if not self.use_natural_primary_keys or not self._resolve_natural_key(obj): - obj_pk = obj.pk - if obj_pk is not None: + if obj._is_pk_set(): attrs["pk"] = obj._meta.pk.value_to_string(obj) try: diff --git a/tests/composite_pk/tests.py b/tests/composite_pk/tests.py index 3b48a61a3605..38c7a48197e2 100644 --- a/tests/composite_pk/tests.py +++ b/tests/composite_pk/tests.py @@ -437,6 +437,11 @@ def test_serialize_user_xml(self): self.assertIn('', result) self.assert_deserializer(format="xml", users=users, serialized_users=result) + def test_serialize_unsaved_user_xml_omits_pk(self): + result = serializers.serialize("xml", [User()]) + self.assertIn('', result) + self.assertNotIn(" pk=", result) + def test_serialize_post_uuid(self): posts = Post.objects.filter(pk=(2, "11111111-1111-1111-1111-111111111111")) result = serializers.serialize("json", posts)