From 34065b70e7c3c04f4d731806efde222e8d136374 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 8 Feb 2026 15:05:58 -0800 Subject: [PATCH 1/7] Fix DictField returning empty dict instead of empty for missing HTML input (#6234) When using DictField with HTML form (multipart/form-data) input, parse_html_dict always returned an empty MultiValueDict when no matching keys were found. This made it impossible to distinguish between an unspecified field and an empty input, causing issues with required/default field handling. This aligns parse_html_dict with parse_html_list by adding a default parameter that is returned when no matching keys are found. Co-Authored-By: Claude Opus 4.6 --- rest_framework/fields.py | 4 +-- rest_framework/serializers.py | 2 +- rest_framework/utils/html.py | 7 +++-- tests/test_fields.py | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index f5009a7303..53a9536ec8 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1749,7 +1749,7 @@ def get_value(self, dictionary): # We override the default field access in order to support # dictionaries in HTML forms. if html.is_html_input(dictionary): - return html.parse_html_dict(dictionary, prefix=self.field_name) + return html.parse_html_dict(dictionary, prefix=self.field_name, default=empty) return dictionary.get(self.field_name, empty) def to_internal_value(self, data): @@ -1757,7 +1757,7 @@ def to_internal_value(self, data): Dicts of native values <- Dicts of primitive datatypes. """ if html.is_html_input(data): - data = html.parse_html_dict(data) + data = html.parse_html_dict(data, default=data) if not isinstance(data, dict): self.fail('not_a_dict', input_type=type(data).__name__) if not self.allow_empty and len(data) == 0: diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ca60810df1..38be958b7c 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -428,7 +428,7 @@ def get_value(self, dictionary): # We override the default field access in order to support # nested HTML forms. if html.is_html_input(dictionary): - return html.parse_html_dict(dictionary, prefix=self.field_name) or empty + return html.parse_html_dict(dictionary, prefix=self.field_name, default=empty) return dictionary.get(self.field_name, empty) def run_validation(self, data=empty): diff --git a/rest_framework/utils/html.py b/rest_framework/utils/html.py index c7ede78035..22aa2f2a78 100644 --- a/rest_framework/utils/html.py +++ b/rest_framework/utils/html.py @@ -66,7 +66,7 @@ def parse_html_list(dictionary, prefix='', default=None): return [ret[item] for item in sorted(ret)] if ret else default -def parse_html_dict(dictionary, prefix=''): +def parse_html_dict(dictionary, prefix='', default=None): """ Used to support dictionary values in HTML forms. @@ -81,6 +81,9 @@ def parse_html_dict(dictionary, prefix=''): 'email': 'example@example.com' } } + + :returns a MultiValueDict of the parsed data, or the value specified in + ``default`` if the dict field was not present in the input """ ret = MultiValueDict() regex = re.compile(r'^%s\.(.+)$' % re.escape(prefix)) @@ -92,4 +95,4 @@ def parse_html_dict(dictionary, prefix=''): value = dictionary.getlist(field) ret.setlist(key, value) - return ret + return ret if ret else default diff --git a/tests/test_fields.py b/tests/test_fields.py index e360793184..95b1ad64b8 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2496,6 +2496,56 @@ def test_allow_empty_disallowed(self): assert exc_info.value.detail == ['This dictionary may not be empty.'] + def test_querydict_dict_input(self): + """ + DictField should correctly parse HTML form (QueryDict) input + with dot-separated keys. + """ + class TestSerializer(serializers.Serializer): + data = serializers.DictField(child=serializers.CharField()) + + serializer = TestSerializer(data=QueryDict('data.a=1&data.b=2')) + assert serializer.is_valid() + assert serializer.validated_data == {'data': {'a': '1', 'b': '2'}} + + def test_querydict_dict_input_no_values_uses_default(self): + """ + When no matching keys are present in the QueryDict and a default + is set, the field should return the default value. + """ + class TestSerializer(serializers.Serializer): + a = serializers.IntegerField(required=True) + data = serializers.DictField(default=lambda: {'x': 'y'}) + + serializer = TestSerializer(data=QueryDict('a=1')) + assert serializer.is_valid() + assert serializer.validated_data == {'a': 1, 'data': {'x': 'y'}} + + def test_querydict_dict_input_no_values_no_default_and_not_required(self): + """ + When no matching keys are present in the QueryDict, there is no + default, and the field is not required, the field should be + skipped entirely from validated_data. + """ + class TestSerializer(serializers.Serializer): + data = serializers.DictField(required=False) + + serializer = TestSerializer(data=QueryDict('')) + assert serializer.is_valid() + assert serializer.validated_data == {} + + def test_querydict_dict_input_no_values_required(self): + """ + When no matching keys are present in the QueryDict and the field + is required, validation should fail. + """ + class TestSerializer(serializers.Serializer): + data = serializers.DictField(required=True) + + serializer = TestSerializer(data=QueryDict('')) + assert not serializer.is_valid() + assert 'data' in serializer.errors + class TestNestedDictField(FieldValues): """ From 506ff842b3961de627fe8a638b85585b0514ba38 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 22 Feb 2026 18:51:45 -0800 Subject: [PATCH 2/7] Address review feedback: add regression test, improve assertions - Add regression test for nested serializer with QueryDict to cover the serializers.py get_value change (test_nested_serializer_not_required_with_querydict) - Use `assert serializer.is_valid(), serializer.errors` for better test output on failures - Keep `default=data` in to_internal_value as `default={}` would discard already-parsed MultiValueDict keys from get_value --- tests/test_fields.py | 6 +++--- tests/test_serializer.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index 95b1ad64b8..a0c41933fb 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2505,7 +2505,7 @@ class TestSerializer(serializers.Serializer): data = serializers.DictField(child=serializers.CharField()) serializer = TestSerializer(data=QueryDict('data.a=1&data.b=2')) - assert serializer.is_valid() + assert serializer.is_valid(), serializer.errors assert serializer.validated_data == {'data': {'a': '1', 'b': '2'}} def test_querydict_dict_input_no_values_uses_default(self): @@ -2518,7 +2518,7 @@ class TestSerializer(serializers.Serializer): data = serializers.DictField(default=lambda: {'x': 'y'}) serializer = TestSerializer(data=QueryDict('a=1')) - assert serializer.is_valid() + assert serializer.is_valid(), serializer.errors assert serializer.validated_data == {'a': 1, 'data': {'x': 'y'}} def test_querydict_dict_input_no_values_no_default_and_not_required(self): @@ -2531,7 +2531,7 @@ class TestSerializer(serializers.Serializer): data = serializers.DictField(required=False) serializer = TestSerializer(data=QueryDict('')) - assert serializer.is_valid() + assert serializer.is_valid(), serializer.errors assert serializer.validated_data == {} def test_querydict_dict_input_no_values_required(self): diff --git a/tests/test_serializer.py b/tests/test_serializer.py index ed8a749118..fac49dcb94 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -552,6 +552,26 @@ class Serializer(serializers.Serializer): assert Serializer({'nested': {'a': '3', 'b': {}}}).data == {'nested': {'a': '3', 'c': '2'}} assert Serializer({'nested': {'a': '3', 'b': {'c': '4'}}}).data == {'nested': {'a': '3', 'c': '4'}} + def test_nested_serializer_not_required_with_querydict(self): + """ + When a nested serializer is not required and the QueryDict does + not contain any matching prefixed keys, the nested serializer + should be omitted from validated_data. Regression test for #6234. + """ + from django.http import QueryDict + + class NestedSerializer(serializers.Serializer): + x = serializers.CharField() + + class ParentSerializer(serializers.Serializer): + name = serializers.CharField() + nested = NestedSerializer(required=False) + + serializer = ParentSerializer(data=QueryDict("name=test")) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == {"name": "test"} + assert "nested" not in serializer.validated_data + def test_default_for_allow_null(self): """ Without an explicit default, allow_null implies default=None when serializing. #5518 #5708 From 4e1adc1359228ca90433abc18774a56ac2b9ccec Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Mon, 23 Feb 2026 23:34:03 -0800 Subject: [PATCH 3/7] Use empty dict as default in DictField.to_internal_value() Change default from data to {} in parse_html_dict call within DictField.to_internal_value(), falling back to a dict conversion of the MultiValueDict when no dot-separated keys are found. This is cleaner than using the input data as its own default. --- rest_framework/fields.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 53a9536ec8..26e0e639ad 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1757,7 +1757,9 @@ def to_internal_value(self, data): Dicts of native values <- Dicts of primitive datatypes. """ if html.is_html_input(data): - data = html.parse_html_dict(data, default=data) + data = html.parse_html_dict(data, default={}) or { + k: v for k, v in data.items() + } if not isinstance(data, dict): self.fail('not_a_dict', input_type=type(data).__name__) if not self.allow_empty and len(data) == 0: From 5242d9a810eaf84ec53841cc1406dbc51f9b92c6 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Wed, 1 Apr 2026 11:40:18 +0100 Subject: [PATCH 4/7] rename some tests --- tests/test_fields.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index 24295a042f..e1cb492877 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2522,7 +2522,7 @@ def test_allow_empty_disallowed(self): assert exc_info.value.detail == ['This dictionary may not be empty.'] - def test_querydict_dict_input(self): + def test_query_dict_input_with_dot_separated_keys(self): """ DictField should correctly parse HTML form (QueryDict) input with dot-separated keys. @@ -2534,7 +2534,7 @@ class TestSerializer(serializers.Serializer): assert serializer.is_valid(), serializer.errors assert serializer.validated_data == {'data': {'a': '1', 'b': '2'}} - def test_querydict_dict_input_no_values_uses_default(self): + def test_query_dict_input_no_values_uses_default(self): """ When no matching keys are present in the QueryDict and a default is set, the field should return the default value. @@ -2547,7 +2547,7 @@ class TestSerializer(serializers.Serializer): assert serializer.is_valid(), serializer.errors assert serializer.validated_data == {'a': 1, 'data': {'x': 'y'}} - def test_querydict_dict_input_no_values_no_default_and_not_required(self): + def test_query_dict_input_no_values_no_default_and_not_required(self): """ When no matching keys are present in the QueryDict, there is no default, and the field is not required, the field should be @@ -2560,7 +2560,7 @@ class TestSerializer(serializers.Serializer): assert serializer.is_valid(), serializer.errors assert serializer.validated_data == {} - def test_querydict_dict_input_no_values_required(self): + def test_query_dict_input_no_values_required(self): """ When no matching keys are present in the QueryDict and the field is required, validation should fail. From 3324ed6a2f0befd4c8b152a215df4a386f45283a Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Wed, 1 Apr 2026 11:41:40 +0100 Subject: [PATCH 5/7] Add failing test --- tests/test_fields.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_fields.py b/tests/test_fields.py index e1cb492877..b9a959d5c0 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2572,6 +2572,24 @@ class TestSerializer(serializers.Serializer): assert not serializer.is_valid() assert 'data' in serializer.errors + def test_partial_update_can_clear_html_dict_field(self): + """ + Test that a partial update can clear a DictField when provided with an + empty string value through a QueryDict. + """ + class TestSerializer(serializers.Serializer): + field_name = serializers.DictField(required=False) + other_field = serializers.CharField(required=False) + + serializer = TestSerializer( + data=QueryDict('field_name='), + partial=True, + ) + assert serializer.is_valid() + assert 'field_name' in serializer.validated_data + assert serializer.validated_data['field_name'] == {} + assert 'other_field' not in serializer.validated_data + class TestNestedDictField(FieldValues): """ From 665cf392a6d87781c9a509afd257fb2e146a1ac3 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Wed, 1 Apr 2026 11:45:00 +0100 Subject: [PATCH 6/7] Add support for clearing a dict field with form data --- rest_framework/fields.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 5c3bd3afad..393618f0ca 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1754,7 +1754,14 @@ def get_value(self, dictionary): # We override the default field access in order to support # dictionaries in HTML forms. if html.is_html_input(dictionary): - return html.parse_html_dict(dictionary, prefix=self.field_name, default=empty) + result = html.parse_html_dict(dictionary, prefix=self.field_name, default=empty) + if result is not empty: + return result + # If the field name itself is present in the input, + # treat it as an explicit empty dict (e.g. clearing the field). + if self.field_name in dictionary: + return {} + return empty return dictionary.get(self.field_name, empty) def to_internal_value(self, data): From e9ebed5135ffbb54d2ad76412ef1438713b822eb 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: Thu, 2 Apr 2026 22:14:07 +0600 Subject: [PATCH 7/7] Update rest_framework/fields.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- rest_framework/fields.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 393618f0ca..60e14890fb 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1769,9 +1769,13 @@ def to_internal_value(self, data): Dicts of native values <- Dicts of primitive datatypes. """ if html.is_html_input(data): - data = html.parse_html_dict(data, default={}) or { - k: v for k, v in data.items() - } + # Coerce HTML form inputs (e.g. QueryDict/MultiValueDict) to a plain dict. + # Use `.dict()` when available to preserve existing behaviour of taking + # the first value for each key, otherwise fall back to `dict()`. + if hasattr(data, 'dict'): + data = data.dict() + else: + data = dict(data) if not isinstance(data, dict): self.fail('not_a_dict', input_type=type(data).__name__) if not self.allow_empty and len(data) == 0: