Django model validation and signals
last modified November 13, 2026 Python 3.15
In this article we keep model data correct and react to changes. We attach validators
to fields, write cross-field rules in clean(), run all the checks with
full_clean(), and see why save() does not validate. Then we
connect receivers to the pre_save, post_save, and
post_delete signals, define a custom signal, and discuss when not to use
signals. The examples use Django 6.1, Python 3.15, and the default SQLite database.
Validation in Django
Django validates data in layers. A database column has a type and constraints
that the database enforces. A model field has validators, functions that raise
ValidationError for a bad value. Cross-field rules go into the model's
clean() method. The method full_clean() runs it all:
clean_fields() checks every field, clean() checks the model as a whole,
validate_unique() checks the unique fields, and
validate_constraints() checks the constraints from Meta. The errors
from all steps are collected in one ValidationError. What we must remember
is that save() does not call full_clean(). Model validation
runs when a ModelForm is validated, or when we call it ourselves.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, start the app store, and create the folder demos for demo scripts.
$ mkdir validation $ cd validation $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp store $ mkdir demos
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'store',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
A product has a name, an
SKU, a price, an optional sale price, and a stock. The SKU must match a pattern
and must not start with a reserved prefix; the price must be positive; a sale price must be
lower than the price; and two products cannot have the same name, whatever the case. The
AuditEntry model records what happens to the products; we use it with the
signals later.
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, RegexValidator
from django.db import models
from django.db.models.functions import Lower
def validate_not_reserved(value):
if value.startswith('TST'):
raise ValidationError(
'The prefix %(prefix)s is reserved.', code='reserved', params={'prefix': 'TST'})
class Product(models.Model):
name = models.CharField(max_length=100)
sku = models.CharField(
max_length=8, unique=True,
validators=[
RegexValidator(r'^[A-Z]{3}-\d{4}$', 'The SKU must look like ABC-1234.'),
validate_not_reserved,
])
price = models.DecimalField(
max_digits=8, decimal_places=2, validators=[MinValueValidator(Decimal('0.01'))])
sale_price = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
stock = models.PositiveIntegerField(default=0)
class Meta:
constraints = [
models.UniqueConstraint(
Lower('name'), name='unique_product_name_ci',
violation_error_message='A product with this name already exists.'),
]
def clean(self):
if self.sale_price is not None and self.price is not None and self.sale_price >= self.price:
raise ValidationError({'sale_price': 'The sale price must be lower than the price.'})
def __str__(self):
return f'{self.sku} {self.name}'
class AuditEntry(models.Model):
sku = models.CharField(max_length=8)
action = models.CharField(max_length=20)
The RegexValidator and MinValueValidator are built-in
validators; validate_not_reserved() is a function that we wrote. It raises
ValidationError with a code and params for the message
template. The functional UniqueConstraint(Lower('name')) makes the name unique
regardless of the case; its violation_error_message is the text that validation reports. The tables are created with
makemigrations and migrate (see
Django migrations).
$ uv run python manage.py makemigrations store
Migrations for 'store':
store/migrations/0001_initial.py
+ Create model AuditEntry
+ Create model Product
$ uv run python manage.py migrate store
Operations to perform:
Apply all migrations: store
Running migrations:
Applying store.0001_initial... OK
Field validators
Validators are listed in the validators argument of a field. A validator is
any callable that takes the value and raises ValidationError; this
is how the built-in ones work, too. A ValidationError can carry a
code, which is a stable identifier for programs and tests, and
params, which are substituted into the message with the
%(name)s syntax. In addition to validators, the field options themselves are
checked: blank, choices, and the maximum length.
When validation fails, message_dict maps a field to its messages and
error_dict maps it to the error objects.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from decimal import Decimal
from django.core.exceptions import ValidationError
from store.models import Product
product = Product(name='', sku='abc', price=Decimal('-1'))
try:
product.full_clean()
except ValidationError as error:
for field, messages in error.message_dict.items():
print(field, messages)
try:
Product(name='Chair', sku='TST-0001', price=Decimal('50')).full_clean()
except ValidationError as error:
reserved = error.error_dict['sku'][0]
print(reserved.code, reserved.params, reserved.message)
The script validates a product that is wrong in three fields; full_clean() does not stop at the
first error but reports all of them: the name is blank, the SKU does not match the pattern, and the price
is below the minimum. The second part validates the reserved prefix and prints the code, the
params, and the message template of the error object, before the substitution.
$ uv run python -m demos.validators
name ['This field cannot be blank.']
sku ['The SKU must look like ABC-1234.']
price ['Ensure this value is greater than or equal to 0.01.']
reserved {'prefix': 'TST'} The prefix %(prefix)s is reserved.
save() does not validate
The most common misunderstanding: validators run only inside full_clean()
and in forms. Calling save() or objects.create() writes whatever
the database accepts. The database enforces only what its constraints say, such as NOT
NULL, unique and check constraints, and the column type. If we want a model to
validate itself, we call full_clean() before saving, for example in
the service function that creates the objects. Forms and the admin site call it for us.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from decimal import Decimal
from store.models import Product
Product(name='', sku='abc', price=Decimal('-1'), stock=10).save()
print(list(Product.objects.values_list('name', 'sku', 'price')))
Product.objects.filter(name='').delete()
print(Product.objects.count())
The script saves a product with an empty name and a negative price, which both
validators would reject, and it is stored. The SKU in the output is ABC,
not abc, because the pre_save receiver that we write below
converts it to the upper case. The script then deletes the invalid row.
$ uv run python -m demos.save
[('', 'ABC', Decimal('-1.00'))]
0
clean() and constraints
Rules that involve several fields belong to the clean() method. It
runs after the field validation, also when some fields failed, so it must not assume that the
values are present or valid; our method checks that the prices are not None.
Raising ValidationError('message') reports a model-wide error under the key
__all__, and raising it with a dictionary, as
ValidationError({'sale_price': '...'}), attaches the error to a field. Since Django 4.1,
full_clean() also validates the constraints declared in
Meta.constraints, and the violation_error_message shows the text. The
validate_unique() step produces the messages for the unique=True
fields. These checks run queries and are a convenience; only the database constraint
protects the data from two concurrent requests. Finally, the exclude argument
of full_clean() skips fields, which is useful when a field is filled in later.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from decimal import Decimal
from django.core.exceptions import ValidationError
from store.models import Product
Product(name='Lamp', sku='LMP-0001', price=Decimal('19.90'), stock=10).save()
for kwargs in [
dict(name='Desk', sku='DSK-0001', price=Decimal('100'), sale_price=Decimal('120')),
dict(name='LAMP', sku='LMP-0002', price=Decimal('5')),
dict(name='Shelf', sku='LMP-0001', price=Decimal('5')),
]:
try:
Product(**kwargs).full_clean()
except ValidationError as error:
print(kwargs['name'], dict(error.message_dict))
try:
Product(name='Desk', sku='abc', price=Decimal('100')).full_clean(exclude=['sku'])
print('valid when sku is excluded')
except ValidationError as error:
print(error)
The script saves a lamp and validates three products. The desk has a sale price above its price, and
clean() reports the error under sale_price. The product named
LAMP breaks the case-insensitive constraint, and the error is stored under
__all__, as the constraint does not belong to one field. The shelf repeats the
SKU of the lamp, which gives the standard message for a unique field. The last call excludes the
sku field, whose value abc is invalid, and the validation
passes.
$ uv run python -m demos.clean
Desk {'sale_price': ['The sale price must be lower than the price.']}
LAMP {'__all__': ['A product with this name already exists.']}
Shelf {'sku': ['Product with this Sku already exists.']}
valid when sku is excluded
Validation in forms
A ModelForm builds its fields from the model and calls the model validation
when the form is validated: the field validators run as the form field is cleaned, and
clean() and the unique checks run afterwards. The errors are collected in
form.errors. Because of this, a view that uses a form needs no extra
validation code. Forms are the subject of a separate article.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.forms import modelform_factory
from store.models import Product
form_class = modelform_factory(Product, fields=['name', 'sku', 'price'])
form = form_class({'name': '', 'sku': 'abc', 'price': '-1'})
print(form.is_valid())
for field, errors in form.errors.items():
print(field, list(errors))
form = form_class({'name': 'Chair', 'sku': 'CHR-0001', 'price': '45.00'})
print(form.is_valid(), form.cleaned_data)
The script creates a form class for three fields with modelform_factory() and
validates the bad data. The messages for the SKU and the price come from the model validators; the blank
name is reported by the form as a required field. The second data set is valid, and the form gives us the
converted values in cleaned_data, including a Decimal price.
$ uv run python -m demos.form
False
name ['This field is required.']
sku ['The SKU must look like ABC-1234.']
price ['Ensure this value is greater than or equal to 0.01.']
True {'name': 'Chair', 'sku': 'CHR-0001', 'price': Decimal('45.00')}
Signals
A signal lets one piece of code announce that something happened, and
other code react to it without being called directly. Django sends signals around the
model lifecycle: pre_save and post_save around
save(), pre_delete and post_delete around deletion,
and m2m_changed for many-to-many relations. There are also signals for
requests and for migrations. A receiver is a function that is registered with the
@receiver decorator for a signal and, with the sender argument, for one model. We
can define our own signals with Signal() and send them with
send(). Receivers run synchronously in the thread that sent the signal, in the
order of registration.
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import Signal, receiver
from .models import AuditEntry, Product
stock_low = Signal()
@receiver(pre_save, sender=Product)
def normalize_product(sender, instance, **kwargs):
instance.name = instance.name.strip()
instance.sku = instance.sku.upper()
@receiver(post_save, sender=Product)
def audit_save(sender, instance, created, update_fields, **kwargs):
AuditEntry.objects.create(sku=instance.sku, action='created' if created else 'updated')
if instance.stock < 5:
stock_low.send(sender=sender, product=instance)
@receiver(post_delete, sender=Product)
def audit_delete(sender, instance, **kwargs):
AuditEntry.objects.create(sku=instance.sku, action='deleted')
@receiver(stock_low)
def warn_low_stock(sender, product, **kwargs):
print(f'low stock: {product.sku} has {product.stock} left')
The first receiver cleans the data before saving: it strips the name and converts the
SKU to the upper case. The post_save receiver gets the created
flag, which tells an insert from an update, and the update_fields argument. It writes an audit entry and,
when the stock is below five, sends our custom signal stock_low with the
product as a keyword argument; the receiver of the custom signal prints a warning. A third receiver records
deletions. Every receiver must accept **kwargs, because Django may add arguments.
from django.apps import AppConfig
class StoreConfig(AppConfig):
name = 'store'
def ready(self):
from . import signals # noqa: F401
The receivers must be imported when Django starts, otherwise they are never
registered. The ready() method of the app configuration is the place for it. If
a module may be imported twice, pass a dispatch_uid to the decorator, so that a receiver is
connected only once. The app is listed in INSTALLED_APPS as
'store'; Django finds the single AppConfig subclass by
itself.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from decimal import Decimal
from store.models import AuditEntry, Product
def audit():
return list(AuditEntry.objects.values_list('sku', 'action'))
Product.objects.all().delete()
AuditEntry.objects.all().delete()
lamp = Product.objects.create(name=' Lamp ', sku='lmp-0001', price=Decimal('19.90'), stock=3)
print(repr(lamp.name), lamp.sku)
print(audit())
print('--- update')
lamp.price = Decimal('17.50')
lamp.save(update_fields=['price'])
print(audit())
print('--- delete')
lamp.delete()
print(audit())
The script creates a product with a padded name and a lower case SKU, and prints it: the
pre_save receiver has normalized them. The low stock warning is printed during the
creation, so it appears before the first line. The audit table has the entry
created. The second step saves the changed price with
update_fields; the receiver reports updated, and the stock is still low. Finally, the
deletion adds a deleted entry.
$ uv run python -m demos.signals
low stock: LMP-0001 has 3 left
'Lamp' LMP-0001
[('LMP-0001', 'created')]
--- update
low stock: LMP-0001 has 3 left
[('LMP-0001', 'created'), ('LMP-0001', 'updated')]
--- delete
[('LMP-0001', 'created'), ('LMP-0001', 'updated'), ('LMP-0001', 'deleted')]
What signals do not see
Signals are tied to the save() and delete() calls of model
instances. Bulk operations that go straight to the database skip them:
bulk_create(), bulk_update(), and
QuerySet.update() send no pre_save or post_save. A
queryset delete(), on the other hand, loads the objects when receivers are
connected, and sends pre_delete and post_delete for each
of them. Raw SQL sends no signals at all. Code that depends on a signal for a rule is therefore not always executed.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from decimal import Decimal
from store.models import AuditEntry, Product
Product.objects.all().delete()
AuditEntry.objects.all().delete()
Product.objects.bulk_create([
Product(name=' One ', sku='one-0001', price=Decimal('1'), stock=10),
Product(name=' Two ', sku='two-0001', price=Decimal('2'), stock=10),
])
Product.objects.filter(sku='one-0001').update(stock=1)
print(list(Product.objects.values_list('name', 'sku', 'stock')))
print(list(AuditEntry.objects.values_list('sku', 'action')))
Product.objects.all().delete()
print(list(AuditEntry.objects.values_list('sku', 'action')))
The script creates two products with bulk_create() and updates one with
update(). The names keep their spaces and the SKUs stay in lower case, as the
pre_save receiver did not run, and the audit table is empty. Deleting the
products with a queryset does fire post_delete for each one, which gives two
deleted entries.
$ uv run python -m demos.bulk
[(' One ', 'one-0001', 1), (' Two ', 'two-0001', 10)]
[]
[('two-0001', 'deleted'), ('one-0001', 'deleted')]
When not to use signals
Signals are convenient, but they hide the flow of the program: reading
product.save() we do not see that an audit entry, a warning, and perhaps an email
follow. They run synchronously and slow the save down, an exception in a receiver breaks the
save, and the bulk operations avoid them. They are a good fit when the code that sends the signal and the code that
reacts live in different apps and should not know each other, or when we react to models that
we do not own, such as the user model of a third-party app. Inside one app, there are plainer
alternatives.
- override
save()on the model for rules that always apply to its instances - write a service function, as in Django raw SQL and transactions, that does the steps explicitly and in a transaction
- use
transaction.on_commit()for side effects such as email or background jobs, so that they run only after the data is committed - use database constraints and defaults for rules that must hold for all writers
A receiver that sends an email should register it with on_commit()
inside the receiver; sending messages is described in
Django email.
Testing validation and signals
The tests cover the validators, the error codes, the cross-field rule, the
constraint, and the fact that save() does not validate. The signal tests check the
normalization, the audit entries, the bulk operation, and a custom signal, with a receiver
that is connected for the duration of the test and disconnected in a
finally block.
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.test import TestCase
from .models import AuditEntry, Product
from .signals import stock_low
def make(**kwargs):
values = dict(name='Lamp', sku='LMP-0001', price=Decimal('10'), stock=10)
values.update(kwargs)
return Product(**values)
class ValidationTests(TestCase):
def test_valid_product(self):
make().full_clean()
def test_sku_format(self):
with self.assertRaises(ValidationError) as context:
make(sku='abc').full_clean()
self.assertIn('sku', context.exception.message_dict)
def test_custom_validator_code(self):
with self.assertRaises(ValidationError) as context:
make(sku='TST-0001').full_clean()
error = context.exception.error_dict['sku'][0]
self.assertEqual(error.code, 'reserved')
def test_clean_compares_fields(self):
with self.assertRaises(ValidationError) as context:
make(sale_price=Decimal('12')).full_clean()
self.assertEqual(list(context.exception.message_dict), ['sale_price'])
def test_case_insensitive_unique_name(self):
make().save()
with self.assertRaises(ValidationError) as context:
make(name='LAMP', sku='LMP-0002').full_clean()
self.assertIn('A product with this name already exists.', context.exception.messages)
def test_save_does_not_validate(self):
make(sku='bad').save()
self.assertTrue(Product.objects.filter(sku='BAD').exists())
class SignalTests(TestCase):
def test_pre_save_normalizes(self):
product = make(name=' Lamp ', sku='lmp-0001')
product.save()
self.assertEqual((product.name, product.sku), ('Lamp', 'LMP-0001'))
def test_post_save_writes_an_audit_entry(self):
product = make()
product.save()
product.save()
actions = list(AuditEntry.objects.values_list('action', flat=True))
self.assertEqual(actions, ['created', 'updated'])
def test_bulk_create_sends_no_signal(self):
Product.objects.bulk_create([make()])
self.assertEqual(AuditEntry.objects.count(), 0)
def test_custom_signal(self):
received = []
def receiver(sender, product, **kwargs):
received.append(product.sku)
stock_low.connect(receiver)
try:
make(stock=1).save()
make(sku='LMP-0002', name='Other', stock=50).save()
finally:
stock_low.disconnect(receiver)
self.assertEqual(received, ['LMP-0001'])
The helper make() builds a valid product, and each test changes the one
value it needs. The line low stock: LMP-0001 has 1 left in the
output comes from the warn_low_stock receiver, which prints during the test of
the custom signal.
$ uv run python manage.py test Found 10 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .low stock: LMP-0001 has 1 left ......... ---------------------------------------------------------------------- Ran 10 tests in 0.007s OK Destroying test database for alias 'default'...
Source
Django documentation: Validating objects
Django documentation: Validators
Django documentation: Signals reference
In this article we have validated models with validators, clean(),
full_clean(), and constraints, seen that save() does not validate, and
written receivers for the model signals and a custom signal, including their limits. Related
articles are Django models,
Django managers and custom QuerySets, and
Django raw SQL and transactions.
Author
List all Django tutorials.