EF Core gives you three ways to store an enum: an integer column by default, the member name with HasConversion<string>(), and a native PostgreSQL enum type through Npgsql.
Strings survive a reordered enum, but they cost space and make comparisons and ORDER BY alphabetical.
Native Postgres enums keep compact storage and declared-order sorting, with harder schema evolution.
For most business apps, strings with a max length are the safe default.
By default, EF Core stores your OrderStatus enum as an int.
Everything works, until a teammate alphabetizes the enum members, or inserts a new one in the middle, and every historical row means something else.
Nothing throws, and no migration flags the change.
Draft becomes Cancelled in place.
Storing enums as strings removes that failure mode, but it changes storage size, index behavior, and how ORDER BY and comparisons translate to SQL.
On PostgreSQL there is a third option that gets you most of both.
Option 1: Ints (the Default)
With no configuration, an enum property maps to the provider's integer type:
public enum OrderStatus
{
Draft = 0,
Submitted = 1,
Shipped = 2,
Cancelled = 3
}
public class Order
{
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
}
Pros:
- 4 bytes per value, cheap to index and compare.
OrderBy(o => o.Status)sorts in numeric declaration order, which often matches workflow order.
Cons:
- The mapping exists only in your C# source. The database has
2, and nothing stops an unrelated2from arriving via raw SQL. - Reordering or inserting members renumbers everything after the change. This is how existing rows end up with the wrong status.
- Every debugging session involves a mental lookup table, and every report writer needs a copy of your enum.
If you keep ints, make the numbering explicit and append-only, exactly like the example above. Never rely on implicit values, and treat the numbers as a public contract.
Option 2: Strings
One line converts storage to the member name:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(50);
}
}
Or model-wide, so no enum ever slips through as an int, using the approach from custom model conventions:
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<Enum>()
.HaveConversion<string>()
.HaveMaxLength(50);
}
Always set the max length.
Without it you get nvarchar(max) on SQL Server, which cannot be used as an index key, or an unbounded text column on PostgreSQL.
What you gain:
- Reorder-proof. The stored value is
'Shipped'. Renumbering the C# enum changes nothing in the database. - Self-describing data. Queries in psql or SSMS, log output, and reports all read naturally.
What you pay:
- Comparisons translate to string comparisons.
Where(o => o.Status > OrderStatus.Submitted)becomesWHERE Status > 'Submitted', an alphabetical comparison that has nothing to do with your workflow. ORDER BYis alphabetical.Cancelled, Draft, Shipped, Submitted. If a UI sorts by status, you now need an explicit ranking, either a switch expression translated in the query or a lookup table.- Storage and index size grow.
'Submitted'is 9 characters versus 4 bytes. On a 100-million-row table with an index on status, that is real space, though compression usually blunts it.
After the conversion, only == and != mean what they meant before.
Range checks and sorting need redesign.
This is a general property of value conversions: the conversion applies to values, and operators run in the store type.
A rename is also no longer free.
Rename Submitted to Placed in C# and every existing 'Submitted' row fails to materialize.
Ship the rename together with a data migration:
UPDATE "Orders" SET "Status" = 'Placed' WHERE "Status" = 'Submitted';
Option 3: Native PostgreSQL Enums
PostgreSQL has native enum types, and Npgsql maps CLR enums onto them. You get compact storage (4 bytes), readable query output, database-side validation of allowed values, and sorting by the enum's declared order.
Register the enum in two places, the data source and the model:
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.MapEnum<OrderStatus>();
var dataSource = dataSourceBuilder.Build();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(dataSource, npgsql => npgsql.MapEnum<OrderStatus>()));
Since Npgsql 9, MapEnum inside UseNpgsql also registers the type with the model, and the migration creates it:
CREATE TYPE order_status AS ENUM ('draft', 'submitted', 'shipped', 'cancelled');
The catch is schema evolution.
Adding a value is easy (ALTER TYPE order_status ADD VALUE 'refunded'), but it cannot run inside a transaction on older Postgres versions, which can complicate migration tooling.
Renaming a value works in place (ALTER TYPE order_status RENAME VALUE 'submitted' TO 'placed'), but removing one means creating a new type and rewriting the column.
Native enums are the right call for stable sets, and a poor one for anything still churning.
If you are already on Postgres for other reasons (I made the broader case in PostgreSQL vs SQL Server for .NET developers), native enums are worth a look for your most stable, most queried status columns.
Which One Should You Pick?
- Default: strings with a max length, applied as a model-wide convention. Business apps refactor enums far more often than they hit enum-column storage limits, and readable stored values help in every incident investigation.
- Ints when the table is huge, the enum is stable and explicitly numbered, and the column is heavily indexed. Also when range semantics (
>=) map to the numeric order and you want them translated. - Native Postgres enums for stable vocabularies on Postgres-committed teams: order status in a mature domain, ISO-like code sets, severity levels.
Whatever you choose, be careful with [Flags] enums in mapped properties.
String conversion stores combinations as comma-separated names like 'Read, Write', which are painful to query, and native Postgres enums cannot represent combinations at all.
If you must persist flags, keep the int mapping, or model the flags as separate boolean columns or a JSON column instead.
Summary
With the default int mapping, reordering the enum changes the meaning of existing data without any error.
HasConversion<string>(), ideally as a model-wide convention, prevents that, but comparisons and ORDER BY then operate on names instead of declaration order, so only equality keeps its meaning.
On PostgreSQL, native enum types recover compact storage, declared-order sorting, and database-side validation, at the cost of heavier schema evolution.
Choose the mapping per column, and if you keep ints, number the members explicitly.
Frequently Asked Questions
How do I store an enum as a string in EF Core?
Configure the property with HasConversion<string>(), or apply it model-wide in ConfigureConventions with configurationBuilder.Properties<Enum>().HaveConversion<string>(). Add a max length so the column is not unbounded.
Should enums be stored as strings or ints?
Ints are compact and sort in declaration order, but existing rows change meaning without any error if the enum is reordered or renumbered. Strings are readable and survive reordering but use more space and compare alphabetically. For most business apps the safety of strings outweighs the size cost.
Why is ORDER BY wrong after converting an enum to string?
Once the column is a string, the database sorts alphabetically, not by the numeric enum order. OrderBy on the enum property translates to an alphabetical sort of the stored names, which usually differs from the declaration order.
Does PostgreSQL support native enum types with EF Core?
Yes. Npgsql maps CLR enums to PostgreSQL enum types. Register the enum on NpgsqlDataSourceBuilder or with MapEnum in UseNpgsql, and EF Core migrations create the Postgres enum type. Values are stored compactly and readable in queries.
What happens if I rename an enum member stored as a string?
Existing rows keep the old name, and materialization throws when EF Core cannot parse it into the CLR enum. A rename requires a data migration that updates the stored values.



