Skip to content

Inserting and updating many rows at once: bulk_create, upsert and bulk_upsert, their batching, conflict handling, and what they skip.

A loop of create() calls is one round trip per row. These are one per batch.

await Post.bulk_create([
{"title": "First", "body": "…"},
{"title": "Second", "body": "…"},
])
await Post.bulk_create(
items,
batch_size=100,
ignore_conflicts=False,
update_fields=None,
on_conflict=None,
)

items may be dicts or model instances, mixed freely. Dicts are turned into instances first.

ParameterMeaning
batch_sizeRows per statement. Default 100.
ignore_conflictsSkip rows that violate a constraint instead of raising
on_conflictThe fields whose conflict triggers an update
update_fieldsWhat to update when one does

Returns the instances. Casts are applied. Each instance is encoded before its batch is written.

batch_size bounds the size of a single statement, not the operation. 10,000 rows at the default is 100 statements.

Raise it for throughput; lower it if you hit a parameter limit (SQLite’s is 999 by default, and a wide model reaches it quickly) or if long statements are holding locks longer than you want.

await Tag.bulk_create(rows, ignore_conflicts=True)

Rows that would violate a unique constraint are skipped. The others are written.

You do not find out which were skipped. The return value is the instances you passed, not what landed. When you need to know, query afterwards, or use upsert so every row ends up in a known state.

Insert, or update if it is already there. One statement, using the database’s native ON CONFLICT support.

setting = await Setting.upsert(
key="theme",
value="dark",
conflict_fields=["key"],
)
ParameterMeaning
conflict_fieldsThe unique key that decides insert vs update. Required.
update_fieldsWhat to write on a conflict. Defaults to every field except the conflict fields and the primary key.

Returns the row, re-fetched, and fetched through without_global_scopes(), so upserting a soft-deleted row still returns it rather than raising DoesNotExist.

get_or_create is a SELECT then an INSERT, and two concurrent callers can both find nothing and both insert.

upsert is one statement, so the database resolves the race. Prefer it whenever the row might be written concurrently: a webhook handler, a job that can be retried, anything idempotent by design.

The same, for many rows:

await Setting.bulk_upsert(
[
{"key": "theme", "value": "dark"},
{"key": "locale", "value": "en"},
],
conflict_fields=["key"],
update_fields=["value"],
batch_size=100,
)

This is the shape for syncing from an external source: pull the current state, upsert the lot, and let the database decide row by row what was new.

Applied?
CastsYes
Model eventsNo
ValidatesBeforeSaveMixinNo
Auto updated_at on a conflict updateDepends on update_fields

Events and validation are skipped because they are per-instance hooks and these paths do not call save(). That is the deliberate trade (loading and hooking every row would defeat the point) but it means:

  • validate the input yourself before a bulk write;
  • fire any follow-on work explicitly afterwards;
  • include updated_at in update_fields if you want it to move.
from sillo.record.transactions import transaction
async with transaction():
await Post.bulk_create(batch_one)
await Tag.bulk_create(batch_two)

A multi-batch write that fails halfway has already committed the earlier batches. A transaction makes the whole thing one unit.

For a handful of rows, create() in a loop is clearer, fires events, and runs validation. The cost of ten round trips is not worth the loss of all three.

Bulk operations are for hundreds and up: an import, a backfill, a sync.