Some failures are cheaper to let the database catch. A unique constraint is the classic one - checking for a duplicate before every insert costs an extra round trip, and it still races with the insert that happens a millisecond later. Letting the database enforce it is faster and actually correct.
The catch is that EF Core reports every one of those failures as the same DbUpdateException, so finding out what went wrong means drilling into a provider-specific inner exception and matching on error numbers.
DbUpdateException is thrown for all of these:
The type tells you nothing about which one happened. To find out, you unwrap InnerException and match on a database error number:
try{await dbContext.SaveChangesAsync(cancellationToken);}catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 }){// Duplicate key - but only if you're on SQL Server}
❌ Figure: Bad example - Provider-specific error numbers leak persistence details into your code
Two things are wrong here. Those magic numbers are SQL Server's, so moving to PostgreSQL means rewriting every handler. And the SqlException type itself is an infrastructure detail that has no business appearing in an application or domain layer - see Do you catch and re-throw exceptions properly?.
EntityFrameworkCore.Exceptions translates provider errors into strongly typed exceptions. Install the package for your provider:
dotnet add package EntityFrameworkCore.Exceptions.SqlServer
Then turn it on where you configure your DbContext:
services.AddDbContext<AppDbContext>(options => options.UseSqlServer(connectionString).UseExceptionProcessor());
✅ Figure: Good example - One line wires up strongly typed exceptions for the whole DbContext
Now the exception type says what happened:
try{await dbContext.SaveChangesAsync(cancellationToken);}catch (UniqueConstraintException){return Error.Conflict(description: $"A hero named '{hero.Name}' already exists");}
✅ Figure: Good example - The exception type carries the meaning, so no error numbers and no provider coupling
The exceptions all inherit from DbUpdateException, so existing catch blocks keep working while you migrate.
| Exception | Thrown when |
UniqueConstraintException | A unique index or constraint is violated |
CannotInsertNullException | Null is written to a non-nullable column |
MaxLengthExceededException | A value is too long for its column |
NumericOverflowException | A number is out of range for its column |
ReferenceConstraintException | A foreign key constraint is violated |
DeadlockException | The database picks the transaction as a deadlock victim |
Each one also exposes ConstraintName, ConstraintProperties, and the schema-qualified table name, which is what you want in a log entry or a retry decision.
Catching a typed exception is a big step up from matching error numbers, but exceptions still shouldn't be how failures travel through your application. Catch at the persistence boundary and convert to a result, as in the Error.Conflict example above - see Do you use the result pattern?.
✅ Video: Stop Dealing with EF Core Exceptions Wrong! - Nick Chapsas (9 min)
ConstraintName or ConstraintProperties.