Skip to content

Bulk writes and upserts

Use bulk writes when many rows need the same persistence operation. Aksara builds batched PostgreSQL statements instead of calling save() on every instance. That reduces statement overhead, but changes which hooks run. It is not an automatic authorization boundary or an all-batches transaction.

A complete helper for Ticket Desk

Use the Ticket model from the first-project tutorial and apply its migrations before calling this helper. Save as app/bulk_tickets.py:

app/bulk_tickets.py
from datetime import datetime, timezone

from aksara import transaction
from .models import Ticket


def checked_subject(value):
    subject = value.strip()
    if not subject or len(subject) > 200:
        raise ValueError("A subject must contain 1 to 200 characters")
    return subject


async def create_tickets(subjects, batch_size=1000):
    if batch_size < 1:
        raise ValueError("batch_size must be positive")
    tickets = [Ticket(subject=checked_subject(value)) for value in subjects]
    async with transaction.atomic():
        return await Ticket.objects.bulk_create(tickets, batch_size=batch_size)


async def append_note(tickets, note, batch_size=1000):
    if batch_size < 1:
        raise ValueError("batch_size must be positive")
    if not tickets:
        return 0
    for ticket in tickets:
        ticket.description += note
    async with transaction.atomic():
        return await Ticket.objects.bulk_update(
            tickets, fields=["description"], batch_size=batch_size
        )


async def import_ticket(ticket_id, subject):
    return await Ticket.objects.upsert(
        id=ticket_id,
        defaults={
            "subject": checked_subject(subject),
            "description": "",
            "resolved": False,
            "updated_at": datetime.now(timezone.utc),
        },
        update_fields=["subject", "updated_at"],
    )

The caller must authorize the import and select only tickets it may modify. For a tenant model, establish the tenant context, supply server-owned tenant values, and enforce database RLS as described in the tenant tutorial. Do not accept arbitrary objects or primary keys from an untrusted request and pass them to these helpers.

The helper validates subjects explicitly because direct ORM writes do not invoke HTTP serializer validation. import_ticket() uses a supplied UUID as its conflict key: insert initializes description/resolved, while update changes only subject/updated_at. It does not overwrite an existing ticket's resolved flag.

Method contracts

Method Inputs Return value
bulk_create(objs, batch_size=1000, ignore_conflicts=False) Unsaved instances List of inserted instances; empty input returns []
bulk_update(objs, fields, batch_size=1000) Saved instances and explicit field names Number of affected rows; empty objects or fields raises ValueError
upsert(defaults=None, update_fields=None, **kwargs) Conflict key fields plus insertion values (instance, created)

Pass a positive integer batch size. These methods materialize their inputs and build statements in memory; batching is not a streaming-import API. Choose batch size with row width and PostgreSQL parameter limits in mind. Field preparation, such as file storage or relationship work, can perform additional operations, so row count divided by batch size is not a universal query count.

Field typing in bulk updates

bulk_update() casts each CASE value to the PostgreSQL type declared by its model field. This preserves different per-row values for text, numeric, Boolean, UUID, temporal, enum, nullable, JSON, Array, and Vector fields without relying on PostgreSQL's inference for otherwise untyped parameters. Field conversion still runs before execution, so invalid values fail according to the field's normal database conversion contract.

For a uniform change, a filtered QuerySet.update() remains simpler. Use bulk_update() when rows need different values and verify custom field types against PostgreSQL before adopting them in a bulk path.

Transactions and partial failure

Each individual PostgreSQL statement is atomic. An unwrapped multi-batch call can commit earlier batches before a later batch fails. The two bulk helpers above deliberately use transaction.atomic() so their supported PostgreSQL writes commit or roll back together. Keep their calls sequential on the pinned connection. See transaction limits.

Rollback restores database state, not Python object attributes or _is_new flags already changed by preparation/hydration. Reload or discard affected instances after a failed operation. Storage uploads and other external effects performed during preparation are not undone by a database rollback.

Preparation, validation and signals

bulk_create() checks that instances are unsaved, rejects expression values, runs async field/generic-relation preparation and field validation for all objects before issuing its inserts, and populates auto_now fields. Explicit auto_now_add values are retained. Returned rows hydrate database-generated values into instances.

bulk_update() writes only the named fields through their database conversion. It does not call save(), run full instance validation/preparation, or refresh auto_now automatically. The text-only append_note() helper deliberately leaves updated_at unchanged. This differs from QuerySet.update(), which refreshes auto_now when updating regular fields unless the caller supplied that timestamp.

upsert() converts supplied fields to database values but does not construct and prepare an unsaved instance as create() does. Supply required insertion values and timestamps intentionally. A Python model default is not proof that an omitted upsert column has a database default; inspect the applied migration.

These three manager methods do not emit per-instance pre_save/post_save signals. Do not use a signal as the sole implementation of an invariant that bulk or raw SQL writes must obey. Database constraints still apply.

Expression values are rejected by bulk_create(), bulk_update(), and the insertion values of upsert(). Use the supported QuerySet.update() expression path when you need an F() calculation. Unresolved File/Image upload objects need a preparation-capable save/create path; bulk update and upsert are not upload handlers. Vector conversion has its own advanced-field contract.

Ignoring conflicts

ignore_conflicts=True adds ON CONFLICT DO NOTHING to bulk insertion. It returns only inserted rows; it does not suppress arbitrary validation, CHECK, foreign-key, or permission failures.

When PostgreSQL returns fewer rows than a batch contained, Aksara constructs returned instances from those rows rather than matching them back to every input object. Use the returned list. Do not infer that all input objects were saved, that each input's state was updated, or that a skipped row's existing contents were changed.

Upsert conflict keys

The names in **kwargs form the SQL conflict target; PostgreSQL must have a matching unique constraint or index. The primary key in the helper satisfies that requirement. Using a non-unique field does not turn it into a unique key. An invalid conflict target fails at the database boundary; the manager does not prevalidate every unique-index shape.

Keep key fields separate from defaults. If update_fields is omitted, fields in defaults are updated on conflict. Explicit update fields missing from defaults use PostgreSQL's proposed insertion value (EXCLUDED), which can include database defaults. A keys-only upsert still emits a no-op DO UPDATE to return a row; do not treat it as a read-only lookup or as free of database update-trigger effects. get_or_create() is a separate lookup/create method and does not inherit upsert's single-statement conflict handling.