Using Django Check Constraints to Limit the Range of an IntegerField2021-05-08
Another way to use database constraints via Django’s
A classic bit of data validation is to check input values lie within the expected range. This can prevent obvious data accidents, such as the NHS recently recording a journalist’s height as 6cm, and thus calculating his BMI as 28,000(!).
Django’s built-in numerical fields have ranges that match the limits that databases support.
IntegerField supports the range −2,147,483,648 (−231) to 2,147,483,647 (231 − 1).
Most real world numbers lie in much more limited ranges, so we can have our application reject obviously wrong numbers.
Imagine we have a
Book model with a field for the page count, which we know only for some books.
We know the page count cannot be negative, so we would use a
This is a great start but the maximum value of 231 − 1 is still really high.
With a little bit of research we can find Wikipedia’s list of longest novels page. This pegs the longest (work-in-progress) novel at 22,400 pages (Venmurasu). If we round this figure up to 25,000 pages for our upper bound, we can reject outlandishly wrong values. A common mistake could be mixing up word count and page count, which such a bound would prevent as most books have more than 25,000 words.
We also know that books have at least 1 page, so we can add that as a lower bound.
For books where the page count is not known, we want to use
NULL rather than
Adding a check constraint and form validation
We can set up these bounds in a
First, we add the constraint to
check here uses a
This does an inclusive check for the value between the two bounds, which we pass as a tuple.
We don’t need to special-case the
NULL values - any comparison with
NULL results in
NULL, which the constraint interprets as “pass”.
Second, we generate the migration:
We open the migration to check it looks correct:
All looks good.
There are minor differences in the
CheckConstraint definition, as the migrations framework has normalized the order of arguments and the construction of the
Third, we can add a couple of tests to ensure that our constraint works as expected:
The tests attempt to store out-of-range values to trigger the
IntegrityError from the database.
We assert that the error message contains the constraint name, to ensure that we aren’t accidentally triggering a different
Fourth, we need to consider what we should do with existing bad data.
If we try and apply our new migration with bad data in the database, it will crash with another
For this example, let’s assume we can discard out-of-range page counts and replace them with
We can add a short
RunPython operation to our migration to do this:
We use the template for
RunPythondocumentation. This has us fetch the point-in-history version of the
Bookmodel and ensure we query the current database alias.
reverse_codeas a no-op, so that this migration is reversible. This might help us if rolling back due to a bug, but it won’t restore the deleted page counts, since we aren’t backing them up anywhere.
We declare the operation as elidable. This tells Django it can drop the operation when squashing the migration history.
Now the migration can run when bad data exists.
Fifth, we should consider how to present friendly error messages to users when entering values outside of the range.
By default, if we try to store data rejected by a
CheckConstraint, Django will only raise the
We want to add validation for forms so that users can see and correct mistakes.
IntegerField and its subclasses already add form validators to check values lie in their supported ranges.
We can follow this example and add our own validators for the new, more limited range.
Adding the validators on the model field means Django can copy them to any form fields derived from the model with
We can add the validators to
Field.validators like so:
Because we’ve changed the field definition, we need to generate a new migration:
This migration uses
AlterField to redefine the field with the new
We can show the SQL for this migration and check it won’t actually modify the database:
Nothing but the standard transaction
END - great!
May your data always be true,
Want better tests? Check out my book Speed Up Your Django Tests which teaches you to write faster, more accurate tests.
One summary email a week, no spam, I pinky promise.
- Using Django Check Constraints to Prevent Self-Following
- Using Django Check Constraints for the Sum of Percentage Fields
- Using Django Check Constraints to Prevent the Storage of The Empty String
© 2021 All rights reserved.