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:
validate_emailruns first and raises an error if the email is invalidnormalizeruns next and cleans up the data- The item is saved to DynamoDB
log_saveruns 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:
Or disable hooks for all operations on a model:
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
- Auto-generate - Generate IDs and timestamps
- Models - Model CRUD operations
- Conditions - Conditional writes