Skip to content

Lifecycle hooks

Run code before or after model operations. Hooks let you add validation, logging, or any custom logic without cluttering your main code.

Key features

  • Validation before save
  • Logging after operations
  • Data transformation
  • Side effects like sending emails

Getting started

Hooks are methods decorated with special decorators. When you call save(), delete(), or update(), pydynox automatically runs the matching hooks.

Available hooks

Hook When it runs
@before_save Before save()
@after_save After save()
@before_delete Before delete()
@after_delete After delete()
@before_update Before update()
@after_update After update()
@after_load After get() or query

Basic usage

Here's a common pattern: validate and normalize data before saving, then log after:

import asyncio

from pydynox import Model, ModelConfig
from pydynox.attributes import StringAttribute
from pydynox.hooks import after_save, before_save


class User(Model):
    model_config = ModelConfig(table="users")

    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    email = StringAttribute()
    name = StringAttribute()

    @before_save
    def validate_email(self):
        if not self.email or "@" not in self.email:
            raise ValueError("Invalid email")

    @before_save
    def normalize(self):
        self.email = self.email.lower().strip()
        self.name = self.name.strip()

    @after_save
    def log_save(self):
        print(f"Saved user: {self.pk}")


async def main():
    # Hooks run automatically
    user = User(pk="USER#HOOK", sk="PROFILE", email="JOHN@TEST.COM", name="john doe")
    await user.save()  # Validates, normalizes, then logs

    # Skip hooks if needed
    await user.save(skip_hooks=True)


asyncio.run(main())

In this example:

  1. validate_email runs first and raises an error if the email is invalid
  2. normalize runs next and cleans up the data
  3. The item is saved to DynamoDB
  4. log_save runs last and prints a message

If any before_* hook raises an exception, the operation stops and the item is not saved.

Advanced

Multiple hooks of the same type

You can have multiple hooks of the same type. They run in the order they're defined in the class:

class User(Model):
    @before_save
    def first_hook(self):
        print("This runs first")

    @before_save
    def second_hook(self):
        print("This runs second")

All hooks example

Here's a model with all available hooks:

from pydynox import Model, ModelConfig
from pydynox.attributes import StringAttribute
from pydynox.hooks import (
    after_delete,
    after_load,
    after_save,
    after_update,
    before_delete,
    before_save,
    before_update,
)


class User(Model):
    model_config = ModelConfig(table="users")

    pk = StringAttribute(partition_key=True)
    name = StringAttribute()

    @before_save
    def on_before_save(self):
        print("Before save")

    @after_save
    def on_after_save(self):
        print("After save")

    @before_delete
    def on_before_delete(self):
        print("Before delete")

    @after_delete
    def on_after_delete(self):
        print("After delete")

    @before_update
    def on_before_update(self):
        print("Before update")

    @after_update
    def on_after_update(self):
        print("After update")

    @after_load
    def on_after_load(self):
        print("After load (get or query)")

Skipping hooks

Sometimes you need to bypass hooks. For example, during data migration or when fixing bad data.

Skip hooks for a single operation:

user.save(skip_hooks=True)
user.delete(skip_hooks=True)
user.update(skip_hooks=True, name="Jane")

Or disable hooks for all operations on a model:

class User(Model):
    class Meta:
        table = "users"
        skip_hooks = True  # All hooks disabled by default

Warning

Be careful when skipping hooks. If you have validation in before_save, skipping it means invalid data can be saved.

Common patterns

Pattern Hook Example
Validation @before_save Check email format, required fields
Normalization @before_save Lowercase email, trim whitespace
Timestamps @before_save Set updated_at field
Logging @after_save Log saved item ID
Audit @after_save Write to audit table
Cleanup @after_delete Delete related data, files
Transformation @after_load Format dates, compute fields

Hooks and transactions

Hooks run for each item in a transaction. If you're saving 10 items in a transaction, before_save runs 10 times.

If a hook raises an exception, the entire transaction fails and nothing is saved.

Testing your code

Test hooks without DynamoDB using the built-in memory backend:

"""Testing lifecycle hooks with pydynox_memory_backend."""

import pytest
from pydynox import Model, ModelConfig
from pydynox.attributes import StringAttribute
from pydynox.hooks import after_save, before_save


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    email = StringAttribute()
    name = StringAttribute()

    @before_save
    def validate_email(self):
        if "@" not in self.email:
            raise ValueError("Invalid email")

    @before_save
    def normalize_email(self):
        self.email = self.email.lower().strip()

    @after_save
    def log_save(self):
        print(f"Saved user: {self.pk}")


def test_before_save_validation(pydynox_memory_backend):
    """Test that before_save hook validates data."""
    user = User(pk="USER#1", email="invalid-email", name="John")

    with pytest.raises(ValueError, match="Invalid email"):
        user.save()

    # User was not saved
    assert User.get(pk="USER#1") is None


def test_before_save_normalization(pydynox_memory_backend):
    """Test that before_save hook normalizes data."""
    user = User(pk="USER#1", email="  JOHN@EXAMPLE.COM  ", name="John")
    user.save()

    found = User.get(pk="USER#1")
    assert found.email == "john@example.com"


def test_skip_hooks(pydynox_memory_backend):
    """Test skipping hooks for special cases."""
    # This would normally fail validation
    user = User(pk="USER#1", email="invalid", name="John")
    user.save(skip_hooks=True)

    # But with skip_hooks=True, it saves anyway
    found = User.get(pk="USER#1")
    assert found.email == "invalid"

No setup needed. Just add pydynox_memory_backend to your test function. See Testing for more details.

Next steps