Fixed #33515 -- Prevented recreation of migration for ManyToManyField to lowercased swappable setting.
diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py
index 18c6c4e607413..ca4f4368b6d1d 100644
--- a/django/db/models/fields/related.py
+++ b/django/db/models/fields/related.py
@@ -1728,11 +1728,15 @@ def deconstruct(self):
             kwargs["db_table"] = self.db_table
         if self.remote_field.db_constraint is not True:
             kwargs["db_constraint"] = self.remote_field.db_constraint
-        # Rel needs more work.
+        # Lowercase model names as they should be treated as case-insensitive.
         if isinstance(self.remote_field.model, str):
-            kwargs["to"] = self.remote_field.model
+            if "." in self.remote_field.model:
+                app_label, model_name = self.remote_field.model.split(".")
+                kwargs["to"] = "%s.%s" % (app_label, model_name.lower())
+            else:
+                kwargs["to"] = self.remote_field.model.lower()
         else:
-            kwargs["to"] = self.remote_field.model._meta.label
+            kwargs["to"] = self.remote_field.model._meta.label_lower
         if getattr(self.remote_field, "through", None) is not None:
             if isinstance(self.remote_field.through, str):
                 kwargs["through"] = self.remote_field.through
diff --git a/docs/releases/4.0.3.txt b/docs/releases/4.0.3.txt
index 2ef642fe5eb24..17e9f65074af2 100644
--- a/docs/releases/4.0.3.txt
+++ b/docs/releases/4.0.3.txt
@@ -12,4 +12,6 @@ reformatted with `black`_.
 Bugfixes
 ========
 
-* ...
+* Prevented, following a regression in Django 4.0.1, :djadmin:`makemigrations`
+  from generating infinite migrations for a model with ``ManyToManyField`` to
+  a lowercased swappable model such as ``'auth.user'`` (:ticket:`33515`).
diff --git a/tests/field_deconstruction/tests.py b/tests/field_deconstruction/tests.py
index 64b90953f19e2..c78ed62876a0d 100644
--- a/tests/field_deconstruction/tests.py
+++ b/tests/field_deconstruction/tests.py
@@ -475,34 +475,34 @@ def test_many_to_many_field(self):
         name, path, args, kwargs = field.deconstruct()
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
-        self.assertEqual(kwargs, {"to": "auth.Permission"})
+        self.assertEqual(kwargs, {"to": "auth.permission"})
         self.assertFalse(hasattr(kwargs["to"], "setting_name"))
         # Test swappable
         field = models.ManyToManyField("auth.User")
         name, path, args, kwargs = field.deconstruct()
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
-        self.assertEqual(kwargs, {"to": "auth.User"})
+        self.assertEqual(kwargs, {"to": "auth.user"})
         self.assertEqual(kwargs["to"].setting_name, "AUTH_USER_MODEL")
         # Test through
         field = models.ManyToManyField("auth.Permission", through="auth.Group")
         name, path, args, kwargs = field.deconstruct()
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
-        self.assertEqual(kwargs, {"to": "auth.Permission", "through": "auth.Group"})
+        self.assertEqual(kwargs, {"to": "auth.permission", "through": "auth.Group"})
         # Test custom db_table
         field = models.ManyToManyField("auth.Permission", db_table="custom_table")
         name, path, args, kwargs = field.deconstruct()
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
-        self.assertEqual(kwargs, {"to": "auth.Permission", "db_table": "custom_table"})
+        self.assertEqual(kwargs, {"to": "auth.permission", "db_table": "custom_table"})
         # Test related_name
         field = models.ManyToManyField("auth.Permission", related_name="custom_table")
         name, path, args, kwargs = field.deconstruct()
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
         self.assertEqual(
-            kwargs, {"to": "auth.Permission", "related_name": "custom_table"}
+            kwargs, {"to": "auth.permission", "related_name": "custom_table"}
         )
         # Test related_query_name
         field = models.ManyToManyField("auth.Permission", related_query_name="foobar")
@@ -510,7 +510,7 @@ def test_many_to_many_field(self):
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
         self.assertEqual(
-            kwargs, {"to": "auth.Permission", "related_query_name": "foobar"}
+            kwargs, {"to": "auth.permission", "related_query_name": "foobar"}
         )
         # Test limit_choices_to
         field = models.ManyToManyField(
@@ -520,7 +520,7 @@ def test_many_to_many_field(self):
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
         self.assertEqual(
-            kwargs, {"to": "auth.Permission", "limit_choices_to": {"foo": "bar"}}
+            kwargs, {"to": "auth.permission", "limit_choices_to": {"foo": "bar"}}
         )
 
     @override_settings(AUTH_USER_MODEL="auth.Permission")
@@ -533,7 +533,7 @@ def test_many_to_many_field_swapped(self):
 
         self.assertEqual(path, "django.db.models.ManyToManyField")
         self.assertEqual(args, [])
-        self.assertEqual(kwargs, {"to": "auth.Permission"})
+        self.assertEqual(kwargs, {"to": "auth.permission"})
         self.assertEqual(kwargs["to"].setting_name, "AUTH_USER_MODEL")
 
     def test_many_to_many_field_related_name(self):
@@ -551,7 +551,7 @@ class MyModel(models.Model):
         self.assertEqual(args, [])
         # deconstruct() should not include attributes which were not passed to
         # the field during initialization.
-        self.assertEqual(kwargs, {"to": "field_deconstruction.MyModel"})
+        self.assertEqual(kwargs, {"to": "field_deconstruction.mymodel"})
         # Passed attributes.
         name, path, args, kwargs = MyModel.m2m_related_name.field.deconstruct()
         self.assertEqual(path, "django.db.models.ManyToManyField")
@@ -559,7 +559,7 @@ class MyModel(models.Model):
         self.assertEqual(
             kwargs,
             {
-                "to": "field_deconstruction.MyModel",
+                "to": "field_deconstruction.mymodel",
                 "related_query_name": "custom_query_name",
                 "limit_choices_to": {"flag": True},
             },
diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py
index 36c6ffb872d71..8ddb3390029f1 100644
--- a/tests/migrations/test_autodetector.py
+++ b/tests/migrations/test_autodetector.py
@@ -3279,6 +3279,31 @@ def test_swappable_lowercase(self):
             [("__setting__", "AUTH_USER_MODEL")],
         )
 
+    @override_settings(AUTH_USER_MODEL="thirdapp.CustomUser")
+    def test_swappable_many_to_many_model_case(self):
+        document_lowercase = ModelState(
+            "testapp",
+            "Document",
+            [
+                ("id", models.AutoField(primary_key=True)),
+                ("owners", models.ManyToManyField(settings.AUTH_USER_MODEL.lower())),
+            ],
+        )
+        document = ModelState(
+            "testapp",
+            "Document",
+            [
+                ("id", models.AutoField(primary_key=True)),
+                ("owners", models.ManyToManyField(settings.AUTH_USER_MODEL)),
+            ],
+        )
+        with isolate_lru_cache(apps.get_swappable_settings_name):
+            changes = self.get_changes(
+                [self.custom_user, document_lowercase],
+                [self.custom_user, document],
+            )
+        self.assertEqual(len(changes), 0)
+
     def test_swappable_changed(self):
         with isolate_lru_cache(apps.get_swappable_settings_name):
             before = self.make_project_state([self.custom_user, self.author_with_user])

ticket-33515

Thanks Chris Lee for the report.

Regression in 43289707809c814a70f0db38ca4f82f35f43dbfd.

Refs ticket-23916.