Events
A method hook like before_create only runs for the view that defines it. The event system lets code outside that view react to what happens inside it, meaning an audit log, a webhook, or a cache invalidation can live in one place instead of being copy-pasted into every ModelView you write.
from starlette_admin.events import AdminEvent, AfterCreateContext
async def notify_slack(ctx: AfterCreateContext) -> None:
print(f"New {ctx.view_key} created: pk={ctx.pk}")
admin.events.on(AdminEvent.AFTER_CREATE, notify_slack)
Register this once next to your admin instance, and every view's create endpoint will call it, including any views added later.
View vs. Admin Level
Every view has an events attribute you can subscribe to directly, scoped to that view alone. The Admin instance has an events attribute too, which reaches every view registered on it, or a subset of them if you pass the keys= parameter.
view.events.on(...): Fires only for that specific view.admin.events.on(...): Fires for every current and future view, unless restricted withkeys=.
You can register on admin.events before or after calling admin.add_view(...). Order does not matter; a handler registered before a view is added will still attach to it once added.
Method Hooks vs. Event Subscriptions
Both fire at the same point in the request lifecycle. They differ in where the code lives and how many views it reaches.
| Feature | Method hook (before_create, ...) |
Event subscription (view.events / admin.events) |
|---|---|---|
| Where the code lives | Inside the view class | Anywhere (e.g., a module-level function or subscriber class) |
| Scope | That specific view | One view (view.events) or every view (admin.events) |
| Good for | Logic specific to that resource (slugify a title, stamp a timestamp) | Cross-cutting concerns (audit logs, notifications, plugins) |
| Multiples allowed? | No, one method per view | Yes, any number of handlers per event, ordered by priority |
Use a method hook when the logic is intrinsic to the model. Use an event subscription when the logic does not belong to any single view, or when you are shipping it as a reusable piece across several admins.
from typing import Any
from starlette.requests import Request
from starlette_admin.contrib.sqla import ModelView
class PostView(ModelView):
# Belongs to this view only, stays here
async def before_create(
self, request: Request, data: dict[str, Any], obj: Any
) -> None:
obj.slug = data["title"].lower().replace(" ", "-")
AdminEvent Values
AdminEvent is a string enum. These are the members actively emitted by the view lifecycle:
| Event | Fired when | Context class |
|---|---|---|
BEFORE_CREATE / AFTER_CREATE |
Record created | BeforeCreateContext / AfterCreateContext |
AFTER_CREATE_COMMITTED |
Create transaction committed | AfterCreateContext |
BEFORE_EDIT / AFTER_EDIT |
Record updated | BeforeEditContext / AfterEditContext |
AFTER_EDIT_COMMITTED |
Edit transaction committed | AfterEditContext |
BEFORE_DELETE / AFTER_DELETE |
Record deleted | BeforeDeleteContext / AfterDeleteContext |
AFTER_DELETE_COMMITTED |
Delete transaction committed | AfterDeleteContext |
BEFORE_ACTION / AFTER_ACTION |
Batch or row action run | BeforeActionContext / AfterActionContext |
BEFORE_EXPORT / AFTER_EXPORT |
Export triggered | BeforeExportContext / AfterExportContext |
BEFORE_IMPORT / AFTER_IMPORT |
Import triggered | BeforeImportContext / AfterImportContext |
AFTER_LOGIN |
Login succeeds | AfterLoginContext |
AFTER_CREATE_COMMITTED, AFTER_EDIT_COMMITTED, and AFTER_DELETE_COMMITTED only fire for backends that defer the commit to the end of the request. Currently, this is limited to the SQLAlchemy backend. See Views for the after_create_committed, after_edit_committed, and after_delete_committed hook methods that emit them.
For AFTER_DELETE_COMMITTED, ctx.obj is a detached instance: its already-loaded attributes remain readable, but accessing an attribute that wasn't loaded before the delete will raise, since the row backing it is gone.
Every context is a dataclass that inherits from EventContext, which carries fields common to all events:
| Attribute | Type | Description |
|---|---|---|
event |
AdminEvent or str |
The event that fired |
request |
Request |
The request in flight |
view_key |
str |
The view's key |
extra |
dict |
Empty by default, free for you to stash data in a custom handler chain |
Each subclass adds the fields relevant to that event.
Edit events fired by an inline edit from the list page carry extra["inline"] = True, and their data / old_data payloads contain only the edited field. Everything else is identical to a regular edit, so existing handlers need no changes.
Subscribing with a Decorator
view.events.on() works as either a decorator or a direct function call:
import logging
from starlette_admin.events import AdminEvent, BeforeDeleteContext
from starlette_admin.contrib.sqla import ModelView
logger = logging.getLogger(__name__)
class OrderView(ModelView):
fields = ["id", "customer_name", "total", "status"]
order_view = OrderView(Order, icon="fa fa-shopping-cart")
@order_view.events.on(AdminEvent.BEFORE_DELETE)
async def log_deletion(ctx: BeforeDeleteContext) -> None:
logger.info("Deleting order pk=%s", ctx.pk)
Registered this way, log_deletion only fires for order_view and not for other views on the admin. The on() method also accepts the handler directly without the decorator form:
AdminEventSubscriber: Grouping Handlers
When a single concern needs to react to several events, AdminEventSubscriber collects them in a single class instead of scattering module-level functions. Decorate methods with @on(AdminEvent.X) (the module-level on from starlette_admin.events, not the bus method) and call subscribe() once:
import logging
from starlette_admin.events import (
AdminEvent,
AdminEventSubscriber,
AfterCreateContext,
AfterDeleteContext,
AfterEditContext,
on,
)
logger = logging.getLogger(__name__)
class AuditSubscriber(AdminEventSubscriber):
"""Logs every create, update, or delete, on any view."""
@on(AdminEvent.AFTER_CREATE)
async def record_create(self, ctx: AfterCreateContext) -> None:
logger.info("created %s pk=%s", ctx.view_key, ctx.pk)
@on(AdminEvent.AFTER_EDIT)
async def record_update(self, ctx: AfterEditContext) -> None:
logger.info("updated %s pk=%s", ctx.view_key, ctx.pk)
@on(AdminEvent.AFTER_DELETE)
async def record_delete(self, ctx: AfterDeleteContext) -> None:
logger.info("deleted %s pk=%s", ctx.view_key, ctx.pk)
admin.events.subscribe(AuditSubscriber())
The subscribe() method is available on both view.events and admin.events. Call it on view.events to scope the subscriber to one specific view instead.
A single method can handle multiple events by stacking or combining decorator arguments. For example, @on(AdminEvent.AFTER_CREATE, AdminEvent.AFTER_EDIT) registers the same method for both events.
admin.events: Delegating to Views
admin.events.on() takes the same arguments as view.events.on(), plus keys=, a list of view keys to restrict the subscription to. Leave it unset (None, the default) and every current and future model view receives the handler:
import httpx
from starlette_admin.events import AdminEvent, AfterCreateContext
@admin.events.on(AdminEvent.AFTER_CREATE, keys=["order"])
async def notify_new_order(ctx: AfterCreateContext) -> None:
async with httpx.AsyncClient() as client:
await client.post(SLACK_WEBHOOK_URL, json={"text": f"New order: {ctx.pk}"})
Only the view registered with key="order" (or whose default key resolves to "order") calls this handler; an AFTER_CREATE on any other view will not trigger it.
admin.events.subscribe() accepts keys= too, meaning an AdminEventSubscriber can be scoped to a subset of views in the exact same way:
The keys= parameter only has an effect for the view-lifecycle events listed in the table above (create, edit, delete, action, export, import). This is how admin.events filters which views a handler applies to. AFTER_LOGIN is admin-level, not tied to any view, so keys= has no effect on it.
Priority
The on() method takes a priority keyword, an integer defaulting to 0. Handlers for the same event run in descending priority order, meaning a higher number fires first:
import logging
from starlette_admin.events import AdminEvent, BeforeDeleteContext
logger = logging.getLogger(__name__)
@order_view.events.on(AdminEvent.BEFORE_DELETE, priority=10)
async def validate_can_delete(ctx: BeforeDeleteContext) -> None:
if ctx.obj.status == "shipped":
raise ValueError("Cannot delete a shipped order") # runs first
@order_view.events.on(AdminEvent.BEFORE_DELETE, priority=0)
async def log_deletion(ctx: BeforeDeleteContext) -> None:
logger.info("deleting order pk=%s", ctx.pk) # runs second
Handlers registered with the same priority run in their order of registration. AdminEventSubscriber methods take a priority through @on(AdminEvent.X, priority=10), which is forwarded the same way.
Warning
A BEFORE_DELETE (or any BEFORE_*) handler that raises an exception stops the operation, and later handlers for that event will not run. An AFTER_* handler that raises an exception turns an already-committed change into a failed request. Wrap risky logic (like network calls or third-party APIs) in its own try/except block inside the handler if a failure there should not surface as an admin error.
Extended Example
examples/05-events runs all of the patterns on this page together: hook overrides on PostView, an AuditSubscriber registered on admin.events for every view, direct handler registration for delete/export/import warnings, a handler scoped to post_view.events, and a CommentModerationSubscriber scoped to comment_view.events. Run it to see priority and scope interact in one app.
What's Next
- Views: The
before_*andafter_*method hooks this page builds on. - Actions: Batch and row actions, which emit
BEFORE_ACTION/AFTER_ACTION. - Inline Forms: Nested records created alongside a parent.