Customizing ValidationError message on UniqueConstraint

Assuming the following model:

class Book(models.Model):
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    name = models.CharField(max_length=128)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["name", "owner"],
                name="book_unique_name_owner",
                violation_error_message="Book with this name already exists.",
            ),
        ]

If we create a Book instance that violates this constraint, calling full_clean() on the instance will raise ValidatioError. For example:

first_book = Book(name="A brief history of time", owner__id=1)
first_book.save()

# Try to create another book that violates the constraint
second_book = Book(name="A brief history of time", owner__id=1)
second_book.full_clean()

# django.core.exceptions.ValidationError: {'__all__': ['Book with this Name and Owner already exists.']}

The ValidationError is correctly raised, but the message is not the custom defined one. The relevant documentation states:

This message is not used for UniqueConstraints with fields and without a condition. Such UniqueConstraints show the same message as constraints defined with Field.unique or in Meta.unique_together.

which explains why the custom message it not displayed. It is not clear to me why the message cannot be set if there is no condition defined, but it is what it is.

I was wondering if there is a clean way to do this, or what other process to follow to achieve something similar. I believe validating the constraints as part of object validation was one of the best features added in django 4.1, which personally helped me remove a lot of redundant code from forms. But this limitation of customizing the message is a bit limiting.

Thanks.

Put out plainly I think this is bug in how we tried to be backward compatible.

When constraint validation support was added we tried to be as backward compatible as possible but I think we failed to account for the fact that passing validation_error_message is a good way to opt-in into the new behaviour.

I’d encourage you to file a new bug report and even submit a PR to get this addressed. I think the following patch should get you started.

diff --git a/django/db/models/constraints.py b/django/db/models/constraints.py
index 56d547e6b0..b7c4186712 100644
--- a/django/db/models/constraints.py
+++ b/django/db/models/constraints.py
@@ -444,14 +444,17 @@ def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS):
                         self.get_violation_error_message(),
                         code=self.violation_error_code,
                     )
-                # When fields are defined, use the unique_error_message() for
-                # backward compatibility.
-                for model, constraints in instance.get_constraints():
-                    for constraint in constraints:
-                        if constraint is self:
-                            raise ValidationError(
-                                instance.unique_error_message(model, self.fields),
-                            )
+                if self.violation_error_message == self.default_violation_error_message:
+                    # When fields are defined, use the unique_error_message() as
+                    # a default for backward compatibility.
+                    validation_error_message = instance.unique_error_message(model, self.fields)
+                    violation_error_code = None
+                else:
+                    validation_error_message = self.get_violation_error_message()
+                    violation_error_code = self.violation_error_code
+                raise ValidationError(
+                    validation_error_message, code=violation_error_code,
+                )
         else:
             against = instance._get_field_value_map(meta=model._meta, exclude=exclude)
             try:
diff --git a/tests/constraints/models.py b/tests/constraints/models.py
index 3ea5cf2323..5f83d2a6e2 100644
--- a/tests/constraints/models.py
+++ b/tests/constraints/models.py
@@ -31,13 +31,17 @@ class Meta:
 class UniqueConstraintProduct(models.Model):
     name = models.CharField(max_length=255)
     color = models.CharField(max_length=32, null=True)
+    age = models.IntegerField(null=True)

     class Meta:
         constraints = [
             models.UniqueConstraint(
                 fields=["name", "color"],
                 name="name_color_uniq",
-                # Custom message and error code are ignored.
+            ),
+            models.UniqueConstraint(
+                fields=["color", "age"],
+                name="color_age_uniq",
                 violation_error_code="custom_code",
                 violation_error_message="Custom message",
             )
diff --git a/tests/constraints/tests.py b/tests/constraints/tests.py
index 55df5975de..ff8c516e5f 100644
--- a/tests/constraints/tests.py
+++ b/tests/constraints/tests.py
@@ -369,7 +369,7 @@ def test_validate_pk_field(self):
 class UniqueConstraintTests(TestCase):
     @classmethod
     def setUpTestData(cls):
-        cls.p1 = UniqueConstraintProduct.objects.create(name="p1", color="red")
+        cls.p1 = UniqueConstraintProduct.objects.create(name="p1", color="red", age=42)
         cls.p2 = UniqueConstraintProduct.objects.create(name="p2")

     def test_eq(self):
@@ -790,6 +790,12 @@ def test_model_validation(self):
             UniqueConstraintProduct(
                 name=self.p1.name, color=self.p1.color
             ).validate_constraints()
+        msg = "Custom message"
+        with self.assertRaisesMessage(ValidationError, msg) as cm:
+            UniqueConstraintProduct(
+                color=self.p1.color, age=self.p1.age,
+            ).validate_constraints()
+        self.assertEqual(cm.exception.error_dict['__all__'][0].code, "custom_code")

     @skipUnlessDBFeature("supports_partial_indexes")
     def test_model_validation_with_condition(self):
@@ -827,6 +833,14 @@ class Meta:
             NoCodeErrorConstraintModel(name="test").validate_constraints()

     def test_validate(self):
+        constraint = UniqueConstraintProduct._meta.constraints[1]
+        msg = "Custom message"
+        non_unique_product = UniqueConstraintProduct(
+            color=self.p1.color, age=self.p1.age,
+        )
+        with self.assertRaisesMessage(ValidationError, msg) as cm:
+            constraint.validate(UniqueConstraintProduct, non_unique_product)
+        self.assertEqual(cm.exception.code, "custom_code")
         constraint = UniqueConstraintProduct._meta.constraints[0]
         msg = "Unique constraint product with this Name and Color already exists."
         non_unique_product = UniqueConstraintProduct(

That is good to know, thank you for the insight!

I will go ahead and open a bug report as you suggest. I will take a close look at the patch, thank you.

See #35103 (UniqueConstraint message does not use violation_error_message) – Django for the bug report. Thanks Simon for this response and the good starting point for a patch!