Chapter 04 · Keys, Indexes, Property Semantics, Value Generation, and Shadow State
Generated Values, Identity/Sequences, Default Values, Computed Columns, and Temporary Keys
Trace temporary, application-generated, default, computed, identity, sequence, and store-generated values so SaveChanges behavior is predictable across providers.
Learning outcomes
After identity and indexes, ServiceHub must decide who owns each
value. Id may be database-generated;
OpenedUtc is application-supplied; audit timestamps
may use database defaults; computed columns may be read back;
sequences may exist only on some engines. EF's
ValueGenerated... metadata describes
expectations—it does not invent a database generation mechanism
for every type/provider.
Distinguish application-supplied values, temporary EF values, database defaults, computed values, identity/autoincrement, sequences, and HiLo.
Inspect IsTemporary, ValueGenerated, BeforeSaveBehavior, and AfterSaveBehavior metadata instead of assuming generated-value behavior.
Use SQLite-compatible defaults/computed columns in a disposable lab while keeping SQL Server/PostgreSQL sequence examples provider-gated.
Explain how store-generated values flow back into tracked entities after SaveChanges.
Diagnose the wrong assumption that ValueGeneratedOnAdd alone guarantees the database knows how to generate a value.
1. Generation has three separate questions
| Question | Example |
|---|---|
| Who chooses the value? | Application constructor, EF temporary generator, database default/identity/computed expression |
| When is it chosen? | Before insert, during insert, or after an update |
| How does EF learn the final value? | Client already knows it, provider returns it, or EF issues provider-specific retrieval |
Do not collapse these questions into one
ValueGeneratedOnAdd() call. Provider conventions
decide many behaviors, and a migration must create the necessary
database mechanism.
2. Integer primary keys are commonly generated on add
For relational providers, a non-composite integer primary key is
commonly configured for generation on add. In EF Core 10, the
Microsoft SQLite provider configures eligible numeric
generated-on-add primary keys with SQLite
AUTOINCREMENT by convention; its provider
documentation also explains how to disable that strategy and
fall back to default ROWID behavior when value reuse is
acceptable. SQL Server commonly uses IDENTITY; PostgreSQL can
use identity/sequence-backed strategies. Similar EF metadata
therefore maps to different physical mechanisms.
var workOrder = new WorkOrder( "WO-2026-000201", "Contoso Pump Station", "Inspect cavitation alarm", WorkOrderPriority.High, DateTimeOffset.Parse("2026-08-27T08:00:00Z"));db.Add(workOrder);var idEntry = db.Entry(workOrder).Property(x => x.Id);Console.WriteLine($"Before save entity Id: {workOrder.Id}");Console.WriteLine($"Tracker current: {idEntry.CurrentValue}");Console.WriteLine($"Temporary: {idEntry.IsTemporary}");await db.SaveChangesAsync(ct);Console.WriteLine($"After save entity Id: {workOrder.Id}");Console.WriteLine($"Temporary: {idEntry.IsTemporary}");
Do not hard-code an expected temporary integer such as
-2147482647; generator details are
implementation/version dependent. The stable evidence is
IsTemporary before/after persistence and the final
key propagated to the entity.
3. Database defaults apply when the insert omits a value
Add a CreatedUtc infrastructure value to the EF
model as a shadow DateTime property normalized to
UTC. The SQLite provider specifically recommends
DateTime rather than
DateTimeOffset when comparison/ordering is
required. In this lab, CURRENT_TIMESTAMP is the
database expression, so EF should omit the column when the value
is generated on add and the store should supply it.
builder.Property<DateTime>("CreatedUtc") .HasColumnName("created_utc") .HasDefaultValueSql("CURRENT_TIMESTAMP") .ValueGeneratedOnAdd();
The SQL fragment is database syntax. SQL Server, PostgreSQL, MySQL/MariaDB, and Oracle have different time functions/types/precision semantics. If cross-provider behavior matters, test each engine rather than copying a default expression.
await db.SaveChangesAsync(ct);var created = db.Entry(workOrder) .Property<DateTime>("CreatedUtc") .CurrentValue;Console.WriteLine($"CreatedUtc from store: {created:O}");
4. Computed columns are generated from other stored values
A computed value differs from a default. A default is normally chosen when a row is inserted without a value. A computed column derives its value from an expression and may be virtual or stored depending on the database.
builder.Property<string>("SearchLabel") .HasColumnName("search_label") .HasComputedColumnSql( "customer_name || ' | ' || summary", stored: true);
This is intentionally SQLite-specific SQL. It gives the chapter a reproducible local observation path without pretending the expression is portable. If the production provider changes, the migration must use that provider's expression and supported computed/generated-column semantics.
5. ValueGeneratedOnAdd/OnAddOrUpdate describe expectations, not magic
builder.Property<DateTime>("CreatedUtc") .ValueGeneratedOnAdd();builder.Property<string>("SearchLabel") .ValueGeneratedOnAddOrUpdate();
Those calls tell EF when it should expect generated values. They
do not, by themselves, define how a
DateTimeOffset or string is generated in the
database. Use a default, computed expression, provider-specific
value-generation strategy, trigger, or another documented
mechanism.
If model metadata says a value is generated but the database has no mechanism to generate it, inserts can store null/default values, fail constraints, or leave EF with incorrect assumptions. Always inspect migration DDL and real insert behavior.
6. Sequences and HiLo are provider capabilities, not SQLite baseline features
SQLite does not expose general database sequences like SQL Server/PostgreSQL/Oracle. Do not make a sequence mandatory in this course's local SQLite lab. The following is a provider-specific design example for an engine that supports sequences.
// Provider-gated example; do not apply to the SQLite baseline.modelBuilder.HasSequence<long>("work_order_numbers") .StartsAt(100000) .IncrementsBy(10);// Provider-specific default SQL is then required, e.g. SQL Server NEXT VALUE FOR.// Verify exact syntax for the target provider before generating migrations.
HiLo is another strategy where blocks/high values are allocated so clients can generate several keys with fewer database round trips. The tradeoff includes gaps and provider-specific implementation. Do not choose it solely because it sounds “faster.”
7. Inspect generation and save-behavior metadata
var entity = db.Model.FindEntityType(typeof(WorkOrder))!;foreach (var property in entity.GetProperties()){ Console.WriteLine(property.Name); Console.WriteLine($" ValueGenerated: {property.ValueGenerated}"); Console.WriteLine($" BeforeSave: {property.GetBeforeSaveBehavior()}"); Console.WriteLine($" AfterSave: {property.GetAfterSaveBehavior()}"); Console.WriteLine($" Concurrency: {property.IsConcurrencyToken}");}
PropertySaveBehavior can be Save,
Ignore, or Throw. These settings
influence whether explicit/changed values are sent before or
after saving. They are advanced model semantics; do not mutate
them casually to force EF around a database-generated property.
8. Deliberately wrong approach: non-default generated key means “insert exactly this ID”
A frequent disconnected-data bug is to construct an object with
a non-default value in a database-generated key and then expect
EF to treat it as a brand-new row. EF key conventions and APIs
can interpret non-default generated keys as existing identity,
especially when attaching/updating graphs. The outcome can be an
unexpected UPDATE, an insert conflict, or state
that does not match developer intent.
// Simplified anti-pattern: incoming DTO carries a database-generated Id.var detached = MapIncomingWorkOrder(request); // detached.Id == 42db.Update(detached); // Marks an existing identity for update-oriented persistence.await db.SaveChangesAsync(ct);
The repair is to define separate create/update contracts. New entities should not receive an arbitrary database-generated primary key from untrusted input. Existing updates should load/attach the intended identity explicitly and validate authorization/concurrency.
9. Hands-on lab: trace values before and after SaveChanges
- Keep
WorkOrder.Idgenerated on add. -
Add shadow
CreatedUtcwith SQLiteCURRENT_TIMESTAMP. -
Add the computed shadow
SearchLabelusing the SQLite concatenation expression. - Generate and review the migration; verify which columns have defaults/generated SQL.
-
Create a new work order and print
Id,IsTemporary,CreatedUtc, andSearchLabelbefore saving. -
Call
SaveChangesAsynconce, then print the same values. - Inspect the generated SQL/logs and SQLite schema.
-
Change
Summary, save again, and verify the computed label changes if the provider returns/reloads it as configured. - Run the metadata save-behavior probe.
- Reset the disposable database after the experiment if you do not keep the generated columns for Chapter 04 continuation.
Verification checklist
- No exact temporary-key number is asserted.
- The migration, not only EF metadata, contains the actual database generation mechanism.
- Default and computed generation are explained separately.
- Sequence/HiLo examples are explicitly non-SQLite/provider-gated.
- No create API accepts arbitrary database-generated IDs from untrusted input.
Check your understanding
- What does ValueGeneratedOnAdd guarantee about database DDL?
- Why should you inspect IsTemporary instead of hard-coding a temporary key value?
- How does a default differ from a computed column?
- Why is a sequence example provider-gated in this course?
- What can happen when a detached object carries a non-default generated primary key?
- What do BeforeSaveBehavior and AfterSaveBehavior describe?
Review the answers
By itself it does not create a universal generation mechanism; provider conventions/defaults/computed expressions/etc. must supply the actual strategy.
Temporary generator values are implementation details; IsTemporary is the semantic signal EF exposes.
A default supplies a value when inserting without one; a computed column derives a value from other state/expression and may be generated on insert/update.
SQLite has no general sequence facility equivalent to engines such as SQL Server/PostgreSQL/Oracle.
EF may treat that identity as existing depending on how the graph is attached/updated, producing unexpected updates or conflicts.
Whether values are saved, ignored, or rejected before the first save and after an entity already exists.
10. Production judgment and bridge
Make value ownership explicit. Application-generated identifiers simplify offline workflows but have their own ordering/index costs; database-generated keys centralize allocation but add store round trips/semantics; defaults and computed columns must be reviewed in migration DDL; sequences/HiLo are provider-specific tools.
Lesson 4 follows the shadow values introduced here. You will see
where shadow data lives, how EF.Property reaches it
in translated queries, why no-tracking entities cannot expose
shadow state after materialization, and where
indexer/property-bag patterns are useful or dangerous.
Authoritative references
- Generated Values - EF Core — defaults, computed columns, generated keys, and explicit value-generation metadata
- Keys - EF Core — generated key values and non-default-key cautions
- PropertySaveBehavior — Save/Ignore/Throw metadata semantics
- SQLite Value Generation — EF Core 10 SQLite AUTOINCREMENT/default value-generation behavior
- .NET 10 Downloads — current runtime and SDK checkpoint