Signals
Signals are hooks that fire at defined moments in a document's lifecycle - before and after save, before and after delete - letting cross-cutting behavior (auditing, cache invalidation, cascades, notifications) attach to models without scattering if checks through the code that calls save(). They are the standard mechanism for the two problems MongoEngine leaves to you: referential cleanup on delete, and keeping a document's updated_at honest.
The Four Signals
| Signal | Fires | Typical use |
|---|---|---|
pre_save | before the document is written | normalize fields, stamp updated_at, compute derived values |
post_save | after the write succeeds | invalidations, fan-out writes, notifications |
pre_delete | before the document is removed | cascade: delete or re-point children |
post_delete | after removal | cleanup that should not block the delete |
All four receive sender (the document class), document (the instance), and, for the save pair, created (True on insert, False on update). A handler is just a function - connect it with the decorator form at module import time:
Connecting Handlers
from mongoengine import Document, DateTimeField, signals, pre_save, post_delete
import datetime
class Post(Document):
title = StringField()
updated_at = DateTimeField()
@pre_save
def stamp_update(sender, document, created, **kwargs):
document.updated_at = datetime.datetime.now(datetime.timezone.utc)
if created:
document.created_at = document.updated_at
@post_delete
def log_removed(sender, document, **kwargs):
AuditLog(action="deleted", doc_id=str(document.id),
collection=sender._meta["collection"]).save()
Handlers registered with @pre_save (no arguments) connect to all classes; scope one with @pre_save.connect plus a sender= check, or register a function that filters on sender is not Post and returns. The discipline: signal handlers run inside the caller's request, so keep them fast - a slow post_save handler slows every save of the class.
The Classic Use: Delete Cascades
from mongoengine import pre_delete
@pre_delete
def remove_author_posts(sender, document, **kwargs):
if sender is not User:
return
Post.objects(author=document).delete() # children first, then the user goes
Comment.objects(author=document).delete()
Because MongoDB references do not cascade, pre_delete on the parent is where the cleanup lives: remove (or re-point) the children before the parent disappears. The handler filters on sender because the global pre_delete fires for every document class in the process. This is the standard pattern that makes a reference relationship behave the way a foreign key with ON DELETE CASCADE would.
Gotchas
- Handlers run in-process. They see the in-memory document, not a database trigger - they run on the Python side, in the same call stack, and their exceptions propagate to the caller (a failing
pre_saveaborts the save). - Order is registration order. Multiple handlers for one signal run in the order they were connected; there is no priority system, so keep each handler single-purpose.
- Tests must import the modules that register handlers. A signal connected at import time in
models.pyexists only if that module was imported - a common "why did my cascade not run?" in test setups. - Save loops. A
post_savehandler that saves another document which saves back is a recursion hazard; guard with an explicit re-entrancy flag if the graph of handlers can cycle.
Summary
Attach lifecycle behavior with the four signals: pre_save to normalize and stamp (the updated_at idiom), post_save for side effects after the write, and pre_delete for the cascade cleanup that MongoDB references do not provide. Connect at module import with the decorator form, filter handlers on sender, keep them fast because they run in the request path, and remember they are in-process hooks, not database triggers - their exceptions are your exceptions.