Filter concepts by levelShowing all levels.

Django · Model Methods and Properties

Model Methods and Properties

save()/delete(), the clean()/full_clean() validation pipeline, and where domain logic belongs

  • save() never validatescall full_clean() explicitly on non-ModelForm write paths
  • full_clean()'s 4 stepsclean_fields → clean → validate_unique → validate_constraints
  • @property for computed valuesno parentheses, no arguments, no side effects
  • Persistence vs business vs orchestrationonly the first two belong on the model itself

1.save() and delete()

save() issues an INSERT or UPDATE (Django decides which based on whether the instance's primary key is already set), and delete() issues a DELETE, removing the row but leaving the Python instance alive in memory with its pk reset to None. Neither one runs full_clean() — save() will happily write invalid data to the database unless something else validates first.

Key features

  • save() INSERTs if the instance has no pk set yet (or the pk's field has a default not yet saved), UPDATEs otherwise — Django decides automatically
  • save() does NOT call full_clean() or clean() — invalid data (violating a choices= list, a custom clean() rule, etc.) can be saved without error
  • delete() removes the database row but the Python object still exists afterward, with its primary key set back to None
  • delete() returns a tuple: (total objects deleted, {model_label: count}) — because CASCADE can delete related objects in the same call
  • save(update_fields=[...]) restricts the UPDATE to only the named fields — auto_now fields NOT listed in update_fields are not touched

save() vs delete()

save() vs delete()
MethodSQL issuedRuns validation?
save()INSERT (no pk yet) or UPDATE (pk already set)no — full_clean() is never called automatically
delete()DELETE (plus any CASCADE-related deletes)no — deletion is unconditional at the Django level
save() never validates on its own
Code
Model instance
Database
  1. 1order.status = "NOT_A_REAL_STATUS"
  2. 2order.save()
  3. 3INSERT/UPDATE — no validation ran
  4. 4succeeds — invalid data now stored
  1. Code → Model instance: order.status = "NOT_A_REAL_STATUS"
  2. Code → Model instance: order.save()
  3. Model instance → Database: INSERT/UPDATE — no validation ran
  4. Database → Code: succeeds — invalid data now stored

Command

python
order.save()                          # INSERT or UPDATE, decided automatically
order.save(update_fields=["status"])  # UPDATE only this column
order.delete()                        # (count, {label: count})

save() decides INSERT vs UPDATE automatically and never validates on its own — call full_clean() explicitly first if the write path skips ModelForm. delete() removes the row but the Python object survives, pk reset to None.

2.clean() and full_clean()

clean() is a method you override to add custom, cross-field validation logic (and can raise ValidationError). full_clean() is the orchestrator — it runs clean_fields() (per-field validation), then clean() (your custom logic), then validate_unique(), then validate_constraints(), collecting every error from all four steps into one ValidationError. Neither runs automatically from save() — full_clean() has to be called explicitly.

Key features

  • full_clean() runs, in order: clean_fields() → clean() → validate_unique() → validate_constraints()
  • clean() is where cross-field validation belongs — logic no single field's own validator could express alone
  • clean() can also NORMALIZE data (Django's own example: auto-setting pub_date when status becomes "published") since it runs before the object is saved
  • full_clean() collects errors from ALL FOUR steps into one ValidationError, rather than raising on the first failure
  • ModelForm calls its own field-level validation automatically, but plain Model.save() never calls full_clean() — it must be invoked explicitly whenever a write path bypasses ModelForm

full_clean()'s four steps, in order

full_clean()'s four steps, in order
StepValidates
clean_fields()each field individually — max_length, choices=, a field's own validators
clean()your custom cross-field logic, overridden per model
validate_unique()unique=True, unique_together, unique_for_date/month/year
validate_constraints()Meta.constraints (CheckConstraint, UniqueConstraint)
full_clean()'s four steps, in order

clean_fields()

each field individually — max_length, choices=, validators

clean()

your custom cross-field logic

validate_unique()

unique=True, unique_together

validate_constraints()

Meta.constraints

  1. clean_fields() — each field individually — max_length, choices=, validators
  2. clean() — your custom cross-field logic
  3. validate_unique() — unique=True, unique_together
  4. validate_constraints() — Meta.constraints

Command

python
try:
    instance.full_clean()
except ValidationError as e:
    print(e.message_dict)   # {field_name: [error, ...], ...}
instance.save()

full_clean() runs clean_fields() → clean() → validate_unique() → validate_constraints(), collecting every error at once. clean() alone only runs your custom logic — call full_clean() for the complete pipeline, and call it explicitly on any write path that skips ModelForm.

3.Instance methods, @property, and where domain logic belongs

@property exposes a computed value as if it were a plain attribute (order.total_with_tax, no parentheses); a plain instance method is for anything that takes arguments or has a side effect (order.mark_paid()). get_absolute_url() is a specific, Django-recognized method returning an object's canonical URL, used by the admin's "View on site" link and the syndication framework. Domain methods belong on the model only up to the point they stay about THAT object — orchestration across multiple models/services belongs elsewhere.

Key features

  • @property computes a value from existing fields without a database round-trip — order.total_with_tax, not order.total_with_tax()
  • get_absolute_url() is a Django-recognized convention (not a required method) — implement it with reverse() so URL changes update it automatically
  • The Django admin's "View on site" link and the syndication feed framework both call get_absolute_url() automatically if the model defines it
  • A domain method (business behavior) should be answerable using only the object's OWN fields/relations — the moment it needs to email someone, call an external API, or touch unrelated models, that is orchestration, not domain logic
  • Persistence (save/delete), business (a domain method), and orchestration (a service function coordinating several models/systems) are three different responsibilities — only the first two belong on the model itself

Where a piece of model-adjacent logic belongs

Where a piece of model-adjacent logic belongs
BehaviorExampleBelongs on the model?
Persistencesave(), delete()yes — built in
Computed attribute@property total_with_taxyes — pure function of this object's own fields
Domain rule about this object aloneis_overdue(), can_be_cancelled()yes — a domain method
Coordinating multiple objects/systemsplacing an order: charge card + decrement stock + send emailno — a service function/layer, not a model method

Command

python
@property
def is_overdue(self):
    return self.status == self.Status.PENDING and ...

def get_absolute_url(self):
    return reverse("orders:detail", kwargs={"pk": self.pk})

@property for a computed value with no arguments; a domain method (like mark_paid()) should stay answerable using only this object's own fields — sending email, calling external APIs, or touching unrelated models is orchestration, and belongs in a service function that CALLS the model, not inside it. Implement get_absolute_url() with reverse().

Remember

  • save() decides INSERT vs UPDATE automatically and never validates — full_clean() must be called explicitly on any write path bypassing ModelForm.
  • full_clean() runs clean_fields() → clean() → validate_unique() → validate_constraints(), collecting every error at once.
  • @property for a computed, argument-free value; a domain method should stay answerable using only the object's own fields.
  • Sending email, calling external APIs, or touching unrelated models from inside a model method is orchestration — push it to a service function instead.

Pro tip

  • delete() removes the row but the Python instance survives in memory with pk reset to None — read any needed data before or right after calling it.
  • Implement get_absolute_url() with reverse(), never a hardcoded path string, so it tracks urls.py automatically.

Next up

  • Django ORM Fundamentals
  • Query Expressions
  • Aggregation and Annotation

Sources

  • Model instance reference — docs.djangoproject.com/en/5.2/ref/models/instances/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-08-24

Spotted a mistake on this page? Tell us.