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:
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:
ALTER TABLE [OrderItems] ADD [LineTotal] AS ([UnitPrice] * [Quantity]) PERSISTED;
A few things happen implicitly:
- The property becomes
ValueGeneratedOnAddOrUpdate. EF Core never includes it inINSERTorUPDATEstatements. - After
SaveChanges, EF Core reads the value back, so the in-memory entity is up to date without a manual reload. - The
private setkeeps 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.
Indexing a stored column is just a normal index:
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:
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:
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:
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
TotalOrderValuethat 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.
- 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.
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:
public decimal LineTotal => UnitPrice * Quantity;
My decision rule:
- 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, 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 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.



