From 08be7da01ca179ea825bfff0242fdd8fcbac2def Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Thu, 4 Jun 2026 16:12:38 -0700 Subject: [PATCH 1/6] Fix validators crashing with AttributeError when many=True passes a queryset as instance When a serializer is used with many=True and a queryset instance, the child serializer's instance is set to the parent ListSerializer's queryset rather than a single model object. UniqueValidator, UniqueTogetherValidator, and BaseUniqueForValidator all called instance.pk or getattr(instance, field) without checking that instance is actually a Model, which raises: AttributeError: 'QuerySet' object has no attribute 'pk' The fix guards every place that reads from instance by checking isinstance(instance, Model) first. When the instance is a queryset the validators fall back to create semantics (no exclusion, no field-value comparison against the existing object). Users who need proper per-object update semantics with many=True should override run_child_validation to set self.child.instance to the individual object, as documented. Fixes #9484 --- rest_framework/validators.py | 27 +++++++++++++++++---------- tests/test_validators.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/rest_framework/validators.py b/rest_framework/validators.py index cc759b39cc..9e4cf90c67 100644 --- a/rest_framework/validators.py +++ b/rest_framework/validators.py @@ -8,7 +8,7 @@ """ from django.core.exceptions import FieldError from django.db import DataError -from django.db.models import Exists +from django.db.models import Exists, Model from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError @@ -69,7 +69,7 @@ def exclude_current_instance(self, queryset, instance): If an instance is being updated, then do not include that instance itself as a uniqueness conflict. """ - if instance is not None: + if instance is not None and isinstance(instance, Model): return queryset.exclude(pk=instance.pk) return queryset @@ -149,10 +149,11 @@ def filter_queryset(self, attrs, queryset, serializer): # If this is an update, then any unprovided field should # have it's value set based on the existing instance attribute. - if serializer.instance is not None: + instance = serializer.instance if isinstance(serializer.instance, Model) else None + if instance is not None: for source in sources: if source not in attrs: - attrs[source] = getattr(serializer.instance, source) + attrs[source] = getattr(instance, source) # Determine the filter keyword arguments and filter the queryset. filter_kwargs = { @@ -166,35 +167,41 @@ def exclude_current_instance(self, attrs, queryset, instance): If an instance is being updated, then do not include that instance itself as a uniqueness conflict. """ - if instance is not None: + if instance is not None and isinstance(instance, Model): return queryset.exclude(pk=instance.pk) return queryset def __call__(self, attrs, serializer): + # When many=True is used, the parent ListSerializer's queryset is + # propagated as the child's instance. Treat that as a create so that + # per-object uniqueness checks don't try to call .pk / field attrs on + # the queryset. + instance = serializer.instance if isinstance(serializer.instance, Model) else None + self.enforce_required_fields(attrs, serializer) queryset = self.queryset queryset = self.filter_queryset(attrs, queryset, serializer) - queryset = self.exclude_current_instance(attrs, queryset, serializer.instance) + queryset = self.exclude_current_instance(attrs, queryset, instance) checked_names = [ serializer.fields[field_name].source for field_name in self.fields ] # Ignore validation if any field is None - if serializer.instance is None: + if instance is None: checked_values = [attrs[field_name] for field_name in checked_names] else: # Ignore validation if all field values are unchanged checked_values = [ attrs[field_name] for field_name in checked_names - if attrs[field_name] != getattr(serializer.instance, field_name) + if attrs[field_name] != getattr(instance, field_name) ] condition_sources = (serializer.fields[field_name].source for field_name in self.condition_fields) condition_kwargs = { source: attrs[source] if source in attrs - else getattr(serializer.instance, source) + else getattr(instance, source) for source in condition_sources } if checked_values and None not in checked_values and qs_exists_with_condition(queryset, self.condition, condition_kwargs): @@ -273,7 +280,7 @@ def exclude_current_instance(self, attrs, queryset, instance): If an instance is being updated, then do not include that instance itself as a uniqueness conflict. """ - if instance is not None: + if instance is not None and isinstance(instance, Model): return queryset.exclude(pk=instance.pk) return queryset diff --git a/tests/test_validators.py b/tests/test_validators.py index 289becb7d0..81083cbcb8 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -832,6 +832,39 @@ def test_unique_constraint_default_message_code(self): assert serializer.errors == {"non_field_errors": [expected_message]} assert serializer.errors["non_field_errors"][0].code == UniqueTogetherValidator.code + def test_unique_constraint_with_many_true_does_not_crash(self): + """ + UniqueConstraint validators must not crash with AttributeError when + a queryset is passed as the instance argument together with many=True. + + When run_child_validation is not overridden the child serializer has + no per-object instance, so uniqueness checks operate as if all items + are new creates. The important thing is that the code path no longer + raises AttributeError by trying to call .pk on the queryset. + + Refs: https://github.com/encode/django-rest-framework/issues/9484 + """ + instances = UniqueConstraintModel.objects.all() + # Use global_id values that don't exist yet so uniqueness checks pass. + data = [ + { + 'race_name': 'new_race', + 'position': 10, + 'global_id': 100, + 'fancy_conditions': 100, + }, + { + 'race_name': 'other_race', + 'position': 20, + 'global_id': 200, + 'fancy_conditions': 200, + }, + ] + serializer = UniqueConstraintSerializer(instances, data=data, many=True) + # Before the fix this raised: AttributeError: 'QuerySet' object has no attribute 'pk' + assert serializer.is_valid(), serializer.errors + + # Tests for `UniqueForDateValidator` # ---------------------------------- From 1e269e37d550a26d594e92c45d331eb272bff600 Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Thu, 4 Jun 2026 16:55:16 -0700 Subject: [PATCH 2/6] Fix validators to guard isinstance(instance, Model) to handle ListSerializer many=True From 2024288551688532e46b2bbca846e7f853febc82 Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Thu, 4 Jun 2026 16:55:21 -0700 Subject: [PATCH 3/6] Add regression test for ListSerializer many=True with unique constraint validators --- tests/test_validators.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_validators.py b/tests/test_validators.py index 81083cbcb8..9d331182ab 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -865,7 +865,6 @@ def test_unique_constraint_with_many_true_does_not_crash(self): assert serializer.is_valid(), serializer.errors - # Tests for `UniqueForDateValidator` # ---------------------------------- From bfe1ff3227ec4ec83fa1046ee26200a02fcd19cf Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Fri, 5 Jun 2026 12:53:34 -0700 Subject: [PATCH 4/6] Thread instance through filter_queryset per review feedback Sources are now resolved in __call__ where the serializer is available and stored on self._sources so filter_queryset can use them when called from __call__. Direct callers of filter_queryset fall back to self.fields. The extraneous instance derivation inside filter_queryset and the now-stale comment in the test are removed. --- rest_framework/validators.py | 17 +++++++++-------- tests/test_validators.py | 3 +-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/rest_framework/validators.py b/rest_framework/validators.py index 9e4cf90c67..6e98e1f1d2 100644 --- a/rest_framework/validators.py +++ b/rest_framework/validators.py @@ -137,19 +137,16 @@ def enforce_required_fields(self, attrs, serializer): if missing_items: raise ValidationError(missing_items, code='required') - def filter_queryset(self, attrs, queryset, serializer): + def filter_queryset(self, attrs, queryset, instance): """ Filter the queryset to all instances matching the given attributes. """ - # field names => field sources - sources = [ - serializer.fields[field_name].source - for field_name in self.fields - ] + # field names => field sources; resolved by __call__ when available, + # otherwise fall back to self.fields for direct callers. + sources = getattr(self, '_sources', None) or list(self.fields) # If this is an update, then any unprovided field should # have it's value set based on the existing instance attribute. - instance = serializer.instance if isinstance(serializer.instance, Model) else None if instance is not None: for source in sources: if source not in attrs: @@ -177,10 +174,14 @@ def __call__(self, attrs, serializer): # per-object uniqueness checks don't try to call .pk / field attrs on # the queryset. instance = serializer.instance if isinstance(serializer.instance, Model) else None + self._sources = [ + serializer.fields[field_name].source + for field_name in self.fields + ] self.enforce_required_fields(attrs, serializer) queryset = self.queryset - queryset = self.filter_queryset(attrs, queryset, serializer) + queryset = self.filter_queryset(attrs, queryset, instance) queryset = self.exclude_current_instance(attrs, queryset, instance) checked_names = [ diff --git a/tests/test_validators.py b/tests/test_validators.py index 9d331182ab..90c0828872 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -513,7 +513,7 @@ def filter(self, **kwargs): serializer = UniquenessTogetherSerializer(instance=self.instance) validator = UniqueTogetherValidator(queryset, fields=('race_name', 'position')) - validator.filter_queryset(attrs=data, queryset=queryset, serializer=serializer) + validator.filter_queryset(attrs=data, queryset=queryset, instance=self.instance) assert queryset.called_with == {'race_name': 'bar', 'position': 1} def test_uniq_together_validation_uses_model_fields_method_field(self): @@ -861,7 +861,6 @@ def test_unique_constraint_with_many_true_does_not_crash(self): }, ] serializer = UniqueConstraintSerializer(instances, data=data, many=True) - # Before the fix this raised: AttributeError: 'QuerySet' object has no attribute 'pk' assert serializer.is_valid(), serializer.errors From 61443c57644ad3e810dd6e722d9bdaa27f0be946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asif=20Saif=20Uddin=20=7B=22Auvi=22=3A=22=E0=A6=85?= =?UTF-8?q?=E0=A6=AD=E0=A6=BF=22=7D?= Date: Sat, 6 Jun 2026 10:52:06 +0600 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/test_validators.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_validators.py b/tests/test_validators.py index 90c0828872..d2b5630eb3 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -863,6 +863,15 @@ def test_unique_constraint_with_many_true_does_not_crash(self): serializer = UniqueConstraintSerializer(instances, data=data, many=True) assert serializer.is_valid(), serializer.errors + # Also cover partial updates: field-level required checks are skipped, + # but uniqueness validators should still fail cleanly (no KeyError / AttributeError). + partial_data = [ + {'global_id': 101}, + {'global_id': 202}, + ] + serializer = UniqueConstraintSerializer(instances, data=partial_data, many=True, partial=True) + assert not serializer.is_valid() + assert isinstance(serializer.errors, list) # Tests for `UniqueForDateValidator` # ---------------------------------- From dc37d8ad281af82a644fcd67189fc2534c24c22b Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sat, 6 Jun 2026 12:42:53 -0700 Subject: [PATCH 6/6] Fix pre-commit lint errors in validators and test file --- rest_framework/validators.py | 11 +++++++++++ tests/test_validators.py | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/rest_framework/validators.py b/rest_framework/validators.py index 6e98e1f1d2..c2f95fc740 100644 --- a/rest_framework/validators.py +++ b/rest_framework/validators.py @@ -180,6 +180,17 @@ def __call__(self, attrs, serializer): ] self.enforce_required_fields(attrs, serializer) + + # When many=True is used, instance is normalized to None above. If the + # serializer is also partial, some unique-together fields may be absent + # from attrs; a uniqueness check is not meaningful in that case. + if instance is None and serializer.partial: + if any( + serializer.fields[field_name].source not in attrs + for field_name in self.fields + ): + return + queryset = self.queryset queryset = self.filter_queryset(attrs, queryset, instance) queryset = self.exclude_current_instance(attrs, queryset, instance) diff --git a/tests/test_validators.py b/tests/test_validators.py index d2b5630eb3..3c304a0e1b 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -510,7 +510,6 @@ def filter(self, **kwargs): data = {'race_name': 'bar'} queryset = MockQueryset() - serializer = UniquenessTogetherSerializer(instance=self.instance) validator = UniqueTogetherValidator(queryset, fields=('race_name', 'position')) validator.filter_queryset(attrs=data, queryset=queryset, instance=self.instance) @@ -865,9 +864,10 @@ def test_unique_constraint_with_many_true_does_not_crash(self): # Also cover partial updates: field-level required checks are skipped, # but uniqueness validators should still fail cleanly (no KeyError / AttributeError). + # Use global_id values that already exist in the DB so the UniqueValidator fires. partial_data = [ - {'global_id': 101}, - {'global_id': 202}, + {'global_id': 1}, + {'global_id': 2}, ] serializer = UniqueConstraintSerializer(instances, data=partial_data, many=True, partial=True) assert not serializer.is_valid() @@ -876,6 +876,7 @@ def test_unique_constraint_with_many_true_does_not_crash(self): # Tests for `UniqueForDateValidator` # ---------------------------------- + class UniqueForDateModel(models.Model): slug = models.CharField(max_length=100, unique_for_date='published') published = models.DateField()