Persistence Hooks for Real Write Logic
This guide is for the moment when normal CRUD behavior stops being enough.
Maybe saving a row should also:
- write an audit entry
- recalculate some summary data
- trigger a workflow check
- go through an app service that already owns the business rules
PowerCRUD can still handle validation, forms, modal responses, and HTMX flow. Your app can take over the actual write.
For the exact hook signatures, see the Hooks reference. This guide focuses on when these hooks are useful and how to structure them in a simple way.
1. The problem in everyday terms
Without persistence hooks, it is easy for write logic to end up scattered:
- one override for the normal form save
- another for inline save
- another for bulk update
That works for a while, but it is harder to maintain and easier to miss a save path later.
The sync persistence hooks solve that problem by giving you two clear places to take over writes:
persist_single_object()for one validated object at a timepersist_bulk_update()for sync bulk updates
2. What PowerCRUD still does for you
These hooks do not mean you are opting out of PowerCRUD.
PowerCRUD still handles:
- building and validating the form
- normalizing bulk-edit payloads
- modal and HTMX response handling
- list refresh behavior and success messages
Your app code only needs to answer one question:
Now that the input is valid, how should this write actually happen?
3. When to use persist_single_object()
Use persist_single_object() when you want one service to own the actual save for a single row.
That hook covers:
- normal create forms
- normal update forms
- inline row updates
So if you want one place for single-row write orchestration, this is the hook.
4. A small write-service example
Here is a deliberately small service from the sample app:
from sample.services import BookWriteService
class BookWriteService:
"""Small write service used by the persistence hook tutorials."""
def save_book(self, *, form, mode):
book = form.save(commit=False)
book.save()
form.save_m2m()
return book
This is intentionally simple. The point is not the business logic itself. The point is that there is now one obvious place to put it.
In a real project, this service might also:
- write audit records
- call a domain service
- trigger downstream recalculation
- decide whether some extra side effect should happen for this model
5. Wiring the service into BookCRUDView
Then the CRUD view becomes very thin:
from sample.services import BookWriteService
class BookCRUDView(SampleCRUDMixin):
def persist_single_object(self, *, form, mode, instance=None):
return BookWriteService().save_book(form=form, mode=mode)
That is the core idea:
- PowerCRUD validates the form
- your service owns the write
- the view just joins those two pieces together
6. save_m2m() in plain terms
This is the part that often confuses people at first.
If you call form.save() directly, Django handles the normal ModelForm save flow for you.
If you do not call form.save() directly, and instead do form.save(commit=False) so your service can control the write, then any many-to-many form values still need to be saved afterwards. That is what form.save_m2m() is for.
So the simple rule is:
- if your override just returns
form.save(), you do not need to think about this - if your override bypasses
form.save(), you own any neededform.save_m2m()
7. When to use persist_bulk_update()
Use persist_bulk_update() when sync bulk edit should go through your own app service instead of going straight through the built-in row update path.
This is useful when a bulk update should still trigger app-level rules, such as:
- audit logging
- selective recalculation
- workflow checks
- one project-specific orchestration layer for multi-row changes
This hook is for sync bulk update. Bulk delete is separate, and async bulk update is covered in the next guide.
8. A small sync bulk-service example
The sample app includes a tutorial-oriented bulk service:
from powercrud.bulk_persistence import (
BulkUpdateExecutionContext,
DefaultBulkUpdatePersistenceBackend,
)
class BookBulkUpdateService:
"""Small example service used by the sync bulk tutorial."""
def apply(
self,
*,
queryset,
bulk_fields,
fields_to_update,
field_data,
context,
progress_callback=None,
):
return DefaultBulkUpdatePersistenceBackend().persist_bulk_update(
queryset=queryset,
bulk_fields=bulk_fields,
fields_to_update=fields_to_update,
field_data=field_data,
context=context,
progress_callback=progress_callback,
)
This example still uses PowerCRUD's built-in bulk update behavior. The point is that your app now has one clear bulk-write hook where extra orchestration can live.
9. Wiring sync bulk into BookCRUDView
from sample.services import BookBulkUpdateService
class BookCRUDView(SampleCRUDMixin):
def persist_bulk_update(
self,
*,
queryset,
fields_to_update,
field_data,
progress_callback=None,
):
return BookBulkUpdateService().apply(
queryset=queryset,
bulk_fields=list(self.bulk_fields),
fields_to_update=fields_to_update,
field_data=field_data,
context=self._build_bulk_update_execution_context(
queryset=queryset,
mode="sync",
),
progress_callback=progress_callback,
)
This keeps the view logic simple while giving your app one bulk-update service to grow later.
Validation errors in service-owned bulk writes
If your bulk service validates rows before applying the batch, keep the PowerCRUD UI flow by returning the normal bulk result payload with success=False and an errors list instead of letting the exception escape.
For the canonical contract and a concrete validation-error example, see Bulk editing (synchronous).
10. Which hook should I use?
- Use
persist_single_object()when one validated object is being created or updated. - Use
persist_bulk_update()when a sync bulk edit should go through your own write orchestration. - Use both when your project wants the same app-level service pattern for single-row and multi-row writes.
11. Next step
If sync bulk update might also run in the background in your project, continue with Async Bulk Persistence Without Surprises.