Repository: django/django

Issue:
# Fixed #36750 -- Made ordering of M2M objects deterministic in serializers.

#### Trac ticket number

ticket-36750

#### Branch description

Following the implementation of `QuerySet.totally_ordered` in ticket [#36857](https://code.djangoproject.com/ticket/36857), this patch updates the Python and XML serializers to apply deterministic ordering for many-to-many relations using natural keys. The serializers check ``qs.totally_ordered`` and, when the ``queryset`` lacks a total ordering, they extract any existing ordering and simply append ``'pk'`` to break ties. This preserves any default model sorting while guaranteeing determinism, addressing the inconsistent fixture output on PostgreSQL without imposing redundant sorting.
 A regression test in `tests/fixtures/tests.py` verifies this behavior for both formats.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/core/serializers/python.py b/django/core/serializers/python.py
index 53a73e19e51f..73ba24368b0b 100644
--- a/django/core/serializers/python.py
+++ b/django/core/serializers/python.py
@@ -77,7 +77,15 @@ def queryset_iterator(obj, field):
                     chunk_size = (
                         2000 if getattr(attr, "prefetch_cache_name", None) else None
                     )
-                    return attr.iterator(chunk_size)
+                    query_set = attr.all()
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
+                    return query_set.iterator(chunk_size)
 
             else:
 
@@ -86,6 +94,13 @@ def m2m_value(value):
 
                 def queryset_iterator(obj, field):
                     query_set = getattr(obj, field.name).select_related(None).only("pk")
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
                     chunk_size = 2000 if query_set._prefetch_related_lookups else None
                     return query_set.iterator(chunk_size=chunk_size)
 
diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py
index d8ffbdf00a98..3d1f79ea0d00 100644
--- a/django/core/serializers/xml_serializer.py
+++ b/django/core/serializers/xml_serializer.py
@@ -177,7 +177,15 @@ def queryset_iterator(obj, field):
                     chunk_size = (
                         2000 if getattr(attr, "prefetch_cache_name", None) else None
                     )
-                    return attr.iterator(chunk_size)
+                    query_set = attr.all()
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
+                    return query_set.iterator(chunk_size)
 
             else:
 
@@ -186,6 +194,13 @@ def handle_m2m(value):
 
                 def queryset_iterator(obj, field):
                     query_set = getattr(obj, field.name).select_related(None).only("pk")
+                    if not query_set.totally_ordered:
+                        current_ordering = (
+                            query_set.query.order_by
+                            or query_set.model._meta.ordering
+                            or []
+                        )
+                        query_set = query_set.order_by(*current_ordering, "pk")
                     chunk_size = 2000 if query_set._prefetch_related_lookups else None
                     return query_set.iterator(chunk_size=chunk_size)
 


Test patch:
diff --git a/tests/serializers/test_natural.py b/tests/serializers/test_natural.py
index b405dc3e284b..4dff100cc7af 100644
--- a/tests/serializers/test_natural.py
+++ b/tests/serializers/test_natural.py
@@ -1,5 +1,7 @@
+from unittest import mock
+
 from django.core import serializers
-from django.db import connection
+from django.db import connection, models
 from django.test import TestCase
 
 from .models import (
@@ -336,6 +338,20 @@ def nullable_natural_key_m2m_test(self, format):
     )
 
 
+def natural_key_m2m_totally_ordered_test(self, format):
+    t1 = NaturalKeyThing.objects.create(key="t1")
+    t2 = NaturalKeyThing.objects.create(key="t2")
+    t3 = NaturalKeyThing.objects.create(key="t3")
+    t1.other_things.add(t2, t3)
+
+    with mock.patch.object(models.QuerySet, "order_by") as mock_order_by:
+        serializers.serialize(format, [t1], use_natural_foreign_keys=True)
+        mock_order_by.assert_called_once_with("pk")
+        mock_order_by.reset_mock()
+        serializers.serialize(format, [t1], use_natural_foreign_keys=False)
+        mock_order_by.assert_called_once_with("pk")
+
+
 # Dynamically register tests for each serializer
 register_tests(
     NaturalKeySerializerTests,
@@ -385,3 +401,8 @@ def nullable_natural_key_m2m_test(self, format):
     "test_%s_nullable_natural_key_m2m",
     nullable_natural_key_m2m_test,
 )
+register_tests(
+    NaturalKeySerializerTests,
+    "test_%s_natural_key_m2m_totally_ordered",
+    natural_key_m2m_totally_ordered_test,
+)