Django Constraints & Database Level Validation
Application-level validation can fail. Users can bypass your forms. Scripts can insert bad data directly. Database constraints are your last line of defense.
Django 2.2 brings native constraint support to the ORM.
Why Database Constraints?
Consider this model:
class Discount(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
percentage = models.IntegerField()
start_date = models.DateField()
end_date = models.DateField()
What stops someone from creating a 200% discount? Or an end date before the start date?
Model validation helps, but it’s not enforced at the database level. Raw SQL, management commands, other applications—they all bypass Django validation.
Check Constraints
Enforce rules at the database level:
from django.db import models
from django.db.models import Q, F
class Discount(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
percentage = models.IntegerField()
start_date = models.DateField()
end_date = models.DateField()
class Meta:
constraints = [
models.CheckConstraint(
check=Q(percentage__gte=0) & Q(percentage__lte=100),
name='valid_percentage_range'
),
models.CheckConstraint(
check=Q(end_date__gte=F('start_date')),
name='end_after_start'
),
]
Now the database rejects invalid data:
>>> Discount.objects.create(percentage=150, ...)
IntegrityError: CHECK constraint failed: valid_percentage_range
Unique Constraints
More powerful than unique=True:
Basic Unique
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
username = models.CharField(max_length=100)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['username'],
name='unique_username'
),
]
Conditional Unique
Only enforce uniqueness under certain conditions:
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.CharField(max_length=20)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['user'],
condition=Q(status='pending'),
name='one_pending_order_per_user'
),
]
Users can have multiple completed orders, but only one pending order.
Case-Insensitive Unique
from django.db.models.functions import Lower
class Brand(models.Model):
name = models.CharField(max_length=100)
class Meta:
constraints = [
models.UniqueConstraint(
Lower('name'),
name='unique_brand_name_case_insensitive'
),
]
“Apple”, “APPLE”, and “apple” are all considered duplicates.
Composite Constraints
Multiple fields together:
class Enrollment(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE)
course = models.ForeignKey(Course, on_delete=models.CASCADE)
semester = models.CharField(max_length=20)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['student', 'course', 'semester'],
name='unique_enrollment'
),
]
A student can only enroll in each course once per semester.
Combining Check Constraints
Use Q objects for complex logic:
class Product(models.Model):
price = models.DecimalField(max_digits=10, decimal_places=2)
sale_price = models.DecimalField(max_digits=10, decimal_places=2, null=True)
is_on_sale = models.BooleanField(default=False)
class Meta:
constraints = [
# Price must be positive
models.CheckConstraint(
check=Q(price__gt=0),
name='positive_price'
),
# If on sale, sale_price must exist and be less than price
models.CheckConstraint(
check=(
Q(is_on_sale=False) |
(Q(is_on_sale=True) & Q(sale_price__isnull=False) & Q(sale_price__lt=F('price')))
),
name='valid_sale_price'
),
]
Migrations
Constraints are automatically included in migrations:
class Migration(migrations.Migration):
operations = [
migrations.AddConstraint(
model_name='discount',
constraint=models.CheckConstraint(
check=models.Q(percentage__gte=0),
name='valid_percentage',
),
),
]
Handling Constraint Violations
from django.db import IntegrityError
try:
Discount.objects.create(percentage=150, ...)
except IntegrityError as e:
if 'valid_percentage' in str(e):
raise ValidationError('Percentage must be between 0 and 100')
raise
Or validate before saving:
class Discount(models.Model):
...
def clean(self):
if self.percentage < 0 or self.percentage > 100:
raise ValidationError('Percentage must be between 0 and 100')
if self.end_date < self.start_date:
raise ValidationError('End date must be after start date')
Database Support
Check which constraints your database supports:
| Constraint | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Check | ✅ | ✅ (8.0+) | ✅ |
| Unique | ✅ | ✅ | ✅ |
| Conditional Unique | ✅ | ❌ | ✅ (3.9+) |
| Expression Index | ✅ | ❌ | ❌ |
PostgreSQL has the best support. SQLite works for development.
Constraints vs Model Validation
Use both:
class Discount(models.Model):
percentage = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(100)]
)
class Meta:
constraints = [
models.CheckConstraint(
check=Q(percentage__gte=0) & Q(percentage__lte=100),
name='valid_percentage_range'
),
]
- Validators: Good error messages, form integration
- Constraints: Enforced by database, catches all bugs
Best Practices
- Name constraints descriptively: You’ll see these names in error messages
- Test constraints: Write tests that expect IntegrityError
- Keep validation in sync: Don’t have constraints without matching validators
- Consider performance: Complex check constraints can slow writes
- Use PostgreSQL: Best constraint support
Final Thoughts
Database constraints are cheap insurance. Add them for any business rule that must never be violated.
Start with obvious rules—positive prices, valid date ranges, uniqueness requirements. Your future self will thank you when a bug tries to insert invalid data.
Trust but verify. Verify at the database.