Avoid N+1 queries in PrimaryKeyRelatedField(many=True) validation - #9984
Avoid N+1 queries in PrimaryKeyRelatedField(many=True) validation#9984adelkhayata76 wants to merge 9 commits into
Conversation
ManyRelatedField.to_internal_value resolved each related object with a separate child_relation.to_internal_value() call, so validating a list of N primary keys issued N SELECT queries (encode#9607). Add an opt-in to_internal_value_bulk() hook on RelatedField (defaulting to the existing per-item loop, so SlugRelatedField, HyperlinkedRelatedField and custom relations are unchanged) and override it on PrimaryKeyRelatedField to resolve every pk with a single in_bulk() query. Per-item error semantics (incorrect_type / does_not_exist), input ordering, duplicate handling, the queryset filter and pk_field transforms are all preserved; a type the backend cannot compare falls back to the per-item path so the offending item still raises the same error.
Handle string primary keys (e.g. from HTML form input): in_bulk() keys its result by the database pk type, so a string "1" must be coerced via the pk field's get_prep_value() before the membership check, exactly as queryset.get(pk=...) does. Without this, string pks raised a spurious does_not_exist error. Also make the regression tests rely on the pks actually created in setUp rather than hard-coding 1..5, which is not guaranteed across backends/test ordering, and add an explicit string-pk test.
There was a problem hiding this comment.
Pull request overview
This PR introduces an opt-in bulk validation hook for relational fields to eliminate N+1 queries during PrimaryKeyRelatedField(many=True) validation, resolving related instances via a single in_bulk() query and adding regression coverage for query count and semantic parity.
Changes:
- Added
RelatedField.to_internal_value_bulk()as a bulk-conversion hook used byManyRelatedField. - Implemented a batched
to_internal_value_bulk()onPrimaryKeyRelatedFieldusingqueryset.in_bulk(...). - Added regression tests to ensure
PrimaryKeyRelatedField(many=True)validates with one query and preserves ordering/duplicates/errors/pk_fieldbehavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
rest_framework/relations.py |
Adds the bulk validation hook and switches ManyRelatedField to use it; implements PK bulk resolution via in_bulk(). |
tests/test_relations_pk.py |
Adds regression tests asserting single-query validation and parity behaviors for PK many validation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| self.child_relation.to_internal_value(item) | ||
| for item in data | ||
| ] | ||
| return self.child_relation.to_internal_value_bulk(data) |
| pks = [] | ||
| for item in data: | ||
| value = item | ||
| if self.pk_field is not None: | ||
| value = self.pk_field.to_internal_value(value) | ||
| try: | ||
| if isinstance(value, bool): | ||
| raise TypeError | ||
| # Coerce to the pk's Python type (e.g. "1" -> 1) so the lookup | ||
| # below matches the keys returned by `in_bulk()`, exactly as | ||
| # `queryset.get(pk=value)` would have. | ||
| value = model_pk.get_prep_value(value) | ||
| except (TypeError, ValueError): | ||
| self.fail('incorrect_type', data_type=type(item).__name__) | ||
| pks.append(value) | ||
| try: | ||
| objects = queryset.in_bulk(pks) | ||
| except (TypeError, ValueError): | ||
| # queryset doesn't support in_bulk (e.g. distinct/sliced); fall | ||
| # back to the per-item path so behavior is unchanged. | ||
| return [self.to_internal_value(item) for item in data] | ||
| result = [] | ||
| for pk in pks: | ||
| if pk not in objects: | ||
| self.fail('does_not_exist', pk_value=pk) |
- Report `incorrect_type` / `does_not_exist` details using the post-`pk_field` value (matching the per-item `to_internal_value` path) instead of the raw input or the pk-coerced lookup key. With a type-changing `pk_field` (e.g. BooleanField) the bulk path previously reported a different `data_type`. - `ManyRelatedField.to_internal_value` now falls back to the per-item loop when the child field has no `to_internal_value_bulk`, so wrapping a non-RelatedField child no longer raises AttributeError. Adds regression tests for both.
|
Thanks for the review. Addressed both points in 1. bulk = getattr(self.child_relation, 'to_internal_value_bulk', None)
if bulk is not None:
return bulk(data)
return [self.child_relation.to_internal_value(item) for item in data]2. Error-detail divergence with a custom Added regression tests for both: one asserting the bulk error detail matches the per-item path under a type-changing |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
rest_framework/relations.py:293
PrimaryKeyRelatedField.to_internal_value_bulk()currently raisesincorrect_typeduring the pre-processing loop, before it knows whether an earlier item would have raiseddoes_not_exist. This can change which error is reported for mixed inputs (e.g.[missing_pk, object()]would raiseincorrect_typein the bulk path, but the per-item path raisesdoes_not_existfor the first element). To preserve per-item left-to-right error precedence, defer raisingincorrect_typeuntil after the bulk query and then iterate in order, raising the first error that would occur per-item (missing vs incorrect type).
# below matches the keys returned by `in_bulk()`, exactly as
# `queryset.get(pk=value)` would have.
lookup_key = model_pk.get_prep_value(value)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(value).__name__)
rest_framework/relations.py:574
ManyRelatedField.to_internal_value()callsto_internal_value_bulkwhenever the attribute exists, but it doesn't verify it's callable. A non-callable attribute with that name (accidental or otherwise) would raise a confusingTypeError. Safer to gate oncallable()and fall back to the per-item loop otherwise.
# `to_internal_value_bulk` is defined on `RelatedField`; fall back to
# the per-item loop for any other child field type.
bulk = getattr(self.child_relation, 'to_internal_value_bulk', None)
if bulk is not None:
return bulk(data)
| with pytest.raises(serializers.ValidationError) as exc_info: | ||
| field.run_validation(['not-a-pk']) | ||
| assert exc_info.value.detail[0].code == 'incorrect_type' | ||
|
|
JPDSousa
left a comment
There was a problem hiding this comment.
Have you explored the alternative of using a class PrimaryKeyManyRelatedField(ManyRelatedField) which implements .to_internal_value, rather than adding one more extension point to RelatedField?
I have no strong position towards any option, but raising this here, as other may have.
| # Coerce to the pk's Python type (e.g. "1" -> 1) so the lookup | ||
| # below matches the keys returned by `in_bulk()`, exactly as | ||
| # `queryset.get(pk=value)` would have. | ||
| lookup_key = model_pk.get_prep_value(value) |
There was a problem hiding this comment.
Isn't this redundant with what queryset will do internally?
There was a problem hiding this comment.
Django does coerce values for SQL, but in_bulk() returns a dict keyed by the instance pk (1), not the input ("1"). Without get_prep_value, HTML string pks miss the dict and raise does_not_exist. The bool guard is also required (bool is a subclass of int).
| result = [] | ||
| for lookup_key, value in entries: | ||
| if lookup_key not in objects: | ||
| self.fail('does_not_exist', pk_value=value) |
There was a problem hiding this comment.
You should report errors in bulk as well, rather than just failing on the first invalid item. That is consistent with error handling in multi-item fields.
There was a problem hiding this comment.
Same change as above — does_not_exist is collected with the rest of the list instead of aborting on the first missing pk.
| # `queryset.get(pk=value)` would have. | ||
| lookup_key = model_pk.get_prep_value(value) | ||
| except (TypeError, ValueError): | ||
| self.fail('incorrect_type', data_type=type(value).__name__) |
There was a problem hiding this comment.
Per-item errors should be reported in bulk as well. This is the common behavior for ListSerializer and ListField
There was a problem hiding this comment.
Done. to_internal_value_bulk now collects every incorrect_type, does_not_exist, and pk_field error into an index-keyed dict — the same shape as ListField.run_child_validation (and the ListSerializer(many=True) dict format from #9837). One in_bulk() query is still used whenever any items are type-valid.
| except (TypeError, ValueError): | ||
| self.fail('incorrect_type', data_type=type(data).__name__) | ||
|
|
||
| def to_internal_value_bulk(self, data): |
There was a problem hiding this comment.
I know that .prefetch_related on the queryset removes the N+1 problem on read operations, but shouldn't we also implement a consistent optimization for .to_representation? Reasons being:
- Developers don't need to remember about
.prefetch_related - We can use
.values_list('pk', flat=True)to return just the primary keys this field needs.
There was a problem hiding this comment.
Agreed that a matching read-side optimization would be useful so callers don't have to remember prefetch_related. I'd rather keep this PR on write-side validation and take to_representation / values_list('pk') as a follow-up — happy to open an issue if you want that tracked.
There was a problem hiding this comment.
Update: GitHub blocks issue creation on encode/django-rest-framework for non-collaborators (see the “limited to collaborators” banner). I left the full issue text in a PR comment for you or a maintainer to file: #9984 (comment)
Report every incorrect_type, does_not_exist, and pk_field error in one index-keyed ValidationError, matching ListField, instead of failing on the first item. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@JPDSousa on the |
I meant that you could override |
Override PrimaryKeyRelatedField.many_init so many=True builds a dedicated ManyRelatedField subclass with in_bulk validation. Removes to_internal_value_bulk from RelatedField and leaves Slug/Hyperlinked on the default path. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@JPDSousa thanks for the clarification — that makes sense. Switched in |
|
@JPDSousa re: opening the Title: Optimize Description
Proposal For the primary-key many case, batch representation so callers do not need to remember queryset.values_list('pk', flat=True)(or an equivalent that still honors Why
Scope notes
Related discussion: #9984 (comment) |
|
@auvipy What are the next steps to get this merged? |
Fixes #9607.
ManyRelatedField.to_internal_valueresolved each related object with its ownto_internal_value()call, so validating a list of N primary keys ran N SELECT queries. As @sevdog noted on the issue, the many-related path delegates per-item and does no DB-level batching.Change
PrimaryKeyRelatedField.many_initto return a privatePrimaryKeyManyRelatedFieldthat resolves every pk with a singlein_bulk()query.SlugRelatedField,HyperlinkedRelatedField, and custom relations keep the defaultRelatedField.many_init→ManyRelatedFieldpath (no new extension point onRelatedField).ValidationError, matchingListField.run_child_validationand theListSerializer(many=True)dict format from Change errors for list serializers (many=True) to dict format #9837.Errors: all invalid indexes, one query
incorrect_type,does_not_exist, andpk_fieldfailures are reported together as{index: [ErrorDetail, ...]}instead of failing on the first item. Input ordering, duplicate handling, the queryset filter, andpk_fieldtransforms are preserved. A queryset that cannot usein_bulk()(e.g. sliced) falls back to a collecting per-item loop.Tests
Adds regression tests in
tests/test_relations_pk.py, including anassertNumQueries(1)guard, parity tests for ordering/duplicates/queryset filtering/pk_field, mixeddoes_not_exist/incorrect_type, collectedpk_fieldvalidation errors, and thatmany=TruebuildsPrimaryKeyManyRelatedField.Follow-up
ListSerializer.createhas the same per-item shape (also flagged on the issue); left out here to keep this change surgical. Read-sideto_representationbatching (values_list('pk')) is tracked separately — see discussion on this PR.