Skip to main content

How to Return 409 Conflict for a Duplicate Key with EF Core and MySQL

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

When a unique index refuses a value, EF Core throws an exception, and your API answers 500. But nothing broke: the client asked for something that already exists, and 409 Conflict is the right answer. This guide reads MySQL's error number from the exception, and catches only that error, with a small helper you can reuse.

Prerequisites​

Step 1: See What Happens with No Handling​

Create a category that exists already:

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-5845f1a69cb341950c04084dcf7ace1b-289730e4dd5c381c-00"
}
API log (the lines that matter)
fail: Microsoft.EntityFrameworkCore.Update[10000]
An exception occurred in the database while saving changes for context type 'Dewiride.Inventory.Data.InventoryDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: Could not save changes. Please configure your entity type accordingly.
---> MySql.Data.MySqlClient.MySqlException (0x80004005): Duplicate entry 'cables' for key 'categories.IX_categories_Name'

DbUpdateException says saving failed. The inner MySqlException says why, and it carries MySQL's error number, 1062. The outer message is the generic one Oracle's provider uses for every failed save.

Step 2: Write a Small Helper​

Put it next to the DbContext, so the API project doesn't need to know about MySQL's types:

src/Dewiride.Inventory.Data/DbUpdateExceptionExtensions.cs
using Microsoft.EntityFrameworkCore;
using MySql.Data.MySqlClient;

namespace Dewiride.Inventory.Data;

/// <summary>
/// Says which MySQL error made SaveChanges fail. EF Core wraps the error from MySQL in a
/// DbUpdateException, so the MySqlException with the error number is its inner exception.
/// </summary>
public static class DbUpdateExceptionExtensions
{
/// <summary>MySQL error 1062: a unique index already holds this value.</summary>
public static bool IsDuplicateKey(this DbUpdateException exception) =>
HasMySqlError(exception, MySqlErrorCode.DuplicateKeyEntry);

/// <summary>MySQL error 1451: other rows still point at the row being deleted or changed.</summary>
public static bool IsRowStillReferenced(this DbUpdateException exception) =>
HasMySqlError(exception, MySqlErrorCode.RowIsReferenced2);

/// <summary>MySQL error 1452: a foreign key points at a row that doesn't exist.</summary>
public static bool IsMissingReferencedRow(this DbUpdateException exception) =>
HasMySqlError(exception, MySqlErrorCode.NoReferencedRow2);

private static bool HasMySqlError(DbUpdateException exception, MySqlErrorCode errorCode) =>
exception.InnerException is MySqlException mySqlException
&& mySqlException.Number == (int)errorCode;
}

MySqlErrorCode is an enum in Oracle's connector with names for MySQL's error numbers. Compare the number, not the message, because the message contains values that change with each row.

Step 3: Catch Only That Error​

src/Dewiride.Inventory.Api/Features/Categories/CategoryEndpoints.cs (excerpt)
db.Categories.Add(category);

try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException exception) when (exception.IsDuplicateKey())
{
return TypedResults.Problem(
title: "A category with this name already exists.",
detail: $"Choose a name other than '{category.Name}'. Names that differ only in upper and lower case count as the same name.",
statusCode: StatusCodes.Status409Conflict);
}

catch … when only catches when the condition is true. Any other failure carries on to your general error handling, as it should.

Step 4: See the Result​

Response
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "A category with this name already exists.",
"status": 409,
"detail": "Choose a name other than 'cables'. Names that differ only in upper and lower case count as the same name.",
"traceId": "00-8763aecc999db0e177ad901e42ec07f9-3396f776bfdb43a3-00"
}

A terminal showing the same duplicate request before and after the change: 500 Internal Server Error with the generic problem body, then 409 Conflict with the message A category with this name already exists.

The API's log still shows EF Core's fail: lines for this request, because EF Core logs the failure before your catch runs.

The Same Pattern for Other Errors​

MySQL errorHelperAnswerWhy
1062, duplicate entryIsDuplicateKey409It clashes with what's stored
1451, row still referencedIsRowStillReferenced409It clashes with what's stored
1452, referenced row missingIsMissingReferencedRow400The request points at nothing

Several catch blocks can sit in a row:

src/Dewiride.Inventory.Api/Features/Products/ProductEndpoints.cs (excerpt)
catch (DbUpdateException exception) when (exception.IsDuplicateKey())
{
return DuplicateSku(product.Sku);
}
catch (DbUpdateException exception) when (exception.IsMissingReferencedRow())
{
return UnknownCategory(product.CategoryId);
}

Why Not Check First?​

You could run AnyAsync before inserting. But two requests can both pass that check, and then both insert. The unique index is the only thing that can't be fooled, so you need this catch either way. A check first only adds a query.

Common Mistakes​

MistakeWhat happensDo this instead
catch (DbUpdateException) with no filterEvery failure looks like a duplicateFilter with when
Matching the message textIt breaks when the wording changesCompare the number
Returning MySQL's message to clientsIt reveals index and table namesWrite your own message
Expecting this from ExecuteDeleteAsyncThat throws MySqlException directlySee that post

FAQ​

Where do the error numbers come from?​

The MySQL server error reference lists them, such as "Error number: 1062; Symbol: ER_DUP_ENTRY".

Can I tell which index refused it?​

MySQL's message names it, as categories.IX_categories_Name. If a table has several unique indexes, you can check that text, since there's no separate property for it.

Conclusion​

Read MySqlException.Number from the DbUpdateException's inner exception, put the check in a small extension method, and use catch … when to handle only that error. A duplicate becomes a 409 with a clear message, and real failures still reach your general error handling.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.