Override a Django model’s save() method when you need to enforce or derive behavior whenever an individual model instance is persisted.
Typical examples:
- Populate a derived field, such as a normalized version of a name.
- Generate a slug.
- Set a value on first creation.
- Enforce a small model-level invariant that depends on persistence state.
- Detect a transition, such as
status changing from PENDING to COMPLETE.
class Asset(models.Model):
title = models.CharField(max_length=255)
normalized_title = models.CharField(max_length=255, blank=True)
def save(self, *args, **kwargs):
self.normalized_title = self.title.strip().casefold()
super().save(*args, **kwargs)
Why and when to be cautious
save() is often overused. It can make persistence have surprising side effects and is a poor place for complex workflows.
For example, avoid putting these directly in save() unless the behavior is very carefully designed:
- Sending emails
- Calling third-party APIs
- Creating S3 objects
- Starting asynchronous jobs
- Large database updates
- Complex cross-model business workflows
Those are usually better in a service layer, explicit domain operation, background job, or—in some cases—a post_save signal.
For a migration-heavy system, implicit side effects are especially risky. A migration may use bulk_create(), bulk_update(), raw SQL, or PostgreSQL upserts, all of which can bypass normal per-instance save() behavior. bulk_create() also does not call model save() or send pre_save / post_save signals.
That means this can be dangerous:
class Asset(models.Model):
def save(self, *args, **kwargs):
self.metadata["normalized"] = True
super().save(*args, **kwargs)
A bulk migration could create thousands of Asset rows without the normalization occurring. For migration transformations, explicit transformation code is usually more reliable:
def normalize_asset_metadata(metadata: dict) -> dict:
return {
**metadata,
"normalized": True,
}
Important save() details
Preserve Django’s method signature
Use:
def save(self, *args, **kwargs):
...
super().save(*args, **kwargs)
Do not forget super().save(), or the row will not be persisted.
Respect update_fields
If your override changes a field during a partial update, ensure it is included in update_fields.
def save(self, *args, **kwargs):
self.normalized_title = self.title.strip().casefold()
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = set(update_fields) | {"normalized_title"}
super().save(*args, **kwargs)
Without this, obj.save(update_fields={"title"}) may calculate normalized_title in Python but not save it to the database.
Identify creation safely
def save(self, *args, **kwargs):
is_new = self._state.adding
if is_new:
self.imported_at = timezone.now()
super().save(*args, **kwargs)
Be careful with logic based only on self.pk is None, especially where primary keys might be assigned before saving.
Other Django model methods commonly overridden
clean()
Use clean() for model-level validation, especially validation involving multiple fields.
from django.core.exceptions import ValidationError
class MigrationJob(models.Model):
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
def clean(self):
if self.completed_at and not self.started_at:
raise ValidationError(
{"completed_at": "A completed job must have a start time."}
)
if self.started_at and self.completed_at:
if self.completed_at < self.started_at:
raise ValidationError(
{"completed_at": "Completion cannot precede start time."}
)
Important interview detail: Django does not automatically call full_clean() when save() is called. ModelForms generally validate models, but code that directly creates a model instance and calls .save() will not automatically run clean().
For database-critical rules, use database constraints too:
class MigrationJob(models.Model):
class Meta:
constraints = [
models.CheckConstraint(
condition=(
models.Q(completed_at__isnull=True)
| models.Q(started_at__isnull=False)
),
name="completed_job_requires_start_time",
)
]
Rule of thumb:
clean() provides helpful application-level validation messages.
- Database constraints protect integrity under all writers, including bulk jobs and raw SQL.
validate_unique()
Less commonly overridden directly. It is part of model validation and can be extended for custom uniqueness validation, but database-level UniqueConstraints are still needed to safely handle concurrent writes.
For tenant-scoped records:
class Asset(models.Model):
tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
external_id = models.CharField(max_length=255)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tenant", "external_id"],
name="unique_asset_external_id_per_tenant",
)
]
This is much safer than relying on Python-only validation during a concurrent migration.
delete()
Override delete() when deleting one model instance requires tightly coupled cleanup.
class ImportFile(models.Model):
s3_key = models.CharField(max_length=1024)
def delete(self, *args, **kwargs):
# Potentially queue cleanup rather than doing remote I/O inline.
super().delete(*args, **kwargs)
Caution: QuerySet.delete() performs bulk deletion and does not invoke each object’s overridden delete() method. Signals may still be involved, but do not rely on per-instance delete() overrides for cleanup that must happen on every deletion path.
For external cleanup such as deleting S3 objects, an explicit service or asynchronous cleanup job is often safer than doing remote calls inside a database deletion operation.
__str__()
Very common and low risk. It improves Django admin, shell output, and logs.
class MigrationJob(models.Model):
tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
source_filename = models.CharField(max_length=255)
def __str__(self):
return f"{self.tenant}: {self.source_filename} ({self.pk})"
get_absolute_url()
Useful when a model has a canonical detail URL.
from django.urls import reverse
def get_absolute_url(self):
return reverse("assets:detail", kwargs={"pk": self.pk})
Less central in API-first applications, but still common in traditional Django applications.
from_db()
An advanced option for customizing how Django constructs a model when loading it from the database. One use is recording original field values to detect changes later.
class Asset(models.Model):
@classmethod
def from_db(cls, db, field_names, values):
instance = super().from_db(db, field_names, values)
instance._loaded_values = dict(zip(field_names, values))
return instance
This can support change detection, but it adds complexity and needs careful handling of deferred fields. For many cases, a service-layer update flow is simpler and clearer.
Common non-model extension points
Interviewers may ask broadly about Django customization, not just model overrides.
Custom QuerySet and Manager
Often preferable to putting query behavior on the model.
class AssetQuerySet(models.QuerySet):
def for_tenant(self, tenant_id):
return self.filter(tenant_id=tenant_id)
def active(self):
return self.filter(is_deleted=False)
class Asset(models.Model):
objects = AssetQuerySet.as_manager()
This is especially useful in multi-tenant applications, though tenant isolation should not rely solely on developer remembering to call .for_tenant().
Forms: clean_<field>() and clean()
For UI-specific validation:
class AssetForm(forms.ModelForm):
def clean_external_id(self):
value = self.cleaned_data["external_id"].strip()
if not value:
raise forms.ValidationError("External ID is required.")
return value
Django REST Framework serializers: validate_<field>(), validate(), create(), update()
For API input and write behavior:
class AssetSerializer(serializers.ModelSerializer):
def validate_external_id(self, value):
return value.strip()
def validate(self, attrs):
if attrs.get("is_public") and not attrs.get("rights_statement"):
raise serializers.ValidationError(
"Public assets require a rights statement."
)
return attrs
Class-based views: get_queryset(), get_context_data(), form_valid()
These are common for tenant-scoped views and small workflow changes.
class AssetListView(ListView):
model = Asset
def get_queryset(self):
return Asset.objects.filter(tenant=self.request.tenant)
Concise interview answer
I override save() only for small, deterministic persistence concerns, such as generating a normalized field or slug. I preserve the method signature, call super(), and account for update_fields. I avoid complex business workflows and external side effects in save() because they are implicit, harder to test and retry, and can be bypassed by bulk_create, bulk updates, or raw SQL—an important concern in data migrations. For validation, I use clean() for friendly model-level validation and database constraints for integrity guarantees. Other common overrides include __str__(), occasionally delete(), get_absolute_url(), and more advanced hooks such as from_db().