Lifecycle Hooks
ModelAdmin provides hooks to customize data access and persistence without
overriding the router.
Query hooks
get_queryset
Add a base filter applied to all changelist queries:
def get_queryset(self, request, base_query: dict) -> dict:
# Only show non-archived products by default
return {**base_query, "status": {"$ne": "archived"}}
Called before search, filters, and date hierarchy are applied.
Persistence hooks
save_model
Mutate validated form data before insert or update:
from datetime import datetime, timezone
async def save_model(
self,
request,
obj: dict,
form_data: dict,
is_new: bool,
) -> dict:
now = datetime.now(timezone.utc).isoformat()
form_data["updated_at"] = now
if is_new:
form_data.setdefault("created_at", now)
return form_data
Parameters:
request— FastAPI request (orNone)obj— existing document (empty dict on create)form_data— Pydantic-validated datais_new—Trueon add,Falseon change
Return the (possibly modified) dict to persist.
delete_model
Called before each document is deleted (single or bulk):
async def delete_model(self, request, obj: dict) -> None:
# Cascade delete related records, audit log, etc.
await cleanup_related(obj["id"])
Not called if permission check fails.
Display hooks
display_value
Override how changelist cell values are resolved:
def display_value(self, request, obj: dict, field_name: str):
value = super().display_value(request, obj, field_name)
if field_name == "status":
return value.upper()
return value
By default, checks for @display methods, then raw document fields.
Form hooks
formfield_for_field
Customize a single form field after defaults and overrides:
def formfield_for_field(self, field, request=None, obj=None):
if field.name == "slug" and obj is None:
field.attrs["readonly"] = True
return field
get_readonly_fields
Dynamic readonly fields based on context:
def get_readonly_fields(self, request, obj=None):
readonly = list(self.readonly_fields or [])
if obj and obj.get("status") == "published":
readonly.append("price")
return readonly
get_fieldsets
Dynamic form layout:
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
if obj and obj.get("role") != "admin":
# Hide advanced section for non-admins
return [fs for fs in fieldsets if fs[0] != "Advanced"]
return fieldsets
Permission hooks
See Permissions for has_view_permission, has_add_permission,
has_change_permission, and has_delete_permission.
Hook execution order
Create flow:
POST form → parse_form_to_model() → save_model() → translate_to_db()
→ prepare_for_mongodb() → backend.insert_one()
Update flow:
POST form → parse_form_to_model() → save_model() → translate_to_db()
→ prepare_for_mongodb() → backend.update_one()
Delete flow:
delete_model() → backend.delete_one() (or delete_many for bulk)
Post-save redirect:
POST add/change → validate → save → redirect to changelist
→ flash cookie with object_repr label
→ success banner on next changelist GET
See Date, Time, and Save Messages for save message and label customization.
Changelist flow:
get_queryset() → build_changelist_query() → backend.find()
→ serialize → display_value() per cell