Filter concepts by levelShowing all levels.

Django · Section 16

Model Methods and Properties

Level
intermediate
Read
20 min
Concepts
3

save() and delete() as raw, unvalidated persistence operations; clean() and full_clean() as the separate, explicit validation pipeline save() never runs on its own; and @property/instance methods/get_absolute_url()/domain methods together with the real skill this section teaches — recognizing where persistence behavior, business behavior about a single object, and application orchestration across multiple objects/systems each belong, since only the first two should live on the model itself.

What is true here

  1. save() decides INSERT vs UPDATE automatically and never calls full_clean() — invalid data can be saved unless validation is triggered explicitly.
  2. full_clean() runs clean_fields(), then clean() (your custom cross-field logic), then validate_unique(), then validate_constraints(), collecting every error into one ValidationError.
  3. delete() removes the database row but the Python instance survives afterward, with its primary key reset to None.
  4. @property exposes a computed, argument-free value; a domain method should stay answerable using only the object's own fields and relations.
  5. The moment a model method sends an email, calls an external API, or coordinates multiple unrelated models, it has become orchestration — that belongs in a service function that calls the model, not inside the model itself.

What you will be able to do

  • Know exactly when save()/delete() do and do not validate, and call full_clean() explicitly where needed
  • Trace an error back to the correct step of full_clean()'s four-stage pipeline
  • Choose correctly between @property and a plain instance method
  • Recognize when logic has outgrown a domain method and belongs in a separate service layer instead

save() and delete()

Raw persistence operations — and the crucial fact that neither one validates anything on its own.

save() and delete()

coreintermediate

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.

Think of it as

save() is a raw persistence operation, not a validated one — the model layer trusts you meant what you set on the instance and writes it, the same way a plain SQL INSERT/UPDATE would. Django deliberately keeps validation (full_clean()) as a SEPARATE, explicit step rather than baking it into save(), because plenty of legitimate code paths (data migrations, bulk imports, internal scripts) need to write data without paying the cost — or accepting the failure modes — of full form-style validation on every single write.

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

What we're doing: Update only one field on an existing instance, avoiding a full-row UPDATE and avoiding overwriting other columns changed concurrently by another process.

orders/services.pypython
def mark_paid(order):
    order.status = Order.Status.PAID
    order.save(update_fields=["status"])
    return order
3
update_fields=["status"] tells Django to UPDATE only the status column — every other field on this in-memory instance (even if it happens to differ from what's currently in the database, e.g. changed by another process) is left untouched.

Why this works: A plain order.save() would UPDATE every column on the model, including any that another concurrent request may have changed since this instance was loaded — update_fields narrows the write to exactly the column this function actually intends to change, avoiding accidentally clobbering an unrelated concurrent update.

Assuming save() validates the data being written

Wrong

python
order = Order(customer=customer, status="NOT_A_REAL_STATUS")
order.save()   # no error — "NOT_A_REAL_STATUS" is now in the database

Better

python
order = Order(customer=customer, status="NOT_A_REAL_STATUS")
order.full_clean()   # raises ValidationError here, before anything is written
order.save()

What you see: Invalid data (a status outside choices=, a value violating a custom clean() rule) is saved successfully with no exception raised anywhere — the bug only surfaces later, wherever that value is read and doesn't match what the code expects.

Why: A ModelForm calls full_clean() (via its own validation) before its save() — but calling Model.save() directly, from a view, a script, or a shell, skips validation entirely unless full_clean() is called explicitly first. save() and full_clean() are two genuinely separate steps in Django, not one combined operation.

save() never validates on its own
Code
Model instance
Database
  1. 1. order.status = "NOT_A_REAL_STATUS"
  2. 2. order.save()
  3. 3. INSERT/UPDATE — no validation ran
  4. 4. succeeds — 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

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

Together

python
order = Order(customer=customer, total=Decimal("-50.00"))   # invalid: negative total
order.save()          # succeeds — no validation ran, the bad value is now in the database

order.delete()        # (1, {'orders.Order': 1}) — deleted; order.pk is now None

Remember: 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.

See also: clean and full clean · instance methods and domain logic · constraints and indexes

Advertisement

clean() and full_clean()

The explicit validation pipeline that has to be called on its own — save() never runs it automatically.

clean() and full_clean()

coreintermediate

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.

Think of it as

Think of full_clean() as a checklist runner, and clean() as the one custom item YOU add to that checklist. Individual field validators (max_length, a choices= list, a custom field validator) are checked by clean_fields(), the checklist's first step — clean() is where logic that spans MULTIPLE fields lives (a pub_date that only makes sense given a certain status), since no single field's own validator can see the rest of the model. full_clean() runs the whole checklist in order and reports every failure at once, rather than stopping at the first one — that batching is exactly why forms use it: showing a user all five problems with their submission in one pass beats five separate round trips.

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

What we're doing: Enforce a cross-field rule (a draft article cannot have a publication date) that no single field's own validator could express alone.

articles/models.pypython
class Article(models.Model):
    status = models.CharField(max_length=10, choices=Status.choices)
    pub_date = models.DateField(null=True, blank=True)

    def clean(self):
        if self.status == "draft" and self.pub_date is not None:
            raise ValidationError(
                {"pub_date": "Draft entries may not have a publication date."}
            )
6
The rule depends on BOTH status and pub_date together — status's own CharField validator only knows about status, and pub_date's own DateField validator only knows about pub_date, so neither can express this rule alone. clean() is the one place both are visible at once.
7
Passing a dict ({"pub_date": ...}) attaches the error to the pub_date field specifically, so a form rendering this error shows it next to the right input — a bare string would become a form-wide, non-field error instead.

Why this works: This exact rule cannot be expressed as a single field's validators= list, since it depends on comparing two fields' values against each other — clean() exists specifically for validation that spans more than one field, run as the second step of full_clean(), after each individual field has already passed its own check.

Calling clean() directly instead of full_clean()

Wrong

python
article.clean()   # only runs YOUR custom logic
article.save()    # choices=/max_length/unique constraints were never checked

Better

python
article.full_clean()   # runs clean_fields(), clean(), validate_unique(), validate_constraints()
article.save()

What you see: A value that violates a field's own choices=/max_length, or a unique constraint, is saved successfully — only the custom clean() rule was actually checked, everything else silently passed through.

Why: clean() is only ONE of full_clean()'s four steps — calling it alone skips clean_fields() (per-field checks), validate_unique(), and validate_constraints() entirely. Only full_clean() runs the complete validation pipeline; clean() by itself was never meant to be called standalone.

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

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)

Together

python
class Article(models.Model):
    status = models.CharField(max_length=10, choices=Status.choices)
    pub_date = models.DateField(null=True, blank=True)

    def clean(self):
        if self.status == "draft" and self.pub_date is not None:
            raise ValidationError({"pub_date": "Draft entries may not have a publication date."})
        if self.status == "published" and self.pub_date is None:
            self.pub_date = timezone.now().date()

Remember: 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.

See also: save and delete · constraints and indexes · custom validation

Advertisement

Properties, domain methods, and where logic belongs

Computed attributes, business rules scoped to one object, and recognizing when logic has become orchestration that belongs elsewhere.

Instance methods, @property, and where domain logic belongs

standardintermediate

@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.

Think of it as

A model in Django genuinely does three different jobs, and conflating them is where models grow unmanageable: PERSISTENCE behavior (save(), delete() — talking to the database), BUSINESS behavior (mark_paid(), is_upperclass() — a rule about this one object, expressible using only its own fields), and APPLICATION ORCHESTRATION (send a confirmation email, call a payment gateway, update three other tables — coordinating MULTIPLE objects or systems). The first two belong on the model. The third does not — a "fat model" that reaches out to send emails or call external APIs from inside a method has quietly become a service layer wearing a model's clothes, harder to test (every test now needs a mocked email backend) and harder to reuse (that email always fires, even from a data migration that never wanted it to).

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})

What we're doing: Add a computed @property and a domain method that stay scoped to the Order object itself, and correctly push email-sending out to a separate service function instead.

orders/models.pypython
class Order(models.Model):
    total = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=20, choices=Status.choices)

    @property
    def total_with_tax(self):
        return self.total * Decimal("1.08")

    def mark_paid(self):
        self.status = Order.Status.PAID
        self.save(update_fields=["status"])

    def get_absolute_url(self):
        return reverse("orders:detail", kwargs={"pk": self.pk})
5
total_with_tax needs only this object's own total field — a pure computation, correctly exposed as a property rather than a method that takes no arguments.
9
mark_paid() is a domain method: it changes this object's own state (status) and persists it. It deliberately does NOT send a confirmation email — that belongs in a service function that calls mark_paid() and then separately triggers the email, not inside the model itself.

Why this works: Keeping mark_paid() limited to "change my own status and save" (not "change my status AND email the customer AND notify the warehouse") means a data-migration script or an admin action can call mark_paid() safely, without an unwanted email firing as a side effect — the orchestration (email + warehouse notification) lives in a service function that CALLS mark_paid(), not inside it.

Turning a domain method into a hidden orchestration point that reaches outside the model

Wrong

python
class Order(models.Model):
    def mark_paid(self):
        self.status = Order.Status.PAID
        self.save(update_fields=["status"])
        send_mail("Payment received", ..., [self.customer.email])   # reaches outside the model
        requests.post("https://payment-gateway.example.com/confirm", json={...})   # an external call

Better

python
# orders/models.py — stays scoped to this object
class Order(models.Model):
    def mark_paid(self):
        self.status = Order.Status.PAID
        self.save(update_fields=["status"])

# orders/services.py — orchestration lives here instead
def process_payment_confirmation(order):
    order.mark_paid()
    send_mail("Payment received", ..., [order.customer.email])
    confirm_with_payment_gateway(order)

What you see: Every test that calls order.mark_paid() now needs a mocked email backend and a mocked HTTP client, even tests that only care about the status transition — and a data migration that bulk-corrects order statuses accidentally sends a wave of "payment received" emails for orders that were never actually being marked paid by a real payment event.

Why: Once a model method reaches outside the object it belongs to (sending email, calling an external API, touching unrelated models), every caller of that method inherits those side effects whether they want them or not — a service-layer function that CALLS the narrow domain method, then separately performs the orchestration, keeps the model method safe to call from contexts (migrations, admin actions, tests) that never wanted an email or an API call to fire.

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

Together

python
class Order(models.Model):
    placed_at = models.DateTimeField(auto_now_add=True)
    status = models.CharField(max_length=20, choices=Status.choices)

    @property
    def is_overdue(self):
        return self.status == self.Status.PENDING and self.placed_at < timezone.now() - timedelta(days=7)

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

Remember: @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().

See also: save and delete · choosing the right on delete · mixins and mro

Advertisement