ZetCode

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.

config/settings.py
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.

store/models.py
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.

demos/validators.py
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.

demos/save.py
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.

demos/clean.py
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.

demos/form.py
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.

store/signals.py
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.

store/apps.py
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.

demos/signals.py
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.

demos/bulk.py
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.

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.

store/tests.py
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

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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.