Custom Field Validation

Hello kind folx, I have a question about Field validation process.

I have a Prefix model:

class Prefix(BaseModel):
prefix = IPNetworkField(
    help_text=‘IPv4 or IPv6 network with mask’
)

class Meta:
    verbose_name = 'Prefix'
    verbose_name_plural = 'Prefixes'

def __str__(self):
    return str(self.prefix)

def clean(self):
    super().clean()

    if self.prefix:
        if not self.login:
            self.login = str(self.prefix)

        overlapping_prefixes = self.get_overlapping_prefixes()
        if overlapping_prefixes:
            raise ValidationError({'prefix': f'Overlapping prefix found: {overlapping_prefixes.first()}.'})

def get_overlapping_prefixes(self):
    other_prefixes = Prefix.objects.exclude(pk=self.pk)
    overlapping_prefix_pks = [prefix.pk for prefix in other_prefixes if IPSet(prefix.prefix) & IPSet(self.prefix)]
    return other_prefixes.filter(pk__in=overlapping_prefix_pks)

Custom field IPNetworkField which coerces IP prefix string into netaddr.IPNetwork object:

class IPNetworkField(CharField):

    default_validators = [prefix_validator]

    def from_db_value(self, value, expression, connection):
        return self.to_python(value)

    def get_internal_type(self):
        return 'CharField'

    def to_python(self, value):
        if not value:
            return value
        try:
            return IPNetwork(value)
        except AddrFormatError:
            raise ValidationError(f'Invalid IP Network format: {value}')
        except (TypeError, ValueError) as e:
            raise ValidationError(e)

    def get_prep_value(self, value):
        if not value:
            return None
        if isinstance(value, list):
            return [str(self.to_python(v)) for v in value]
        return str(self.to_python(value))

And prefix_validator func:

def prefix_validator(prefix):

if prefix.ip != prefix.cidr.ip:
    raise ValidationError(f'{prefix} is not a valid prefix. Did you mean {prefix.cidr}?')

if prefix.prefixlen == 0:
    raise ValidationError('Cannot create prefix with /0 mask.')

According to the Form and field validation | Django documentation | Django :

Validation of a form is split into several steps, which can be customized or overridden:

  • The to_python() method on a Field is the first step in every validation. It coerces the value to a correct datatype and raises ValidationError if that is not possible. This method accepts the raw value from the widget and returns the converted value. For example, a FloatField will turn the data into a Python float or raise a ValidationError.

As it in Django’s source code:

def clean(self, value, model_instance):
    """
    Convert the value's type and run validation. Validation errors
    from to_python() and validate() are propagated. Return the correct
    value if no error is raised.
    """
    value = self.to_python(value)
    self.validate(value, model_instance)
    self.run_validators(value)
    return value

So, if I understood this correctly, then any string that is not a valid IPv4 or IPv6 prefix should raise a ValidationError exception in the to_python() of the IPNetworkField. And then the validation process is interrupted and an error message is displayed to the user in the form.

But in my case IPNetworkField’s clean() does not throw an exception. So, prefix attribute in a model has str type, not IPNetwork. And then the code throws an exception in model clean():

Please, help. What am I doing wrong?

Welcome @The-Astiks !

Side note: Please do not post images of text information - copy/paste the text into the body of your post, marked in the same way you marked your code.

When you’re posting information about a traceback, get the traceback from the server and not the browser window, and include the complete traceback.

You’re showing a Prefix model inheriting from a model named BaseModel - what is it? Is it an abstract or concrete model? Does it override the clean method?

What is the form accepting this input?

In this specific case, you might also want to show the actual value attempting to be validated.

get the traceback from the server and not the browser window,

Hallo, @KenWhitesell
I’m now look on the this screenshot (above at browser), too. This screenshot, it when we working to the BUG mode.

and above by browser, we can will see the text-error (row). This text-error, also, we can see in IDE - when we have process the error and auto-pause (in the buge mode).

You mean - " from the server", this error-text form browsers or it’s means the strong from IDE (this some one text-error)?

i have the simple interest, may by - we have the someone more than the simple text-error (in IDE).
What will you say?

I’m referring to the traceback being displayed by runserver in the console window where it’s being run.

Posting the text is far superior to an image:

  1. It’s more readable on some devices than an image
  2. You can’t quote text from the image to highlight key information being referenced.
  3. The forum can’t index and you can’t search for information provided in an image, reducing the usefulness of the post for people looking for solutions in the future.

This is why we request information be copy/pasted into a post rather than screen images. When talking about tracebacks, the best source for this is the console window where you’re running runserver.

Sorry, I’m new to Django and this forum, I’ll keep in mind.

This is an abstract, clean is not overridden:

class BaseModel(models.Model):
    description = models.CharField(
        max_length=100,
        blank=True
    )
    login = models.CharField(
        help_text='Subscriber\'s login. If not specified, the IP address or prefix value is used by default',
        max_length=100,
        blank=True
    )
    is_multi_ip_login = models.BooleanField(
        help_text='If checked login is considered to be common for multiple IP addresses and/or prefixes',
        verbose_name='Login multiple',
        default=True
    )
    policing_profile = models.ForeignKey(
        PolicingProfile,
        verbose_name='Policing profile',
        on_delete=models.PROTECT,
        blank=True,
        null=True
    )
    service_profiles = models.ManyToManyField(
        ServiceProfile,
        verbose_name='Service profiles',
        blank=True
    )
    session_timeout = models.PositiveIntegerField(
        help_text='Reauthentication period in seconds',
        default=300
    )
    session_status = models.CharField(
        verbose_name='Session status',
        choices=AccessStatusChoices,
        default=AccessStatusChoices.ACCEPT
    )
    created = models.DateTimeField(
        auto_now_add=True
    )
    last_updated = models.DateTimeField(
        auto_now=True
    )
    comments = models.TextField(
        blank=True
    )

    class Meta:
        abstract = True

The form:

class PrefixForm(ModelForm):
    service_profiles = ModelMultipleChoiceField(
        queryset=ServiceProfile.objects.all(),
        widget=SelectMultiple(attrs={
            'class': 'form-select tom-select hidden-on-load',
            'multiple': 'multiple',
            'style': 'display: none;',
            'data-ts-placeholder': ''
        }),
        required=False
    )
    class Meta:
        model = Prefix
        fields = ['prefix', 'description', 'login', 'is_multi_ip_login', 'policing_profile',
                  'service_profiles', 'session_timeout', 'session_status', 'comments']
        widgets = {
            'prefix': TextInput(attrs={'class': 'form-control'}),
            'description': TextInput(attrs={'class': 'form-control'}),
            'login': TextInput(attrs={'class': 'form-control'}),
            'is_multi_ip_login': CheckboxInput(attrs={'class': 'form-check-input'}),
            'policing_profile': Select(attrs={'class': 'form-select'}),
            'session_timeout': NumberInput(attrs={'class': 'form-control'}),
            'session_status': Select(attrs={'class': 'form-control'}),
            'comments': Textarea(attrs={'class': 'form-control'}),
        }

The view:

class PrefixCreateView(CreateView):
    model = Prefix
    template_name = 'ipam/prefix_add.html'
    form_class = PrefixForm
    success_url = reverse_lazy('ipam:prefix_list')

    def form_valid(self, form):
        messages.success(self.request, 'Prefix created successfully.')
        return super().form_valid(form)

For example, if I’ll try to enter the invalid prefix 10.0.0.0/244 (there is an extra digit in the prefix length), the traceback will be:

ERROR 2025-11-21 10:45:21,179 log Internal Server Error: /ipam/prefixes/add/
Traceback (most recent call last):
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\strategy\ipv4.py”, line 128, in str_to_int
packed = _inet_pton(AF_INET, addr)
^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\fbsocket.py”, line 147, in inet_pton
return _inet_pton_af_inet(ip_string)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\fbsocket.py”, line 135, in _inet_pton_af_inet
raise invalid_addr
OSError: illegal IP address string ‘1’

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\ip_init_.py”, line 346, in init
self._value = self._module.str_to_int(addr, flags)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\strategy\ipv4.py”, line 132, in str_to_int
raise error
netaddr.core.AddrFormatError: ‘1’ is not a valid IPv4 address string!

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\ip_init_.py”, line 1034, in init
value, prefixlen = parse_ip_network(
^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\ip_init_.py”, line 902, in parse_ip_network
ip = IPAddress(val1, module.version, flags=INET_PTON)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\ip_init_.py”, line 348, in init
raise AddrFormatError(
netaddr.core.AddrFormatError: base address ‘1’ is not IPv4

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\core\handlers\exception.py”, line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\core\handlers\base.py”, line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\views\generic\base.py”, line 105, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\views\generic\base.py”, line 144, in dispatch
return handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\views\generic\edit.py”, line 182, in post
return super().post(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\views\generic\edit.py”, line 150, in post
if form.is_valid():
^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\forms\forms.py”, line 206, in is_valid
return self.is_bound and not self.errors
^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\forms\forms.py”, line 201, in errors
self.full_clean()
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\forms\forms.py”, line 339, in full_clean
self.post_clean()
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\forms\models.py”, line 498, in post_clean
self.instance.full_clean(exclude=exclude, validate_unique=False)
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\django\db\models\base.py”, line 1654, in full_clean
self.clean()
File “C:\Users\Astiks\PycharmProjects\skat\ipam\models.py”, line 109, in clean
overlapping_prefixes = self.get_overlapping_prefixes()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat\ipam\models.py”, line 115, in get_overlapping_prefixes
overlapping_prefix_pks = [prefix.pk for prefix in other_prefixes if IPSet(prefix.prefix) & IPSet(self.prefix)]
^^^^^^^^^^^^^^^^^^
File “C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\ip\sets.py”, line 117, in init
for cidr in cidr_merge(mergeable):
^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\ip_init.py", line 1675, in cidr_merge
net = IPNetwork(ip)
^^^^^^^^^^^^^
File "C:\Users\Astiks\PycharmProjects\skat.venv\Lib\site-packages\netaddr\ip_init.py", line 1045, in init
raise AddrFormatError(‘invalid IPNetwork %s’ % (addr,))
netaddr.core.AddrFormatError: invalid IPNetwork 1
ERROR 2025-11-21 10:45:21,374 basehttp “POST /ipam/prefixes/add/ HTTP/1.1” 500 177508

In this case, I expect that an AddrFormatError exception would be raised in IPNetworkField’s to_python and the validation process would fail, but this is not the case.

I added some logging to the clean methods:

log = logging.getLogger(__name__)

class IPNetworkField(CharField):

    default_validators = [prefix_validator]

    def from_db_value(self, value, expression, connection):
        return self.to_python(value)

    def get_internal_type(self):
        return 'CharField'

    def to_python(self, value):
        if not value:
            return value
        try:
            return IPNetwork(value)
        except AddrFormatError:
            log.error(f'Invalid IP address {value}')
            raise ValidationError(f'to_python: Invalid IP Network format: {value}')
        except (TypeError, ValueError) as e:
            raise ValidationError(e)

    def get_prep_value(self, value):
        if not value:
            return None
        if isinstance(value, list):
            return [str(self.to_python(v)) for v in value]
        return str(self.to_python(value))

    def clean(self, value, model_instance):
        log.info(f'Input: {value}')
        log.info(f'Execute to_python() method')
        value = self.to_python(value)
        log.info(f'Execute validate() method')
        self.validate(value, model_instance)
        self.run_validators(value)
        return value

    def validate(self, value, model_instance):
        log.info('Validate...')
        super().validate(value, model_instance)

    def run_validators(self, value):
        log.info('Run validators...')
        super().run_validators(value)

Then if I try to enter invalid prefix, this log is displayed in the console:

INFO 2025-11-21 11:43:00,437 fields Input: 10.0.0.0/244
INFO 2025-11-21 11:43:00,438 fields Execute to_python() method
ERROR 2025-11-21 11:43:00,438 fields to_python: Invalid IP address 10.0.0.0/244
ERROR 2025-11-21 11:43:00,583 log Internal Server Error: /ipam/prefixes/add/
Traceback (most recent call last):
 ... the same traceback ...

I’m confused, clean in IPNetworkField fails, why is Django trying to clean model anyway?

Probably the reason is that Django still attempts to run your model’s clean() method even though your IPNetworkField's validation fails because of the specific sequence of steps within your ModelForm’s full_clean() process. Model instance reference | Django documentation | Django

1 Like

Django will try to clean the model instance even when the fields fail to validate.
In the past I did some dirty monkey patching in order to get a different behavior.

You can try that (at your own risk), but that might not be exactly what you want in the end.

You can read more about this here: Model.clean after errors on Model.full_clean - #6 by Scotchester

1 Like

Thank you, guys! @anefta @leandrodesouzadev
I looked at the source code of the Model.full_clean() method.
Django performs all checks sequentially, any exceptions that occur are stored in a dictionary and raised only at the end. There is even a comment about it:

 def full_clean(self, exclude=None, validate_unique=True, validate_constraints=True):
    """
    Call clean_fields(), clean(), validate_unique(), and
    validate_constraints() on the model. Raise a ValidationError for any
    errors that occur.
    """
    errors = {}
    if exclude is None:
        exclude = set()
    else:
        exclude = set(exclude)

    try:
        self.clean_fields(exclude=exclude)
    except ValidationError as e:
        errors = e.update_error_dict(errors)

    # Form.clean() is run even if other validation fails, so do the
    # same with Model.clean() for consistency.
    try:
        self.clean()
    except ValidationError as e:
        errors = e.update_error_dict(errors)

    # Run unique checks, but only for fields that passed validation.
    if validate_unique:
        for name in errors:
            if name != NON_FIELD_ERRORS and name not in exclude:
                exclude.add(name)
        try:
            self.validate_unique(exclude=exclude)
        except ValidationError as e:
            errors = e.update_error_dict(errors)

    # Run constraints checks, but only for fields that passed validation.
    if validate_constraints:
        for name in errors:
            if name != NON_FIELD_ERRORS and name not in exclude:
                exclude.add(name)
        try:
            self.validate_constraints(exclude=exclude)
        except ValidationError as e:
            errors = e.update_error_dict(errors)

    if errors:
        raise ValidationError(errors)

If we perform datatype-sensitive actions in Model.clean(), we need to duplicate at least part of the validation code of the field itself. But I don’t quite understand why it was done this way.