# Mapping Enums in EF Core: Strings, Ints, and Native Postgres Enums

> EF Core stores enums as ints by default, which breaks the day someone reorders the enum. Storing them as strings survives refactoring, but it silently changes how ORDER BY and comparisons translate to SQL. Here are all three options, including native PostgreSQL enums, and when each one wins.

Published: 2026-08-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/ef-core-enum-mapping

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 silently means something else.

No error, no migration warning.
`Shipped` becomes `Cancelled` in place.

Storing enums as strings removes that failure mode, but it is not free: it changes storage size, index behavior, and, the part almost nobody expects, how `ORDER BY` and comparisons translate to SQL.
On PostgreSQL there is a third option that gets you most of both.
Let's walk through all three.

## Option 1: Ints (the Default)

With no configuration, an enum property maps to the provider's integer type:

```csharp
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 lives only in your C# source. The database has `2`, and nothing stops an unrelated `2` from arriving via raw SQL.
- Reordering or inserting members renumbers everything after the change. This is the silent data corruption scenario.
- 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:

```csharp
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**](https://milanjovanovic.tech/blog/ef-core-custom-conventions):

```csharp
protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<Enum>()
        .HaveConversion<string>()
        .HaveMaxLength(50);
}
```

Always set the max length.
Without it you get `nvarchar(max)` or `text`, which is wasteful and, on SQL Server, hostile to indexing.

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, and this is the part that surprises people:

- **Comparisons translate to string comparisons.** `Where(o => o.Status > OrderStatus.Submitted)` becomes `WHERE Status > 'Submitted'`, an alphabetical comparison that has nothing to do with your workflow. EF Core translates the operator faithfully; the semantics changed underneath it.
- **`ORDER BY` is 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.

The comparison change is worth internalizing: after the conversion, only `==` and `!=` mean what they meant before.
Range checks and sorting need redesign.
This is a general property of [**value conversions**](https://milanjovanovic.tech/blog/value-conversions-ef-core), 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:

```sql
UPDATE "Orders" SET "Status" = 'Placed' WHERE "Status" = 'Submitted';
```

## Option 3: Native PostgreSQL Enums

PostgreSQL has first-class 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:

```csharp
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:

```sql
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**](https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide) tooling.
Removing or renaming a value means creating a new type and rewriting the column.
Native enums are the right call for genuinely 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 self-describing data pays for itself 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 (`>=`) genuinely 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 `'Draft, Submitted'`, 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**](https://milanjovanovic.tech/blog/ef-core-json-columns) instead.

## Summary

The default int mapping is a refactoring landmine: reorder the enum and the data silently changes meaning.
`HasConversion<string>()`, ideally as a model-wide convention, defuses it, but remember what you traded away: comparisons and `ORDER BY` now operate on names, not on your declaration order, so only equality survives the conversion unchanged.

On PostgreSQL, native enum types recover compact storage, declared-order sorting, and database-side validation, at the cost of heavier schema evolution.

Pick per column, but pick consciously.
The worst outcome is the accidental one: an implicit int mapping nobody chose, waiting for the first well-intentioned cleanup of the enum file.

## 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 break silently 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.
