From 0dffeafc773fd0dcd9969a134725822fece94de9 Mon Sep 17 00:00:00 2001 From: "A.Adarsh Jagannath" Date: Tue, 21 Jul 2026 22:19:50 +0530 Subject: [PATCH] fix(validators): guard UniqueConstraint validators against queryset instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #9484 When a ModelSerializer is used with many=True and an instance queryset (e.g. for bulk create via ListSerializer), the child serializer receives the parent queryset as its `.instance` instead of a model instance. This causes a hard crash in `UniqueTogetherValidator.exclude_current_instance` and `UniqueValidator.exclude_current_instance` when they call `queryset.exclude(pk=instance.pk)` — because a QuerySet has no `.pk` attribute: AttributeError: 'BaseQuerySet' object has no attribute 'pk' Reproducing case (from issue #9484): class PetSerializer(serializers.ModelSerializer): class Meta: model = Pet # has UniqueConstraint on (name, animal_type) fields = ('name', 'animal_type', 'can_fly') # This crashes on .is_valid() serializer = PetListSerializer( instances, # <- queryset, not a single instance data=[{"name": "Polly", "animal_type": "Parrot", "can_fly": True}, ...], many=True, ) Root cause: `many_init` correctly passes `instance` to the ListSerializer, but the child serializer (PetSerializer) inherits the same `instance` reference — which is the queryset. When the UniqueConstraint validators run on the child, `serializer.instance` is the queryset, and `.pk` is undefined on it. Fix: Add a `_is_single_instance` helper that returns True only when `instance` is a non-None, non-queryset, non-list object that has a `.pk` attribute. Both `UniqueValidator.exclude_current_instance` and `UniqueTogetherValidator.exclude_current_instance` now call this helper before accessing `instance.pk`, treating a queryset/list as "no instance" (i.e. a create operation) for the purposes of the uniqueness exclusion. This is the minimal, backward-compatible fix: - Single-instance update: behaviour unchanged (instance.pk still used) - Bulk create (ListSerializer + queryset): validators no longer crash, correctly treat each item as a create - Existing tests: all pass unchanged --- rest_framework/validators.py | 44 +++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/rest_framework/validators.py b/rest_framework/validators.py index cc759b39cc..cc54f3e0d5 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, QuerySet from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError @@ -43,6 +43,26 @@ def qs_filter(queryset, **kwargs): return queryset.none() +def _is_single_instance(instance): + """ + Return True if `instance` is a saved model instance (i.e. not None, + not a QuerySet, not a list/tuple), and has a `.pk` attribute. + + When a serializer is used with many=True and a queryset is passed as + `instance` (e.g. for ListSerializer bulk-create), the child serializer + inherits that queryset as its `.instance`. Calling `.pk` on a QuerySet + raises AttributeError, crashing the UniqueConstraint validators. + + This helper guards against that case so validators can treat a queryset + or list `instance` as a create operation rather than an update. + """ + if instance is None: + return False + if isinstance(instance, (QuerySet, list, tuple)): + return False + return hasattr(instance, 'pk') + + class UniqueValidator: """ Validator that corresponds to `unique=True` on a model field. @@ -68,8 +88,12 @@ def exclude_current_instance(self, queryset, instance): """ If an instance is being updated, then do not include that instance itself as a uniqueness conflict. + + When `instance` is a QuerySet or list (e.g. when the serializer is + used inside a ListSerializer with many=True), treat this as a create + operation and do not attempt to exclude by pk. """ - if instance is not None: + if _is_single_instance(instance): return queryset.exclude(pk=instance.pk) return queryset @@ -149,7 +173,7 @@ 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: + if _is_single_instance(serializer.instance): for source in sources: if source not in attrs: attrs[source] = getattr(serializer.instance, source) @@ -165,8 +189,12 @@ 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. + + When `instance` is a QuerySet or list (e.g. when the serializer is + used inside a ListSerializer with many=True), treat this as a create + operation and do not attempt to exclude by pk. """ - if instance is not None: + if _is_single_instance(instance): return queryset.exclude(pk=instance.pk) return queryset @@ -180,7 +208,7 @@ def __call__(self, attrs, serializer): serializer.fields[field_name].source for field_name in self.fields ] # Ignore validation if any field is None - if serializer.instance is None: + if not _is_single_instance(serializer.instance): checked_values = [attrs[field_name] for field_name in checked_names] else: # Ignore validation if all field values are unchanged @@ -272,8 +300,12 @@ 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. + + When `instance` is a QuerySet or list (e.g. when the serializer is + used inside a ListSerializer with many=True), treat this as a create + operation and do not attempt to exclude by pk. """ - if instance is not None: + if _is_single_instance(instance): return queryset.exclude(pk=instance.pk) return queryset