Database Constraints in Django

Table of Contents

Introduction

Data integrity refers to the accuracy of data stored inside a database. When creating web applications, data integrity becomes an important issue, and rules help enforce data integrity at various levels including inside Django by writing Python code or at the database level with constraints. I prefer using database constraints because they require very little code and maintenance, and the rule gets enforced regardless of where in the code the database table is used. A Django Model’s clean method is an alternative to database constraints. Using the clean method can be confusing because it is not called by a Model’s save method. I’ve found this leads to bugs and invalid data entering the database.

For this article, we’ll create a Django application to store employee data for various organizations. Part of our application allows organizations to set employee goals with a score between 1 and 5. The goal includes a baseline score where the employee starts and a target score where the employee should end.

Example Code

import decimal

from django.contrib.auth import get_user_model
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import (
    DateRangeField,
    RangeBoundary,
    RangeOperators,
)
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models

User = get_user_model()


class DateRangeFunc(models.Func):
    function = "daterange"
    output_field = DateRangeField()


class Organization(models.Model):
    name = models.CharField(max_length=255)


class Employee(models.Model):
    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["user", "organization"], name="unique_employee_user"
            )
        ]

    user = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name="employees"
    )
    organization = models.ForeignKey(
        Organization, on_delete=models.CASCADE, related_name="employees"
    )


class EmployeeGoal(models.Model):
    class Meta:
        constraints = [
            ExclusionConstraint(
                name="exclude_overlapping_goals",
                expressions=(
                    (
                        DateRangeFunc(
                            "start_date", "end_date", RangeBoundary()
                        ),
                        RangeOperators.OVERLAPS,
                    ),
                    ("employee", RangeOperators.EQUAL),
                ),
            ),
            models.CheckConstraint(
                name="baseline_less_than_target",
                check=models.Q(baseline_score__lte=models.F("target_score")),
            ),
        ]

    employee = models.ForeignKey(
        Employee,
        on_delete=models.CASCADE,
    )
    description = models.TextField()
    target_score = models.DecimalField(
        decimal_places=2,
        max_digits=3,
        validators=[
            MinValueValidator(decimal.Decimal(1)),
            MaxValueValidator(decimal.Decimal(5)),
        ],
    )
    baseline_score = models.DecimalField(
        decimal_places=2,
        max_digits=3,
        default=decimal.Decimal("2.50"),
        validators=[
            MinValueValidator(decimal.Decimal(1)),
            MaxValueValidator(decimal.Decimal(5)),
        ],
    )
    start_date = models.DateField()
    end_date = models.DateField()

Unique Constraint

Our Employee model contains a unique constraint to ensure we don’t duplicate an employee in an organization.

models.UniqueConstraint(
    fields=["user", "organization"], name="unique_employee_user"
)

In the fields argument, we pass a list of model fields that should be unique together. We use the Employee model to map Django’s default User model to an Organization model that we defined above our Employee model, and we want to ensure that duplicate employee records don’t exist. Let’s say we have an organization, LAAC Technology; a User, Steven; and an employee record linking these stored in our database. If we attempt to create another employee linking LAAC Technology and Steven, an IntegrityError is raised, and the database prevents this new record from being created.

Check Constraint

Our check constraint exists on the EmployeeGoal model to prevent the goal’s baseline score from being greater than the target score. In our application, an employee goal where the baseline score is greater than the target score doesn’t make sense because we want the employee to improve not decline in score.

models.CheckConstraint(
    name="baseline_less_than_target",
    check=models.Q(baseline_score__lte=models.F("target_score")),
),

For the check argument, we pass a Q object which contains our query, baseline score is less than or equal to the target score. We wrap the target score in an F object, which lets us refer to the value of target_score within the check constraint. If the baseline score is greater than the target score, the check constraint results in an IntegrityError and prevents the change in the database.

Exclusion Constraint

Django supports a PostgreSQL specific database constraint called the exclusion constraint. This constraint allows us to define complex rules for our database tables. To use this constraint, you need to run a migration to install the btree gist extension.

For our EmployeeGoal model, we only want one goal active at a time. To implement this, we use an exclusion constraint to prevent overlapping date ranges for start date and end date per employee. We define a database function that allows our constraint to determine the date range.


class DateRangeFunc(models.Func):
    function = "daterange"
    output_field = DateRangeField()

ExclusionConstraint(
    name="exclude_overlapping_goals",
    expressions=(
        (
            DateRangeFunc(
                "start_date", "end_date", RangeBoundary()
            ),
            RangeOperators.OVERLAPS,
        ),
        ("employee", RangeOperators.EQUAL),
    ),
),

The exclusion constraint allows us to pass in a list of expressions that make up the constraint. An expression consists of a reference to a field or database function and a SQL operator. Our first expression says that the date range between start_date and end_date should not overlap. Our second expression says that the employee should not be equal. Combining these rules creates our exclusion constraint. Let’s say that our Steven employee of LAAC Technology has a goal with a start date of Feburary 1, 2021 and end date of March 1, 2021. If we attempt to create an employee goal for Steven with a start date of Februray 15, 2021 and an end date of March 15, 2021, the attempt results in an IntegrityError, and the new goal won’t be created.

Final Thoughts

Whenever I want to define rules for my application’s data, I use database constraints. In my experience, they result in the most consistent data because no matter where in the code a database table is operated on the rule is enforced. Occasionally, the database constraints are too limited, and I’ll be forced to write Python code. In these cases, I’ll reach for a Model’s clean method. Since this method is not called during a Model’s save method, you have to ensure that you call the clean method in the appropriate places such as inside a Form.

Avatar
Steven Pate
Founder

Senior Software Engineer with writing and people skills specializing in Python based solutions

Related