Identity columns and sequences generate the key during the INSERT, so the id exists only after SaveChanges returns.
HiLo reserves a block of ids from a sequence and assigns them client-side, so an entity has its id the moment you Add it.
Use identity by default, and HiLo when your code needs the id before the save.
Sequences match identity's timing but let several tables share one id range.
You build an aggregate in memory: an Order with ten OrderLine children.
With identity columns, none of those objects has a real id until SaveChanges returns, because the database generates the value during the insert.
Most of the time EF Core hides this from you. But the moment you need the id before saving (for an outbox message, a domain event, a log line, a reference from an object EF does not manage), the default strategy becomes a constraint. Identity, sequence, and HiLo differ in one dimension more than any other: when the id becomes known.
Identity: The Default
On SQL Server, an int or long key maps to an IDENTITY column by default.
On PostgreSQL, Npgsql uses GENERATED BY DEFAULT AS IDENTITY:
public class Order
{
public long Id { get; set; } // identity by convention
public List<OrderLine> Lines { get; set; } = [];
}
The id is born inside the INSERT.
EF Core appends a RETURNING / OUTPUT clause to read it back, then runs relationship fixup: it patches the real ids into tracked children's foreign keys and inserts them in dependency order.
That machinery is why identity feels free. It also has consequences:
- Before
SaveChanges,order.Idis0. Temporary negative ids exist only inside the change tracker. - Inserts of parent-child graphs must be ordered (parents first), constraining how EF batches statements.
- Every insert round trip carries the overhead of returning generated values.
For most CRUD workloads, none of this matters.
It starts to matter in outbox and event patterns, where you want to serialize an event containing order.Id in the same unit of work, ideally without contorting your code to run after the save. The transactional outbox gets much cleaner when ids exist up front.
Sequence: Identity Timing, More Flexibility
A sequence is a standalone database object, decoupled from any table. EF Core 7+ supports it directly on SQL Server:
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.UseSequence("OrderIds");
The migration creates the sequence and gives the key column a default constraint of NEXT VALUE FOR [OrderIds], so the database still generates the value during the insert and EF Core reads it back, exactly like identity.
A sequence alone does not give you ids any earlier.
What it does give you:
- Shared ranges. Multiple tables can draw from one sequence, guaranteeing ids unique across tables (useful for table-per-concrete-type inheritance mappings).
- No identity semantics on the column. Plain inserts with explicit ids, no
IDENTITY_INSERTdance when migrating data. - Tunable caching on the database side (
CACHE 50) to cut sequence round trips.
Think of sequences as the infrastructure HiLo builds on.
HiLo: Ids Before SaveChanges
HiLo splits id generation between database and client. The database sequence hands out high values; the application turns each high value into a block of ids and assigns the low values itself.
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.UseHiLo("OrderHiLo");
modelBuilder.Entity<OrderLine>()
.Property(l => l.Id)
.UseHiLo("OrderLineHiLo");
The migration creates sequences with an increment of 10 (the default block size).
When you Add the first entity, EF Core fetches one sequence value and now owns ids, say, 41 through 50, assignable with zero database contact.
The payoff is immediate and visible:
var order = new Order();
context.Orders.Add(order);
Console.WriteLine(order.Id); // real, final id. SaveChanges has NOT run.
var outboxMessage = OutboxMessage.From(
new OrderCreatedEvent(order.Id)); // safe: the id is real
context.Add(outboxMessage);
await context.SaveChangesAsync();
Two things just became possible:
- Foreign keys on unsaved graphs. You can wire up references between new objects by id, not just by navigation property, including references from things EF does not track (serialized events, cache keys, messages).
- Leaner batch inserts. Since ids are known, EF Core sends plain inserts without needing generated keys back, and insert ordering constraints relax. On graphs of thousands of new rows this measurably reduces save time, though for true bulk loads
SqlBulkCopy-style APIs still win by an order of magnitude, as I showed in fast SQL bulk inserts.
The costs are mostly aesthetic and operational:
- Gaps. An app instance that reserved ids 41-50 and restarted after using 41 discards nine ids. Sequences also produce gaps on rollback. If anyone in your organization believes invoice numbers must be gap-free, ids are the wrong place for that requirement anyway; model document numbers separately.
- Block size tuning. Increment 10 means a sequence round trip every 10 inserts per instance. High-throughput insert paths want a bigger block; you set it by configuring the sequence's increment and matching it in
UseHiLo's sequence definition. - Provider support. SQL Server and PostgreSQL support HiLo well through their EF providers.
What About GUIDs?
Client-generated GUIDs solve the same "id before save" problem with zero database coordination:
public class Order
{
public Guid Id { get; set; } = Guid.CreateVersion7();
}
Random Version 4 GUIDs fragment clustered indexes badly, which is the historical argument for HiLo.
.NET 9's Guid.CreateVersion7() produces time-ordered values that insert nearly sequentially, removing most of that objection.
I compared the index behavior in detail in GUID v7 in .NET.
The remaining tradeoff is size and ergonomics: 16 bytes versus 8, and integer ids stay friendlier in URLs, logs, and support conversations. If you were reaching for HiLo purely to get ids before save, UUIDv7 is the simpler modern answer. If you want small sequential integers and early ids, HiLo remains the only game in town.
Choosing
- Default CRUD app, ids never needed pre-save: identity. Zero configuration, everyone understands it.
- Ids needed before
SaveChanges(outbox, domain events, cross-aggregate references): HiLo for integer keys, UUIDv7 for GUID keys. - Heavy graph inserts through EF Core: HiLo; it strips the returning overhead and ordering constraints. Combine with the batching guidance from optimizing bulk database updates.
- Unique ids across several tables, data migrations with explicit ids: sequence.
- Distributed id generation across services: none of these; use UUIDv7 or an id service. Database-coordinated strategies stop at the database boundary.
One migration note: switching strategies on an existing table is a real schema change (dropping identity, creating sequences, seeding the sequence past the current max id). Plan it like any other risky migration, and rehearse against a production-sized copy.
Summary
The three strategies answer one question differently: when do you learn the id?
Identity and sequences answer "after the insert", and HiLo answers "the moment you Add the entity", because the application owns a reserved block of ids.
That timing difference is not a micro-optimization.
Ids known before SaveChanges are what make outbox messages, domain events, and references across unsaved graphs clean to implement, and they let EF Core batch inserts without reading keys back.
Use identity until you feel the constraint. When you do, reach for HiLo if you value compact integer keys, or UUIDv7 if you value zero coordination. Both give you the id exactly when the interesting patterns need it: before the save.
Frequently Asked Questions
What is the HiLo pattern in EF Core?
HiLo reserves a block of ids from a database sequence (the high value) and assigns individual ids client-side (the low values). EF Core implements it with UseHiLo. Entities get real ids when added to the context, before SaveChanges runs.
What is the difference between identity and sequence key generation?
An identity column is owned by one table and generates the value during the insert. A sequence is a standalone database object that the key column draws from through a default constraint, so the timing matches identity, but multiple tables can share one sequence and you can still insert rows with explicit ids.
Why are my ids not sequential with HiLo?
Each application instance reserves a block of ids, and unused ids from a block are discarded on restart. Gaps are normal with HiLo, and also with identity columns after rollbacks. Never treat database-generated ids as gap-free.
Does HiLo make inserts faster?
It removes the need for the database to return generated keys during the insert, so EF Core can send plain parameterized inserts in a batch without reading ids back. For large graphs of new entities the difference is measurable, though bulk copy APIs remain much faster for pure bulk loads.
Should I just use GUIDs instead?
Client-generated GUIDs also give you ids before saving without a database round trip. Random GUIDs fragment indexes, but Version 7 GUIDs are time-ordered and largely fix that. If you do not need small integer keys, UUIDv7 is a strong alternative to HiLo.



