Django’s bulk_create(), bulk_update(), and update() are fast because they skip save(). That also means they skip the pre_save and post_save signals, so your receivers never fire. It’s documented for bulk_create(), bulk_update(), and update(), but nothing warns you at runtime. If a signal invalidates a cache, writes an audit log, or syncs a search index, one bulk call quietly skips that critical work. This post shows how to make those calls fail loudly instead.
The Footgun
Say a receiver keeps a search index in sync whenever a book is saved:
class Book(models.Model):
title = models.CharField(max_length=255)
publisher = models.ForeignKey(Publisher, null=True, on_delete=models.SET_NULL)
@receiver(post_save, sender=Book)
def index_book(sender, instance, **kwargs):
search_index.upsert([instance.pk])
Saving a single book works:
book.title = "Dune Messiah"
book.save() # ✅ Reindexed
The bulk equivalents don’t:
Book.objects.bulk_create(books) # ❌ New books not searchable
Book.objects.bulk_update(books, ["title"]) # ❌ Search shows old titles
Book.objects.filter(pk=book.pk).update(title="Dune Messiah") # ❌ Search shows old title
There’s no error and no warning. Search results drift from the database until something resyncs them. It’s also easy to introduce: in code review, swapping a loop of save() calls for one update() looks like a performance win, not a bug.
Make It Loud
Add a guardrail: override the three methods on a custom queryset and raise unless the caller explicitly opts out:
from contextlib import contextmanager
from contextvars import ContextVar
from django.db import models
_skip_signals = ContextVar("skip_signals", default=False)
@contextmanager
def skip_signals():
token = _skip_signals.set(True)
try:
yield
finally:
_skip_signals.reset(token)
class SignalSafeQuerySet(models.QuerySet):
def bulk_create(self, *args, **kwargs):
self._check("bulk_create")
return super().bulk_create(*args, **kwargs)
def bulk_update(self, *args, **kwargs):
self._check("bulk_update")
return super().bulk_update(*args, **kwargs)
def update(self, **kwargs):
self._check("update")
return super().update(**kwargs)
def _check(self, method):
if not _skip_signals.get():
raise RuntimeError(
f"{self.model.__name__}.{method}() skips signals. "
"Use save() or wrap it in skip_signals()."
)
Attach it to every model whose signals do real work, as both the default manager and the base manager:
class Book(models.Model):
...
objects = SignalSafeQuerySet.as_manager()
class Meta:
base_manager_name = "objects"
The base manager matters because Django runs a few internal writes through it instead of objects (the same kind of blind spot as reads that skip custom managers), and they skip post_save too:
publisher.book_set.add(book) # ❌ Reverse foreign key add()
publisher.delete() # ❌ on_delete=SET_NULL
- A reverse foreign key
add()moves the book with oneBook._base_manager.update()instead ofsave(). - Deleting a publisher sets
publishertoNULLon its books the same way.
The base manager is a plain Manager by default, so neither write is caught. Pointing it at objects makes both raise. Just make sure objects doesn’t filter rows.
Now the bulk call fails loudly:
>>> Book.objects.filter(pk=book.pk).update(title="Dune Messiah")
RuntimeError: Book.update() skips signals. Use save() or wrap it in skip_signals().
Everything that fires signals keeps working:
save(),create(),get_or_create(), andupdate_or_create()all go throughsave().delete(), on an instance or a queryset, already sendspre_deleteandpost_deletefor each row.
The async variants (aupdate(), abulk_create(), and abulk_update()) call their sync counterparts, so they’re blocked for free.
The same goes for a custom many-to-many through model with its own receivers: book.tags.add(tag) inserts its rows with bulk_create(), so giving the through model a SignalSafeQuerySet makes add() raise too.
Opting Out
Sometimes you need the bulk speed, like a backfill that trims whitespace from a million titles. Wrap the call:
with skip_signals():
Book.objects.update(title=Trim("title"))
The two base manager writes need different handling. A reverse foreign key add() has a signal-safe mode, so it needs no bypass:
publisher.book_set.add(book, bulk=False) # ✅ Calls save() per book
Deletes do, every time: Django runs the SET_NULL update even when the publisher has no books. Run the receiver’s logic for the affected books yourself:
book_ids = list(publisher.book_set.values_list("pk", flat=True))
with skip_signals():
publisher.delete()
search_index.upsert(book_ids)
Bypass sparingly. Every skip_signals() block is a place where search indexes, caches, or anything else driven by signals won’t update. The upside is that each one is now an explicit, searchable decision that shows up in code review.
What It Doesn’t Cover
This guards the ORM, not the database. Raw SQL and data migrations (which use historical models) still skip signals.
Covering every write path means moving the side effect into the database itself, such as a trigger that queues changed rows in an outbox table for a worker to process. Most databases support triggers, and on Postgres, django-pgtrigger lets you define them alongside your models. However, that’s more complex and lives far from your app code. If your bulk writes go through the ORM, the queryset guard is the better trade-off.
