Published on Feb. 25, 2025
Go homeError specialisation in Django
When building Django applications with complex data models, we often encounter situations where database constraint violations need to be translated into meaningful messages for users. The default Django approach can leave developers stuck with generic database errors that don't provide clear guidance to end users.
In this post, I'll demonstrate a pattern for specializing database errors in Django applications, creating a cleaner architecture that separates error handling concerns while providing actionable feedback.
Exception hierarchy
Here we define our exception hierarchy.
class DbError(Exception): ...
class ContactError(DbError): ...
class ContactEmailUniqueError(ContactError): ...
class ContactIdNumberUniqueError(ContactError): ...
Each of these execptions correspond to a database constraint violation.
constraints = [
models.UniqueConstraint(
fields=["email_address", "entity_type"],
condition=~models.Q(email_address=""),
violation_error_code="CONEMAIL_UNIQUE",
violation_error_message="Email must be unique for entity type.",
name="conemail_unique",
),
models.UniqueConstraint(
fields=["id_number", "entity_type"],
condition=~models.Q(id_number=""),
violation_error_code="CONID_UNIQUE",
violation_error_message="ID number must be unique for entity type.",
name="conid_unique",
),
]
Constraint violation mapping
Below we define the mechanism for specialising contact specific errors. To specialise an error we pass the exception to the specialise_contact_db_integrity_error
function and raise the result.
class ContactDbConstraintDesription(enum.StrEnum):
"""Models the contact DB errors."""
CONID_UNIQUE = "UNIQUE constraint failed: db_contact.id_number, db_contact.entity_type"
CONEMAIL_UNIQUE = "UNIQUE constraint failed: db_contact.email_address, db_contact.entity_type"
def specialise_contact_db_integrity_error(ex: Exception) -> ContactError: # noqa: PLR0912
"""Reraise a more specific error given the information provided in the db integrity error."""
match f"{ex}":
case ContactDbConstraintDesription.CONID_UNIQUE:
return ContactIdNumberUniqueError("Email is not unique for entity type.")
case ContactDbConstraintDesription.CONEMAIL_UNIQUE:
return ContactEmailUniqueError("ID number is not unique for entity type.")
case _:
return ContactError("Unspecialised contact integrity error.")
Integration with models and views
In our model's methods, we catch database integrity errors and translate them using our specialization function:
def update_detail(self, ...):
try:
# Update logic here...
except IntegrityError as ex:
raise specialise_contact_db_integrity_error(ex) from ex
Finally, in our views, we can catch these specialized exceptions and provide field-specific error messages. Note the use of form.add_error
, with this mechanism we are able to report the issue to the user.
try:
contact.update_detail(...)
messages.success(request, _("Contact details updated"))
except db_models.OptimisticUpdateError:
form.add_error("", GENERIC_ERROR_MESSAGE)
except db_models.ContactEmailUniqueError:
form.add_error("email_address", _("The provided email address is not unique."))
except db_models.ContactIdNumberUniqueError:
form.add_error("id_number", _("The provided identity number is not unique."))
Full example
class DbError(Exception): ...
class ContactError(DbError): ...
class ContactEmailUniqueError(ContactError): ...
class ContactIdNumberUniqueError(ContactError): ...
class ContactDbConstraintDesription(enum.StrEnum):
"""Models the contact DB errors."""
CONID_UNIQUE = "UNIQUE constraint failed: db_contact.id_number, db_contact.entity_type"
CONEMAIL_UNIQUE = "UNIQUE constraint failed: db_contact.email_address, db_contact.entity_type"
def specialise_contact_db_integrity_error(ex: Exception) -> ContactError: # noqa: PLR0912
"""Reraise a more specific error given the information provided in the db integrity error."""
match f"{ex}":
case ContactDbConstraintDesription.CONID_UNIQUE:
return ContactIdNumberUniqueError("Email is not unique for entity type.")
case ContactDbConstraintDesription.CONEMAIL_UNIQUE:
return ContactEmailUniqueError("ID number is not unique for entity type.")
case _:
return ContactError("Unspecialised contact integrity error.")
class Contact(BaseModel):
"""Models a contact."""
name = models.CharField(max_length=25)
short_name = models.CharField(max_length=25, blank=True)
entity_type = models.CharField(max_length=25, choices=EntityType.choices)
id_number = models.CharField(max_length=13, blank=True)
email_address = models.EmailField()
# Versioning
version = models.PositiveIntegerField(db_default=1)
valid_from = models.DateTimeField(auto_now=True)
valid_to = models.DateTimeField(null=True)
# Custom managers
objects = ContactManager()
class Meta:
verbose_name = _("Contact")
verbose_name_plural = _("Contacts")
constraints = [
models.UniqueConstraint(
fields=["email_address", "entity_type"],
condition=~models.Q(email_address=""),
violation_error_code="CONEMAIL_UNIQUE",
violation_error_message="Email must be unique for entity type.",
name="conemail_unique",
),
models.UniqueConstraint(
fields=["id_number", "entity_type"],
condition=~models.Q(id_number=""),
violation_error_code="CONID_UNIQUE",
violation_error_message="ID number must be unique for entity type.",
name="conid_unique",
),
]
indexes = [
models.Index(fields=["entity_type"], name="con_entity_type_idx"),
models.Index(fields=["name"], name="con_name_idx"),
]
def update_detail(
self,
name: str,
short_name: str,
entity_type: str,
id_number: str,
email_address: str,
user: User,
current_datetime: datetime.datetime,
) -> None:
"""Update the contact detail."""
contact_id = self.pk
try:
records_updated = Contact.objects.filter(pk=contact_id, version=self.version).update(
name=name,
short_name=short_name,
entity_type=entity_type,
id_number=id_number,
email_address=email_address,
updated_by=user,
updated_at=current_datetime,
)
if records_updated == 0:
raise OptimisticUpdateError(f"Failed to update contact {contact_id}")
except IntegrityError as ex:
raise specialise_contact_db_integrity_error(ex) from ex
self.refresh_from_db()
def update_contact_detail_view(
request: HttpRequest,
contact_id: int,
) -> HttpResponse:
"""Update the contact details."""
contact = get_object_or_404(db_models.Contact.objects.filter(pk=contact_id))
form = ContactDetailForm(data=request.POST)
if not form.is_valid():
context = self.get_context_data(
contact_id=contact_id,
contact=contact,
request=request,
)
return render(request, self.template_name, context)
name = form.cleaned_data["name"]
short_name = form.cleaned_data["short_name"]
entity_type = form.cleaned_data["entity_type"]
id_number = form.cleaned_data["id_number"]
email_address = form.cleaned_data["email_address"]
user: db_models.User = request.user # type: ignore
current_datetime = timezone.now()
try:
contact.update_detail(
name=name,
short_name=short_name,
entity_type=entity_type,
id_number=id_number,
email_address=email_address,
user=user,
current_datetime=current_datetime,
)
messages.success(request, _("Contact details updated"))
except db_models.OptimisticUpdateError:
form.add_error("", GENERIC_ERROR_MESSAGE)
except db_models.ContactEmailUniqueError:
form.add_error("email_address", _("The provided email address is not unique."))
except db_models.ContactIdNumberUniqueError:
form.add_error("id_number", _("The provided identity number is not unique."))
if not form.is_valid():
context = self.get_context_data(
contact_id=contact_id,
contact=contact,
request=request,
)
return render(request, self.template_name, context)
else:
return redirect(self.get_redirect_url(contact=contact))