# Computed Columns in EF Core

> Derived values calculated in C# drift out of sync the moment someone updates the database directly. Computed columns push the calculation into the database itself, so the value is always consistent. Here is how to map them in EF Core, and why the stored vs virtual choice decides whether you can index them.

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

Canonical: https://milanjovanovic.tech/blog/ef-core-computed-columns

A computed column in EF Core is a column whose value the database derives from other columns in the same row, mapped with `HasComputedColumnSql` in your entity configuration.
EF Core treats it as read-only, never sends it in `INSERT` or `UPDATE`, and reads it back after `SaveChanges`.
Pass `stored: true` when the value is filtered, sorted, or joined, so it is written to disk and can be indexed.

You have an `OrderItem` with `UnitPrice` and `Quantity`, and half your queries need the line total.
Calculating it in C# works until a report queries the table directly, a bulk update skips your domain logic, or someone filters by total in SQL and gets a full table scan.

Computed columns move the calculation into the database schema.
The value cannot drift because nobody writes it, the database derives it.
EF Core maps them with one configuration call, but the `stored` flag you pass decides whether the column is just a convenience or something you can index and filter on cheaply.

## Mapping a Computed Column

`HasComputedColumnSql` takes a SQL expression and wires the property as database-generated:

```csharp
public class OrderItem
{
    public Guid Id { get; set; }
    public decimal UnitPrice { get; set; }
    public int Quantity { get; set; }
    public decimal LineTotal { get; private set; }
}

public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
    public void Configure(EntityTypeBuilder<OrderItem> builder)
    {
        builder.Property(oi => oi.LineTotal)
            .HasComputedColumnSql("[UnitPrice] * [Quantity]", stored: true);
    }
}
```

The migration produces a column the database owns:

```sql
ALTER TABLE [OrderItems] ADD [LineTotal] AS ([UnitPrice] * [Quantity]) PERSISTED;
```

A few things happen implicitly:

- The property becomes `ValueGeneratedOnAddOrUpdate`. EF Core never includes it in `INSERT` or `UPDATE` statements.
- After `SaveChanges`, EF Core reads the value back, so the in-memory entity is up to date without a manual reload.
- The `private set` keeps your own code honest. Assigning it does nothing useful, so make it impossible.

Note the SQL is provider-specific.
`[UnitPrice]` is SQL Server quoting; on PostgreSQL you would write `"UnitPrice" * "Quantity"`.
If you support multiple providers, this is one of the places the abstraction leaks.

## Stored vs Virtual: The Decision That Matters

The `stored` parameter is the whole game:

- **Virtual (default, `stored: false`)**: the expression runs every time the row is read. Zero storage cost, and the value is recalculated even if you change the expression without rewriting rows. But every read pays the compute, and on SQL Server you generally cannot index it unless the expression is deterministic and precise.
- **Stored / persisted (`stored: true`)**: the value is calculated on insert and update and written to disk. Reads are as cheap as any column, and you can index it.

My default is `stored: true` for anything used in a `WHERE`, `ORDER BY`, or `JOIN`.
The moment you filter on a virtual computed column, the database evaluates the expression for every candidate row.
That is a scan, exactly what I profile for in [**EF Core query performance mistakes**](https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes).

Indexing a stored column is just a normal index:

```csharp
builder.HasIndex(oi => oi.LineTotal);
```

One PostgreSQL-specific gotcha: before PostgreSQL 18, generated columns are **always stored**, and Npgsql throws at migration time if you leave `stored: false` (the default).
PostgreSQL 18 adds virtual generated columns (the Npgsql provider supports them from version 10), but they cannot be indexed, so for anything you filter on the call stays:

```csharp
builder.Property(oi => oi.LineTotal)
    .HasComputedColumnSql("\"UnitPrice\" * \"Quantity\"", stored: true);
```

## A More Useful Example: Searchable Full Names

Concatenations are where computed columns earn their keep, because they make prefix searches and sorting trivial:

```csharp
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.Property(c => c.FullName)
            .HasComputedColumnSql("[FirstName] + ' ' + [LastName]", stored: true)
            .HasMaxLength(201);

        builder.HasIndex(c => c.FullName);
    }
}
```

Now this LINQ query uses the index instead of concatenating per row:

```csharp
var customers = await context.Customers
    .Where(c => c.FullName.StartsWith(prefix))
    .OrderBy(c => c.FullName)
    .Take(20)
    .ToListAsync();
```

Without the computed column, `c.FirstName + " " + c.LastName` in the `Where` clause translates to an expression the database evaluates row by row.
Same result, very different query plan.

Another PostgreSQL pattern is a generated `tsvector` column for search, covered in **PostgreSQL full-text search with EF Core**.
Same mechanism, bigger payoff.

## What You Cannot Do

Computed columns have hard limits, and hitting them late hurts:

- **No subqueries or other tables.** The expression can only reference columns of the same row. A `TotalOrderValue` that sums child rows is not a computed column; that is a view, a trigger, or an application-maintained value.
- **Deterministic expressions only for persisted columns.** `GETUTCDATE()` or anything nondeterministic cannot be persisted on SQL Server and cannot be a generated column on Postgres at all.
- **EF Core cannot validate your SQL.** The expression is an opaque string. A typo surfaces when the migration runs, not at build time. Test migrations in CI, which is one more reason I like [**migration bundles**](https://milanjovanovic.tech/blog/ef-core-migration-bundles).
- **Changing the expression is a migration.** For stored columns on Postgres and persisted columns on SQL Server, altering the expression means dropping and re-adding the column, and the table rewrite that comes with it. On a large table, plan for it like any other [**risky migration**](https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core).

## Computed Column or C# Property?

Not every derived value belongs in the database.
A plain C# expression-bodied property is simpler when the value never appears in a query:

```csharp
public decimal LineTotal => UnitPrice * Quantity;
```

My decision rule:

![Decision flowchart: a derived value only displayed becomes a C# property, one used in WHERE, ORDER BY or JOIN becomes a stored indexed computed column, and one derived from other rows becomes a view or projection](https://milanjovanovic.tech/blogs/articles/ef-core-computed-columns/computed-column-decision.png)

- **Only displayed, never queried**: C# property. No schema, no migration, no provider-specific SQL.
- **Filtered, sorted, joined, or read by other SQL consumers** (reports, ETL, [**Dapper queries**](https://milanjovanovic.tech/blog/ef-core-vs-dapper), triggers): computed column, stored, probably indexed.
- **Derived from other rows or tables**: neither. Use a view, a projection, or maintain it explicitly with concurrency control.

The worst option is the one teams drift into by accident: a normal column that application code tries to keep in sync.
Every code path that forgets is a data bug, and [**bulk updates**](https://milanjovanovic.tech/blog/what-you-need-to-know-about-ef-core-bulk-updates) with `ExecuteUpdateAsync` will forget, because they bypass your domain logic entirely.
A computed column is immune to that by construction: `ExecuteUpdateAsync` changes `Quantity`, and the database recalculates `LineTotal` in the same statement.

## Summary

Computed columns hand the derivation to the only party that sees every write: the database.
`HasComputedColumnSql` maps them in one line, EF Core treats them as read-only and refreshes them after save, and no code path, not even raw SQL or bulk updates, can make the value inconsistent.

The `stored` flag is the real decision.
Virtual columns cost nothing to store but recompute on every read and mostly cannot be indexed.
Stored columns pay a small write cost and give you indexable, filter-friendly values.
On PostgreSQL, pass `stored: true`: generated columns are always stored before PostgreSQL 18, and the virtual ones PostgreSQL 18 adds cannot be indexed.

If the value is only ever rendered, keep it as a C# property.
The database earns the job the moment the value shows up in a `WHERE` clause.

## Frequently asked questions

### How do I create a computed column in EF Core?

Configure the property with HasComputedColumnSql in your entity configuration, passing the SQL expression as a string. Pass stored: true to persist the value on write instead of computing it on every read.

### What is the difference between stored and virtual computed columns?

A virtual computed column is evaluated every time the row is read and takes no storage. A stored (persisted) column is calculated on insert and update and written to disk, which makes reads cheaper and allows indexing.

### Can I index a computed column?

On SQL Server you can index a persisted computed column, and even some non-persisted ones if the expression is deterministic. On PostgreSQL stored generated columns can be indexed like any regular column, but the virtual generated columns added in PostgreSQL 18 cannot be indexed.

### Can EF Core set the value of a computed column?

No. The database owns the value. EF Core marks the property as ValueGeneratedOnAddOrUpdate, never sends it in INSERT or UPDATE statements, and reads it back after saving.

### Do computed columns work with PostgreSQL and EF Core?

Yes, via generated columns. Pass stored: true to HasComputedColumnSql, since generated columns are always stored before PostgreSQL 18. PostgreSQL 18 adds virtual generated columns, supported by the Npgsql provider starting with version 10, but they cannot be indexed.
