Skip to content

[2d] Add bulk_upsert() and make bulk_create insert-only#86

Draft
davegaeddert wants to merge 5 commits into
returningfrom
bulk-upsert
Draft

[2d] Add bulk_upsert() and make bulk_create insert-only#86
davegaeddert wants to merge 5 commits into
returningfrom
bulk-upsert

Conversation

@davegaeddert

@davegaeddert davegaeddert commented Jul 23, 2026

Copy link
Copy Markdown
Member

What

Adds QuerySet.bulk_upsert() — a clearly-named, RETURNING-backed replacement for the old bulk_create(update_conflicts=True, ...) surface — and narrows bulk_create to insert-only.

Stacked on the returning branch.

API

QuerySet.bulk_upsert(objs, *, update_fields: list[Field], unique_fields: list[Field], batch_size=None) -> list[T]

update_fields and unique_fields take typed field references (e.g. update_fields=[CachedItem.value], unique_fields=[CachedItem.key]), validated by a shared _validate_field_refs() — a string or a field from the wrong model raises TypeError/FieldError at the call, before any batch is issued.

Issues one INSERT ... ON CONFLICT (unique_fields) DO UPDATE SET col = EXCLUDED.col ... RETURNING ... per batch, wrapped in a single atomic transaction. Every object comes back — both freshly inserted and conflict-updated rows — with its DB-returned fields (primary key, DB defaults) populated. DO UPDATE returns all targeted rows, so updates are hydrated too, not just inserts.

Row matching by unique key (not zip order)

Under ON CONFLICT, Postgres does not guarantee RETURNING order matches VALUES order, so results can't be zipped back to the input objects positionally. The RETURNING clause carries the DB-returned fields plus the unique fields; after execution we build a lookup keyed by the tuple of unique-field values from each returned row, then assign every object the fields from its matching row by that key. The unique fields are excluded from update_fields (validated), so a conflicting row's key column keeps its value and always matches.

Deadlock-avoidance ordering

Each call sorts the objects by the conflict key before issuing the batches. Concurrent bulk_upserts touching overlapping keys in different input orders then lock rows in the same order and can't deadlock. Input order is preserved in the returned list (only the batch issuance is sorted).

Validation (at call time)

  • unique_fields must name the primary key, or a condition-free / expression-free UniqueConstraint declared on the model (checked via unique_fields_match_constraint, which matches against total_unique_constraints). Clear error naming the model and fields otherwise.
  • update_fields must be concrete, non-PK, and must not overlap unique_fields.
  • Every object must have a non-null value for every unique field — NULL never conflicts in Postgres, so upsert semantics are meaningless and row-matching impossible.

bulk_create narrowing

bulk_create loses its update_conflicts / update_fields / unique_fields parameters and the top-level OnConflict plumbing behind them; it is now insert-only. The shared _batched_insert helper still threads on_conflict/update_fields/unique_fields through to _insert() per batch — bulk_upsert() is the only caller that now passes them. No deprecation shim — the upgrade agent fixes user code.

Internal caller migrated

plain.cache's set_many() switched from bulk_create(update_conflicts=True, ...) to bulk_upsert(update_fields=[model.value, model.expires_at, model.updated_at], unique_fields=[model.key]). No other update_conflicts usages exist in the repo.

Tests

New public tests in plain-postgres/tests/public/test_bulk_upsert.py: insert-only, mixed insert/update batch (PKs + updated values), update_fields scoping, by-key matching when every row conflicts, batching, empty input, and each validation error (unmatched constraint, null unique value, overlapping fields, string field, wrong-model field). A bulk_create test asserts the conflict kwargs now raise TypeError. plain-cache's suite still passes after the migration. Full ./scripts/test, ./scripts/type-check plain-postgres, and ./scripts/fix are green.

- Fold _batched_upsert into _batched_insert via optional conflict kwargs
- Compute each object's unique key once, reused for null-check, sort, and
  RETURNING row matching
- Remove unreachable OnConflict.IGNORE / ON CONFLICT DO NOTHING branch
- Drop redundant ManyToManyField check (non-concrete already covers it)
- Use enumerate() for db_returning row indices
- Move the unique-fields-match-constraint predicate onto Options
@davegaeddert davegaeddert changed the title Add bulk_upsert() and make bulk_create insert-only [merge 5] Add bulk_upsert() and make bulk_create insert-only Jul 23, 2026
@davegaeddert davegaeddert changed the title [merge 5] Add bulk_upsert() and make bulk_create insert-only [2d] Add bulk_upsert() and make bulk_create insert-only Jul 23, 2026
update_fields and unique_fields now accept Field references (Model.field)
instead of column-name strings. A shared _validate_field_refs() helper
enforces that each ref is a Field belonging to the queryset's model (also
now backing returning()'s validation), so a string or a wrong-model ref
raises a clear error pointing at Model.field. unique_fields_match_constraint
still works on the derived name set. Migrates the plain-cache set_many()
caller to CachedItem field references.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant