Fix org.springframework.dao.DataIntegrityViolationException: constraint violation in Spring JPA

intermediateโ˜• Java2026-04-28| Java 11+, Spring Boot 2.x/3.x, Spring Data JPA, Hibernate 5/6, MySQL/PostgreSQL

Error Message

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [users.UK_email]
#java#spring#jpa#hibernate#database#constraint

The scenario

You call userRepository.save(user) and get this in the logs:

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [users.UK_email]
nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement

The stack trace buries the real cause under Hibernate internals. Look past the noise โ€” the meaningful part is constraint [users.UK_email]. That's the unique index on the email column in the users table. You tried to insert or update a row with an email that already exists.

The same error fires for any constraint type: unique, not-null, foreign key, or check constraint. Whatever broke, the constraint name in brackets tells you exactly which one.

Reading the error correctly

Step one isn't fixing anything โ€” it's reading the constraint name. The pattern is always constraint [table.constraint_name] or sometimes just constraint [constraint_name].

Common constraint names and what they mean:

  • UK_email or users_email_key โ€” unique constraint on email
  • FK_orders_user_id โ€” foreign key violation (the referenced row doesn't exist)
  • users_username_not_null โ€” not-null constraint; you sent null for a required field
  • CHK_age_positive โ€” check constraint; the value doesn't satisfy the condition

Once you know the constraint name, find the column in your entity or DDL. Then check what value you were actually trying to persist.

Quick fix: check before saving

For a unique constraint violation โ€” by far the most common case โ€” add an existence check before calling save():

// In your repository
public interface UserRepository extends JpaRepository<User, Long> {
    boolean existsByEmail(String email);
    Optional<User> findByEmail(String email);
}

// In your service
public User createUser(CreateUserRequest request) {
    if (userRepository.existsByEmail(request.getEmail())) {
        throw new DuplicateEmailException("Email already registered: " + request.getEmail());
    }
    return userRepository.save(new User(request));
}

No database-round-trip exception, no 500. The user gets a clean, readable error message instead.

One caveat: two requests landing within the same millisecond can both pass the check and then race to insert. On high-concurrency paths, catch the exception as a fallback too (see below).

Catching and handling the exception properly

Wrap the save call and catch DataIntegrityViolationException. Don't reach for the raw Hibernate exception โ€” Spring wraps it deliberately to keep your code decoupled from the ORM layer.

import org.springframework.dao.DataIntegrityViolationException;

public User createUser(CreateUserRequest request) {
    try {
        return userRepository.save(new User(request));
    } catch (DataIntegrityViolationException ex) {
        String message = ex.getMostSpecificCause().getMessage();
        if (message != null && message.contains("UK_email")) {
            throw new DuplicateEmailException("Email already registered: " + request.getEmail());
        }
        // Different constraint โ€” let it bubble up
        throw ex;
    }
}

Always use getMostSpecificCause().getMessage() to get the underlying SQL error, which contains the constraint name. The top-level exception message varies between Hibernate 5 and 6 โ€” don't parse that one.

Permanent fix: validate at the application layer

Treat the database constraint as the last line of defense, not the first. Bean Validation annotations let Spring catch bad input before the SQL even runs:

// Entity
@Entity
@Table(name = "users", uniqueConstraints = {
    @UniqueConstraint(name = "UK_email", columnNames = "email")
})
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    @NotBlank(message = "Email is required")
    @Email(message = "Email format invalid")
    private String email;

    @Column(nullable = false)
    @NotBlank(message = "Username is required")
    private String username;
}
// DTO / request object
public class CreateUserRequest {
    @NotBlank
    @Email
    private String email;

    @NotBlank
    @Size(min = 3, max = 50)
    private String username;
}
// Controller โ€” @Valid triggers Bean Validation before hitting the service
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(
        @Valid @RequestBody CreateUserRequest request) {
    return ResponseEntity.status(201).body(userService.createUser(request));
}

@Valid on the request body catches a blank or malformed email before the service layer is called at all. No database trip. No DataIntegrityViolationException.

Global exception handler (the clean way)

Catching in every service method gets repetitive fast. A @ControllerAdvice handles constraint violations in one place:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleDataIntegrity(
            DataIntegrityViolationException ex) {
        String cause = ex.getMostSpecificCause().getMessage();
        String userMessage = "A database constraint was violated.";

        if (cause != null && cause.contains("UK_email")) {
            userMessage = "This email address is already registered.";
        } else if (cause != null && cause.contains("FK_orders_user_id")) {
            userMessage = "Referenced user does not exist.";
        }

        return ResponseEntity
            .status(HttpStatus.CONFLICT)
            .body(new ErrorResponse(409, userMessage));
    }
}

Return HTTP 409 Conflict, not 500. A 500 means your server broke. A 409 means the client sent data that conflicts with existing state โ€” entirely different problem, different response.

Foreign key violations

A constraint name like FK_orders_user_id means you're inserting a row that references a parent that doesn't exist. Verify the parent first:

public Order createOrder(CreateOrderRequest request) {
    User user = userRepository.findById(request.getUserId())
        .orElseThrow(() -> new EntityNotFoundException(
            "User not found: " + request.getUserId()));
    Order order = new Order(user, request.getItems());
    return orderRepository.save(order);
}

Verify the fix works

  • Register with a duplicate email โ€” you should get a 409 with a readable message, not a 500.
  • Scan your logs โ€” DataIntegrityViolationException should no longer appear as an unhandled exception.
  • Enable spring.jpa.show-sql=true and confirm the SQL being sent matches what you expect.
  • Write an integration test against a real database โ€” H2 in-memory or Testcontainers, not mocks โ€” to verify constraint behavior end-to-end:
@SpringBootTest
@Transactional
class UserServiceTest {
    @Autowired UserService userService;

    @Test
    void createUser_duplicateEmail_throwsDuplicateEmailException() {
        userService.createUser(new CreateUserRequest("alice@example.com", "alice"));
        assertThrows(DuplicateEmailException.class, () ->
            userService.createUser(new CreateUserRequest("alice@example.com", "alice2"))
        );
    }
}

Tips

Migrating data in bulk and hitting this repeatedly? Audit for duplicates before applying any unique constraint. One query does it:

SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1;

Clean the duplicates first. If you let Flyway or Liquibase run migrations against dirty data, they'll fail at startup with the exact same exception โ€” and the logs won't make it obvious why.

When you don't want raw email addresses showing up in error logs, hashing them is worth the extra step. ToolCraft's Hash Generator generates SHA-256 hashes client-side โ€” nothing leaves the browser โ€” which makes it handy for quick sanity checks during debugging without leaking PII into your log aggregator.

Related Error Notes