Skillversitas

Signals and Hooks

Signals are Peewee's hook system: the model fires named events at the moments a row is saved, inserted, updated, or deleted, and listeners attached to those events run code in response - validation before the write, a cache invalidation after it, an audit log of the change. The mechanism is the Model.save() lifecycle (the pre_save/post_save methods on the model) and the signals module's event registry, and the value is that the logic that must run on every write of a model lives in one place, attached to the model, instead of repeated in every call site. This chapter covers the model lifecycle methods, the signal events and their payloads, the listener registration, and the rules that keep the hooks from surprising the code that triggers them.

The Model Lifecycle: pre_save and post_save

from peewee import *

class Article(Model):
    title = CharField()
    body = TextField()
    author = ForeignKeyField(Author, backref="articles")
    class Meta:
        database = db

    def pre_save(self, insert: bool):
        # runs BEFORE the SQL, on both insert and update
        # insert is True for a new row, False for an update
        # - normalize the data, enforce invariants, raise to cancel
        self.title = self.title.strip()
        if not self.title:
            raise ValueError("title is required")
        # setting fields here changes what gets written
        return super().pre_save(insert)

    def post_save(self, insert: bool, *args, **kwargs):
        # runs AFTER the SQL succeeds - the row is durable
        # - the side effects: cache, notify, audit
        if insert:
            invalidate_cache("articles:latest")
        super().post_save(insert, *args, **kwargs)

Every model has two lifecycle hooks. pre_save(insert) runs before the INSERT or UPDATE is issued - insert says which - and it is the place for the write-time invariant: normalize the fields, validate them, and raise to cancel the save (the exception rolls back, the row is not written). post_save(insert, ...) runs after the statement succeeds, with the row durable - the place for the side effect that should only happen when the write actually happened (a cache invalidation, a notification, an audit entry). Both must call the super() form, because the base implementation is what fires the class-level signals the next section uses. The split is the design rule: mutate and validate before, react after - a side effect in pre_save runs even if the save then fails, and a validation in post_save cannot cancel a write that already happened.

The Signal Events and Listeners

from playhouse.signals import Model, pre_save, post_save

# a signals.Model is a Model with the event registry wired in:
class Article(Model):
    title = CharField()
    author = ForeignKeyField(Author, backref="articles")
    class Meta:
        database = db

# attach a listener - it fires for EVERY save of the model:
def log_article_save(sender, instance, created, **kwargs):
    # sender is the model class, instance the row,
    # created is the insert flag
    print(f"{'INSERT' if created else 'UPDATE'} {instance.id} {instance.title}")

pre_save.connect(log_article_save, sender=Article)
post_save.connect(log_article_save, sender=Article)

# the event set: pre_save, post_save (and the insert/update/delete
# variants where the backend supports firing them distinctly)
# a listener can DISCONNECT later:
# pre_save.disconnect(log_article_save, sender=Article)

The playhouse.signals module provides a Model base that fires the event registry, and the listeners attach with pre_save.connect(func, sender=Model) (and post_save.connect), scoped to a model via sender. The listener's signature receives the sender (the model class), the instance (the row), and the created flag, which is the payload the hook logic needs to tell an insert from an update. The class-level pre_save/post_save methods from the previous section are the per-model form; the signals are the per-listener form - several independent pieces of logic (an audit logger, a cache layer, a notification service) each attach their own listener to the same event, without editing the model. The disconnect is the teardown, and the sender scoping is what keeps a listener from firing on every model in the app.

The Validation Hook: Raising to Cancel

from playhouse.signals import Model, pre_save

class Article(Model):
    title = CharField()
    slug = CharField()
    class Meta:
        database = db

def enforce_unique_slug_per_author(sender, instance, created, **kwargs):
    # a business rule that spans rows: the slug must be unique
    # PER AUTHOR, which a plain unique=True field cannot express
    if instance.author_id:
        clash = (sender.select()
                   .where((sender.slug == instance.slug) &
                          (sender.author_id == instance.author_id) &
                          (sender.id != instance.id))
                   .first())
        if clash:
            raise ValueError(
                f"slug {instance.slug!r} already used by this author")

pre_save.connect(enforce_unique_slug_per_author, sender=Article)

# the raise inside the hook cancels the save at the call site:
try:
    art.save()
except ValueError as e:
    print("rejected:", e)   # the row was not written

The most useful hook is the cross-row validation: a rule that needs other rows to evaluate, which a field constraint cannot express - the slug unique per author, the balance that cannot go negative, the order that needs an available stock. The listener queries for the conflict and raises, and the raise propagates out of save() to the caller, canceling the write, which is the same cancellation as a pre_save method raise. The pattern is the exception-as-rejection the transactions chapter used, moved into the hook so the rule is enforced on every save path (a direct save(), a create(), a bulk that triggers the signal) instead of only the one that remembered to check. The rule's scope is the model it is attached to via sender, and the query inside the hook is on the same model - the hook validates the row against its own table, which is the shape the database can check efficiently.

The Working Rules

  • Mutate and validate in pre_save, react in post_save. A side effect before the write runs even on a failed save; a validation after cannot cancel a done write. The split is the design, not a convention.
  • Call super() in the model methods - the base is what fires the class-level signals the listeners depend on.
  • Scope listeners with sender so a hook fires on its model, not the whole app, and disconnect it on teardown.
  • A hook raise cancels the save - that is the rejection mechanism, and the caller sees the exception at the save() call site, as if the model raised it.
  • Keep hooks cheap and side-effect-honest. A pre_save that does a slow query runs on every save of the model; a post_save that "just logs" is still a side effect the caller did not ask for explicitly. The hook is a contract the model makes, and it should be small and its effect named.

Summary

The model lifecycle is pre_save (before the SQL, on insert and update, where the fields are normalized and the invariants raised) and post_save (after the row is durable, where the side effects run), and both call super() to fire the class-level events. The playhouse.signals Model adds the listener registry: pre_save.connect(func, sender=Model) attaches an independent piece of logic - an audit log, a cache invalidation, a cross-row validation that raises to cancel the save - to the model's write events, scoped by sender and removable with disconnect. The design rules keep the hooks predictable: mutate and validate before, react after, keep them cheap, and treat a hook raise as the rejection the caller handles at the save() site. Signals are how the logic that must run on every write of a model lives attached to the model, in one place, instead of repeated - and silently skipped - at every call site.