Model.clean after errors on Model.full_clean

If you do not call clean when there is at least one error, you may miss some errors in the final result. For example, if the errors (from clean_fields) are only for the name field (e.g. too long value), then by not calling clean, your model won’t return a potential error for ends_at being before starts_at.

However, your use case is legitimate and it may appear cumbersome to check for each field implied in a cross-checking whether the check can be achieved or not (in your case, that would mean verify if starts_at and ends_at are instances of datetime.date before doing comparison).

Another approach if you want to do cross-check only if implied fields are not in errors could be to override clean_fields like this

def clean_fields(self, excluded=None):
    errors = {}
    try:
        super().clean_fields(excluded=excluded)
    except ValidationError as e:
        e.update_error_dict(errors)
    try:
        if "starts_at" not in errors and "ends_at" not in errors and self.starts_at > self.ends_at:
            raise ValidationError({"ends_at": _("...")})
    except ValidationError as e:
        e.update_error_dict(errors)
    if errors:
        raise ValidationError(errors)

But checking whether fields to check are in errors like in the above is not really simpler than writing:

def clean(self):
    if isinstance(self.starts_at, datetime.date) and isinstance(self.ends_at, datetime.date) and self.starts_at > self.ends_at:
        raise ValidationError({"ends_at": _("...")})

Moreover, the latter has the advantage of checking consistency between starts_at and ends_at if both are dates but one is invalid (for example because it is beyond a max value you may have define on those fields with a validator). To achieve the same with the clean_fields override example above, you not only have to check whether fields are in errors or not, but you also need to check what kind of error happened.

Hope that makes sense.