skip to content

In Django form validation, why should a ValidationError carry a code and params rather than a pre-formatted message?

level: middleimportance: should knowfreq 40%

answer

  1. machines versus humans
  2. stable across translations
  3. interpolation happens later
  4. error_messages keyed by code
  5. lists and dicts of errors

basics

~10 s

A Django ValidationError's code lets tests, views and error_messages overrides identify the error without parsing text, and params let the message be translated and reworded with placeholders filled in at render time.

solid answer

~40 s

Django's documentation recommends `ValidationError(_('Only %(max)s guests per room.'), code='too_many_guests', params={'max': 4})`. The **code** is a stable identifier: tests assert it with `has_error()`, views branch on it through `errors.as_data()`, JSON clients receive it, and it survives translation and rewording. The **params** are interpolated only when the message is rendered, so translators can move or drop `%(max)s`, and the message stays overridable. Codes also drive `error_messages`: when a validator run by a field raises with code `min_value`, Django swaps in the field's `error_messages['min_value']` if one is set. A pre-formatted f-string loses all of that. You can also raise a list of errors, or, from `Form.clean()` only, a dict keyed by field names.

code

python · 23 lines
python
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

MAX_GUESTS = {'single': 1, 'double': 2, 'family': 4}

class BookingForm(forms.Form):
    room_type = forms.ChoiceField(choices=[(k, k.title()) for k in MAX_GUESTS])
    guests = forms.IntegerField(
        min_value=1,
        error_messages={'min_value': _('Book at least one guest.')},
    )

    def clean(self):
        cleaned = super().clean()
        room, guests = cleaned.get('room_type'), cleaned.get('guests')
        if room and guests and guests > MAX_GUESTS[room]:
            self.add_error('guests', ValidationError(
                _('A %(room)s room holds at most %(max)s guests.'),
                code='too_many_guests',
                params={'room': room, 'max': MAX_GUESTS[room]},
            ))
        return cleaned

go deeper

for a junior

Recall the constructor: message, code and params, and that you should always give a code.

for a middle

Explain how params are interpolated at render time, how error_messages replaces a validator's message by code, and where list and dict errors are allowed.

for a senior

Show you design reusable validators and fields with stable codes, test by code, and keep messages translatable and overridable.

for a principal

Treat error codes as part of the product's contract with front ends and translators, and set conventions for naming and stability.

## Anatomy of a ValidationError `django.core.exceptions.ValidationError(message, code=None, params=None)` is the one exception every layer of Django form validation raises. It carries three things: - **`message`**: human-readable text, ideally marked for translation, with `%(name)s` placeholders. - **`code`**: a short machine-readable identifier such as `'too_many_guests'`. - **`params`**: a dictionary whose values fill the placeholders when the message is displayed. The message can also be a **list** of errors or a **dict** mapping field names to errors, which is how several problems travel in one exception. ## The documented guidelines Django's form-validation documentation spells out four rules for flexible messages: 1. Give every error a descriptive `code`, so programs can recognise it independently of the wording. 2. Do not interpolate values into the message yourself; use placeholders and `params`. 3. Use named placeholders (`%(max)s`) with a dict, not positional `%s` with a tuple, so translators can reorder or omit them. 4. Wrap the text in `gettext` so it can be translated. Putting it together for a hotel booking form: ```python from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ raise ValidationError( _('A %(room)s room holds at most %(max)s guests.'), code='too_many_guests', params={'room': 'double', 'max': 2}, ) ``` ## What the code buys you | Consumer | Uses the code to | |---|---| | Tests | `form.has_error('guests', code='too_many_guests')` survives rewording | | Views | branch on `form.errors.as_data()` without parsing text | | Script clients | `get_json_data()` sends `code` next to `message` | | Field `error_messages` | replace a validator's message by code | The last row is easy to miss. Inside `Field.run_validators()`, if a validator raises an error whose `code` is a key in the field's `error_messages`, Django replaces the message. So `forms.IntegerField(min_value=1, error_messages={'min_value': 'Book at least one guest.'})` rewrites `MinValueValidator`'s text, because that validator raises with code `'min_value'`. The swap happens in the field's validator step (built-in field errors such as `required` and `invalid` also read `error_messages`); an error you raise yourself in `clean_guests()` is not rewritten, so give it the final text. ## Multiple errors and where dicts are allowed - Raise `ValidationError([error1, error2])` to report several problems for one field at once. - Raise `ValidationError({'check_out': error, 'guests': other})` to target fields, but **only from `Form.clean()`**. Django routes per-field errors through `add_error(fieldname, error)`, which raises `TypeError` when it receives a dict together with a field name, so a dict raised from `clean_guests()` crashes the request instead of showing a message. - Inside `clean()`, `self.add_error()` is often clearer than a dict because it does not stop the method. ## Reading an error back A `ValidationError` exposes its contents in shapes that match how it was built: - `error.messages`: a flat list of rendered strings, with `params` interpolated. - `error.message_dict`: a field-to-messages mapping, available only for dict-shaped errors; on any other error it raises `AttributeError`. - `error.error_list`: the individual `ValidationError` objects, each with its own `code` and `params`. Interpolation happens when the error is iterated or rendered, which is why the untouched `params` stay available to code that inspects the objects. ## When a plain message is acceptable The documentation concedes that at the very end of the chain, in your own form's `clean()`, a message you will never need to override can be formatted directly. Reusable fields, validators and forms shared between apps should always follow the full pattern. ## What interviewers listen for That codes are for machines and messages for people, that params keep translation and overrides working, the `error_messages` swap by code, and the dict-only-in-`clean()` rule.

  • In Django, why does raising ValidationError with a dict from clean_guests() fail?
    Django catches errors from per-field cleaning and calls `add_error('guests', error)`. `add_error()` refuses a dict-shaped error when a field name is given and raises `TypeError`, which escapes as a server error. Dicts keyed by field are only valid from `Form.clean()`, where the field argument is `None`.
  • Should a Django form use gettext or gettext_lazy for ValidationError messages?
    Messages built inside a method at validation time can use `gettext`, because the request's language is active then. Messages defined at import time, such as `error_messages` in a field declaration, need `gettext_lazy` so translation happens when the message is displayed rather than when the module loads.

saying these in an interview costs you the question

  • Formatting values into the message with an f-string is the recommended style.
  • error_messages on a field only affects the required and invalid errors.
  • A dict of field errors can be raised from any clean_<field>() method.
  • The error code changes when the message is translated.
  • ValidationError can only carry one message at a time.