ThrowableEqualsHashCode
Overriding Throwable.equals() or hashCode() is discouraged.

Severity
WARNING

The problem

Throwables should not override equals() or hashCode().

  1. Exceptions are Events, Not Value Objects Philosophically, an exception represents a unique historical event: something went wrong at a specific time, in a specific thread, at a specific line of code. It is not a data container or a value type (like a String or a Money object). Even if two IllegalArgumentExceptions are thrown with the exact same message ("ID cannot be null"), and perhaps even identical stack traces, they represent two distinct failures that happened independently. Treating them as “equal” conceptually conflates two different events.

  2. The Stack Trace Problem When an exception is instantiated (or thrown), Java populates its stack trace via fillInStackTrace().

    • Complexity: Are you going to include the stack trace in your equals() comparison? If you do, comparing arrays of StackTraceElement is computationally expensive. Exceptions can also have causes and suppressed exceptions. This adds expense, too. Also, will all the transitive causes and suppressed exceptions themselves implement equals() the way you want? Plus, causes and suppressed exceptions make exceptions mutable, and mutable objects generally shouldn’t implement equals().
    • Brittleness: If you don’t include the stack trace, you are saying that an exception thrown in ServiceA is equal to an exception thrown in ServiceB just because they share a message or an error code. This masks critical debugging context.
  3. It Hides Bad Architecture The primary reason you would need to override these methods is if you are placing Exceptions into a HashSet, or using them as keys in a HashMap. If you are doing this, you are likely using Exceptions for normal business logic or control flow, which is a known anti-pattern. Exceptions are for exceptional circumstances; they are heavy (because of the stack trace) and slow to generate.

If you find yourself needing to compare exceptions, extract the state into a separate value object.

Instead of this:

public class UserNotFoundException extends RuntimeException {
  private final String userId;
  private final String groupId;

  // Override equals and hashCode based on userId and groupId...
}

Do this: Create a custom Exception that contains a value object (like a Record or a POJO), and compare the value objects instead.

public class UserNotFoundException extends RuntimeException {
  private final ErrorDetails details;

  public UserNotFoundException(ErrorDetails details) {
    super("%s is not a member of %s:".formatted(details.userId(), details.groupId()));
    this.details = details;
  }

  public ErrorDetails getDetails() { return details; }
}

// Compare the details, not the exceptions!
if (e1.getDetails().equals(e2.getDetails())) { ... }

Suppression

Suppress false positives by adding the suppression annotation @SuppressWarnings("ThrowableEqualsHashCode") to the enclosing element.