Avoid N+1 queries in PrimaryKeyRelatedField(many=True) validation - #9984
Avoid N+1 queries in PrimaryKeyRelatedField(many=True) validation#9984adelkhayata76 wants to merge 13 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.
- 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.
| 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.
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? |
|
lets wait for another round of review |
There was a problem hiding this comment.
🟡 Changes recommended
Custom relation overrides and custom primary-key conversions can break, while the sliced-queryset fallback rejects valid values.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
rest_framework/relations.py:639
get_prep_value()produces a database lookup value, not necessarily the Python value stored inobj.pk. Passing that prepared value toin_bulk()causes Django to prepare it again, and the returned mapping is keyed by the model's Python PK; custom primary-key fields with non-idempotent preparation can therefore query the wrong key or report an existing object as missing. Use the field's Python conversion for comparison keys and let the ORM perform database preparation once.
# 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)
rest_framework/relations.py:655
- The stated sliced-queryset fallback does not work: after
in_bulk()rejects a sliced queryset,child.to_internal_value()callsqueryset.get(pk=...), which also attempts to filter the sliced queryset and raisesTypeError. Because the child catches that exception asincorrect_type, even valid PKs are rejected. The new test only mocksin_bulk()on an unsliced queryset, so it misses this behavior; handle sliced querysets without callingget()on the slice and test with an actual slice.
except (TypeError, ValueError):
# queryset doesn't support in_bulk (e.g. distinct/sliced); fall
# back to a collecting per-item loop so mixed lists still report
# every invalid item.
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Sliced querysets still fail in fallback, and strict Python key matching can reject database-valid primary keys.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
| # queryset doesn't support in_bulk (e.g. distinct/sliced); fall | ||
| # back to a collecting per-item loop so mixed lists still report | ||
| # every invalid item. |
There was a problem hiding this comment.
@adelkhayata76 please cross check and fix the open suggestions
| for idx, lookup_key, value in entries: | ||
| if lookup_key not in objects: | ||
| try: | ||
| child.fail('does_not_exist', pk_value=value) |
Materialize sliced querysets once instead of calling get() on them. Recover Python key misses after in_bulk via one filter probe, then guarded get only when needed. Add subclass and real-slice regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@auvipy Cross-checked the open Copilot suggestions and addressed them in
|
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.