49

While subclassing db.models.Model, sometimes it's essential to add extra checks/constraints.

For example, I have an Event model with start_date and end_date: I want to add validation into the fields or the model so that end_date > start_date.

At least I know this can be done outside the models.Model inside the ModelForm validation. But how to attach to the fields and the models.Model?

ivanleoncz
  • 9,070
  • 7
  • 57
  • 49
Viet
  • 17,944
  • 33
  • 103
  • 135
  • what you suggest as constraint can not be defined as a sql statement so only change you'll expect from such check is in admin form. You can do that by overriding adminform save function for that class. umnik700's answer shows how you can do it. – Numenor Feb 17 '10 at 14:07
  • 3
    Actually, there is a "CHECK" constraint in SQL. PostgreSQL supports this: http://www.postgresql.org/docs/8.1/static/ddl-constraints.html However, MySQL does not support this: The CHECK clause is parsed but ignored by all storage engines (see http://dev.mysql.com/doc/refman/5.5/en/create-table.html) – emil.p.stanchev May 05 '10 at 15:18
  • @slack3r: Thanks. I know that there's a check but I just want it at higher level, at the Django metadata declaration level. I avoid schema changes. – Viet May 05 '10 at 16:30
  • Yes, I know, this was just a reply to Numenor who said that this cannot be defined as an sql statement :) – emil.p.stanchev May 05 '10 at 19:35
  • In this case, I'd look at using a DateRange field instead. That will ensure start <= end. – Matthew Schinckel Apr 14 '16 at 00:41

6 Answers6

61

I would not put constraints like these in the save method, it's too late. Raising an exception there, doesn't help the user who entered the data in the wrong way, because it will end up as a 500 and the user won't get the form with errors back etc.

You should really check for this in the Forms/ModelForms clean method and raise a ValidationError, so form.is_valid() returns false and you can send the errors in the form back to the user for correction.

Also note that since version 1.2, Django has had Model Validation.

It would look something like this:

class Foo(models.Model):
    #  ... model stuff...
    def clean(self):
        if self.start_date > self.end_date:
            raise ValidationError('Start date is after end date')
stefanw
  • 10,456
  • 3
  • 36
  • 34
  • 2
    Thanks for the second/better(?) answer. It's just what I needed. – KobeJohn Oct 05 '11 at 07:19
  • 2
    This, of course, only works if you're using a `ModelForm` or call `is_valid` manually. Otherwise, if you just call `save`, [it does *nothing*](https://stackoverflow.com/questions/4441539/why-doesnt-djangos-model-save-call-full-clean). – mlissner Jul 19 '17 at 17:30
  • 2
    Since Django 2.2, you can also add database-level constraints from the model's `Meta` class. https://docs.djangoproject.com/en/2.2/releases/2.2/#constraints – Pablo M Apr 16 '19 at 22:34
58

As of Django 2.2, database level constraints are supported:

from django.db import models
from django.db.models import CheckConstraint, Q, F

class Event(models.Model):
    start_date = models.DatetimeField() 
    end_date = models.DatetimeField()

    class Meta:
        constraints = [
            CheckConstraint(
                check = Q(end_date__gt=F('start_date')), 
                name = 'check_start_date',
            ),
        ]
Lord Elrond
  • 13,430
  • 7
  • 40
  • 80
  • 5
    This is the, currently, right answer! People who are using Django nowadays should know this feature! – gbrennon Mar 18 '20 at 02:20
  • 4
    This is the answer I was looking for, for hours :) So for others, the important takeaway is that you can access the values of other fields with F('other_field') and thus make comparison constraints on DB level, awesome stuff :) Still need to validate though, because the constraint alone will raise an error (500 on server), but it guarantees the DB will refuse as a last resort. – Özer Jul 14 '20 at 21:55
13

Do it inside your save method of your model:

def save(self, *args, **kwargs):
    if(self.end_date > self.start_date):
        super(Foo, self).save(*args, **kwargs)
    else:
        raise Exception, "end_date should be greater than start_date" 
Hamish Downer
  • 16,603
  • 16
  • 90
  • 84
Sergey Golovchenko
  • 18,203
  • 15
  • 55
  • 72
  • 5
    in django 1.2 or later remember to add *args, **kwargs both to the definition of the overriden save() method and anywhere it's being called – michuk Feb 22 '11 at 23:44
  • 3
    IMHO, having a check in the save model is great, but insufficient. You should always impose restrictions at the lowest possible level: in this case, you'd want the database to prevent storing any values that break the constraint. – Matthew Schinckel Apr 14 '16 at 00:42
12

As @stefanw says, it's better user experience to check in the form's clean method.

This is enough if you're very sure that there isn't, and never will be, another way to change the value. But since you can rarely be sure of that, if database consistency is important, you can add another check (in addition to the form), one of:

  • The easier and database-independent way is in the model's save method as @umnik700 said. Note that this still doesn't prevent other users of the database (another app, or the admin interface) from creating an inconsistent state.
  • To be 'completely' sure the database is consistent, you can add a database level constraint. E.g. you can create a migration with RunSQL and SQL, something like (not tested):

    migrations.RunSQL('ALTER TABLE app_event ADD CONSTRAINT chronology CHECK (start_date > end_date);')
    

    (Not tested). This may be database dependent, which is a downside of course.

In your example, it's probably not worth it (incorrect start/end times just look a bit weird, but affect only the one inconsistent event), and you don't want manual schema changes. But it's useful in cases where consistency is critical.

EDIT: You can also just save the start time and the duration, instead of the start and end times.

Mark
  • 18,730
  • 7
  • 107
  • 130
  • Note that CHECK constraints straight-up [don't work in MySQL](https://stackoverflow.com/questions/2115497/check-constraint-in-mysql-is-not-working) – mlissner Jul 19 '17 at 17:26
  • 1
    It may be a little late, I would like to add that as per Django documentation documentation this requires installing the sqlparse Python library. https://docs.djangoproject.com/en/2.1/ref/migration-operations/ – Alberto Millan Dec 10 '18 at 22:54
3

As of today, both postgres 9.4 and MS SQL Server >= 2008 support check constraints in sql. On top of this, there is django issue 11964 which seems to be ready for review since yesterday, so hopefully we'll see this integrated into django 2. The project rapilabs/django-db-constraints seems to implement this too.

Shadi
  • 9,742
  • 4
  • 43
  • 65
3

Summarizing the answers from before, here is a complete solution I used for a project:

from django.db import models
from django.db.models import CheckConstraint, Q, F
from django.utils.translation import gettext_lazy as _

class Event(models.Model):
    start_date = models.DatetimeField() 
    end_date = models.DatetimeField()

    class Meta:
        constraints = [
            # Ensures constraint on DB level, raises IntegrityError (500 on debug=False)
            CheckConstraint(
                check=Q(end_date__gt=F('start_date')), name='check_start_date',
            ),
        ]

    def clean(self):
        # Ensures constraint on model level, raises ValidationError
        if self.start_date > self.end_date:
            # raise error for field
            raise ValidationError({'end_date': _('End date cannot be smaller then start date.')})

Too bad there is no django.core.validators that can handle this :(

kamartem
  • 994
  • 1
  • 12
  • 22
Özer
  • 2,059
  • 18
  • 22
  • This is the complete solution that I confirm works to this date. Handles database data consistency and model (UI) validation. – n1_ Sep 06 '21 at 13:38
  • There's a small logical bug. The check constraint, specifies `end_date > start_date` as a requirement. The clean method specifies `start_date > end_date` as an error. The two checks are inconsistent in handling the `start_date == end_date` case. – hashemi Sep 23 '22 at 10:39