Skip to main content

How to Store an Enum as a String with EF Core and MySQL

· 10 min read
Jagdish Kumawat
Founder @ Dewiride

By default, EF Core saves a C# enum as its number: Sale becomes 2. That works, but anyone reading the table needs the C# code to know what 2 means, and reordering the enum silently changes the meaning of every old row. This guide stores enums as their names instead, first for one property and then for every enum at once, and handles the part most guides skip: the rows that already hold numbers.

Prerequisites​

The Example: Stock Movements​

An inventory app records every change to a product's stock, and an enum says what kind of change it was:

src/Dewiride.Inventory.Data/Entities/StockMovementType.cs
namespace Dewiride.Inventory.Data.Entities;

/// <summary>Why a product's stock changed. Stored as its name, such as "Receipt".</summary>
public enum StockMovementType
{
/// <summary>Stock arrived, for example a delivery from a supplier.</summary>
Receipt,

/// <summary>A person corrected the count, for example after a stocktake.</summary>
Adjustment,

/// <summary>An order took the stock.</summary>
Sale,

/// <summary>A cancelled order put the stock back.</summary>
SaleCancelled,
}

The entity uses it as an ordinary property:

src/Dewiride.Inventory.Data/Entities/StockMovement.cs (excerpt)
public required StockMovementType Type { get; set; }

Step 1: See the Default, a Number​

Without any configuration, the migration EF Core generates for the table makes Type an integer column:

Migrations/…_AddStockMovements.cs (excerpt)
Type = table.Column<int>(type: "int", nullable: false),

And the rows hold numbers, the enum members' positions: Receipt is 0, Sale is 2.

mysql>
SELECT Id, ProductId, Type, QuantityChange, Reference FROM stock_movements;
Output
+----+-----------+------+----------------+-----------+
| Id | ProductId | Type | QuantityChange | Reference |
+----+-----------+------+----------------+-----------+
| 1 | 3 | 0 | 20 | DN-1042 |
| 2 | 3 | 2 | -2 | ORD-17 |
+----+-----------+------+----------------+-----------+
2 rows in set (0.000 sec)

Step 2: Store One Property as a String​

EF Core has a built-in conversion for this. Its value conversions page says that "EF Core will actually do this automatically when the provider type is configured as string using the generic type of HasConversion". In the entity's configuration:

src/Dewiride.Inventory.Data/Configurations/StockMovementConfiguration.cs (excerpt)
builder.Property(m => m.Type)
.HasConversion<string>()
.HasMaxLength(32);
  • HasConversion<string>() stores the member's name, Receipt, and turns it back into the enum when reading. The page lists the converter behind it as "EnumToStringConverter<TEnum> - Enum to string".
  • HasMaxLength(32) gives the column a fixed size. Without it, MySQL would get a longtext column for a word of at most 13 letters.

Create the migration:

Terminal
dotnet ef migrations add StoreMovementTypeAsString --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
Output
Build started...
Build succeeded.
An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy.
Done. To undo this action, use 'ef migrations remove'

The warning is about the type change: the migration turns int into varchar(32). Take it seriously, because of what happens to the rows that are already there.

Step 3: Convert the Rows That Already Hold Numbers​

Applied as generated, the migration only changes the column's type. MySQL turns each number into text, and the old rows end up holding the strings '0' and '2':

Output (SELECT after the generated migration)
+----+------+----------------+-----------+
| Id | Type | QuantityChange | Reference |
+----+------+----------------+-----------+
| 1 | 0 | 20 | DN-1042 |
| 2 | 2 | -2 | ORD-17 |
+----+------+----------------+-----------+

EF Core can still read them: '2' parses back to Sale. But new rows get names, so the table now mixes 2 and Sale for the same thing, which defeats the point. The fix is to convert the values inside the migration. EF Core's docs expect this kind of edit: "you should always review the code and make sure it corresponds to the desired change; in some cases, it is even necessary to do so."

Migrations/…_StoreMovementTypeAsString.cs (edited)
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Type",
table: "stock_movements",
type: "varchar(32)",
maxLength: 32,
nullable: false,
oldClrType: typeof(int),
oldType: "int");

// Rows written before this change hold the enum's number, now as text ('0', '2').
// Turn each number into the member's name, so every row reads the same way.
migrationBuilder.Sql(
"""
UPDATE `stock_movements`
SET `Type` = CASE `Type`
WHEN '0' THEN 'Receipt'
WHEN '1' THEN 'Adjustment'
WHEN '2' THEN 'Sale'
WHEN '3' THEN 'SaleCancelled'
ELSE `Type`
END;
""");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
// Back to numbers first: an int column can't hold 'Receipt'.
migrationBuilder.Sql(
"""
UPDATE `stock_movements`
SET `Type` = CASE `Type`
WHEN 'Receipt' THEN '0'
WHEN 'Adjustment' THEN '1'
WHEN 'Sale' THEN '2'
WHEN 'SaleCancelled' THEN '3'
ELSE `Type`
END;
""");

migrationBuilder.AlterColumn<int>(
name: "Type",
table: "stock_movements",
type: "int",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(32)",
oldMaxLength: 32);
}

The order matters in both directions. In Up, the column must already be text before it can hold names. In Down, the names must become numbers before the column becomes int again; without that, reverting fails with "Incorrect integer value: 'Adjustment' for column 'Type'", which has its own post.

Apply it:

Terminal
dotnet ef database update --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
Output (the lines that matter)
Applying migration '20261103044948_StoreMovementTypeAsString'.
ALTER TABLE `stock_movements` MODIFY `Type` varchar(32) NOT NULL;
UPDATE `stock_movements`
Done.
mysql>
SELECT Id, Type, QuantityChange, Reference FROM stock_movements;
Output
+----+------------+----------------+-----------+
| Id | Type | QuantityChange | Reference |
+----+------------+----------------+-----------+
| 1 | Receipt | 20 | DN-1042 |
| 2 | Sale | -2 | ORD-17 |
| 3 | Adjustment | 1 | RMA-7 |
| 4 | Sale | -1 | NULL |
+----+------------+----------------+-----------+
4 rows in set (0.000 sec)

The mysql client showing the stock_movements Type column first as '0' and '2' after the generated migration, then as Receipt, Sale, Adjustment and Sale after the edited migration converts the numbers to names.

Every row now reads the same way, whether it was written before the change or after.

Step 4: Store Every Enum as a String at Once​

Configuring each enum property separately is easy to forget for the next enum. EF Core can apply the rule to every property of a type, with ConfigureConventions in the DbContext. Its bulk configuration page explains: "EF Core allows the mapping configuration to be specified once for a given CLR type; that configuration is then applied to all properties of that type in the model as they are discovered." And the type can be a base type: "The type specified in a call from ConfigureConventions can be a base type, an interface or a generic type definition." Every enum derives from System.Enum, so this covers all of them:

src/Dewiride.Inventory.Data/InventoryDbContext.cs (excerpt)
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
ArgumentNullException.ThrowIfNull(configurationBuilder);

// Every enum is stored as its name, such as 'Receipt', not as a number, so the
// tables still make sense to someone reading them without the C# code.
configurationBuilder.Properties<Enum>()
.HaveConversion<string>()
.HaveMaxLength(32);
}

The InventoryDbContext ConfigureConventions method in a macOS-style code window, configuring every Enum property to be stored as a string with a maximum length of 32.

With this in place, I removed the per-property configuration from Step 2 and checked that the model was still the same:

Terminal
dotnet ef migrations has-pending-model-changes --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
Output
Build started...
Build succeeded.
No changes have been made to the model since the last migration.

The convention produces exactly the same column, varchar(32), so no migration is needed. Every enum added later is stored by name automatically.

Step 5: Know What Happens with an Unknown Name​

A name column can hold a value the enum doesn't have: a typo from a manual fix, or a member that was renamed. I inserted a row with Refund, which isn't a StockMovementType, and read the table through EF Core. The whole query failed:

Output
System.InvalidOperationException: Cannot convert string value 'Refund' from the database to any value in the mapped 'StockMovementType' enum.

EF Core doesn't guess; it stops. That's safer than silently reading the wrong kind, but it means a renamed enum member breaks every query until the rows are updated. Rename members with a migration that updates the stored names too. Fixed: "Cannot convert string value … to any value in the mapped … enum" covers that error in detail.

Common Mistakes​

MistakeWhat happensDo this instead
Applying the generated migration on a table with dataOld rows hold '0', '2'; new rows hold namesConvert the values in the migration
Converting in Up but not in DownReverting fails with "Incorrect integer value"Write the reverse conversion
HasConversion<string>() without HasMaxLengthA longtext columnAdd HasMaxLength(32) or the convention's HaveMaxLength
Renaming an enum member without a migrationEvery query on the table failsUpdate the stored names in a migration

Troubleshooting​

SymptomLikely causeFix
Cannot convert string value '…' from the database to any value in the mapped '…' enumA row holds a name the enum doesn't haveFix the row, or add the member
The column is still intThe migration wasn't created or applieddotnet ef migrations list shows what's pending
has-pending-model-changes reports changes after adding the conventionThe convention's length differs from the per-property oneUse the same maximum length in both

FAQ​

Does storing names cost much space?​

A few bytes per row: varchar(32) stores only the characters used, plus a length byte. For most tables, readability is worth far more.

Can I store a different text than the member's name?​

Yes, with your own converter, HasConversion(v => …, v => …). Keep the plain names unless you have a reason: they're what every tool shows.

What about MySQL's own ENUM type?​

EF Core's MySQL provider maps HasConversion<string>() to varchar, not to MySQL's ENUM column type. A varchar is simpler to change later: adding a member needs no ALTER TABLE.

Conclusion​

EF Core stores enums as numbers unless you say otherwise. HasConversion<string>().HasMaxLength(32) stores one property by name, and configurationBuilder.Properties<Enum>().HaveConversion<string>().HaveMaxLength(32) in ConfigureConventions does it for every enum. When the table already has rows, edit the migration so Up turns numbers into names and Down turns them back, and remember that a name the enum doesn't know stops every query on that table.

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.