Wednesday, May 13, 2026

EF Core 11.0: Temporal Period Properties Backed by CLR Properties

In this post, let's have a look at a small but very welcome improvement to SQL Server Temporal Tables support in EF Core shipping with .NET 11 Preview 4, the ability to map temporal period columns (PeriodStart and PeriodEnd) to regular CLR properties on the entity.

Long time ago I blogged about EF Core 6.0: Introducing Support for SQL Server Temporal Tables, where we saw how to opt an entity into temporal tracking using IsTemporal(). Back then, the period columns were always managed as shadow properties, which meant we had to read them through EF.Property<DateTime>(...) whenever we wanted to project, filter, or order by them. That works, but it isn't great when you actually want to surface those values on the entity itself.

Starting with EF Core 11 Preview 4, we now have new strongly-typed overloads of HasPeriodStart and HasPeriodEnd that accept a lambda expression pointing to a CLR property on the entity. EF Core still wires them up with ValueGenerated.OnAddOrUpdate and BeforeSaveBehavior.Ignore under the hood, so SQL Server's SYSTEM_TIME machinery keeps owning the values, we just get to read them like any other column.

Let's have a look at it in action.

First, the project file. Make sure you are using >= EF Core 11 Preview 4.
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-preview.4.26230.115">
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  <PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="11.0.0-preview.4.26230.115" />
Now the entity. Notice how PeriodStart and PeriodEnd are just plain DateTime properties, no shadow property tricks required.
public class Category
{
    public int Id { get; set; }

    public string Name { get; set; }

    public DateTime PeriodStart { get; set; }

    public DateTime PeriodEnd { get; set; }
}
And here is the DbContext. The interesting bit is inside IsTemporal, where we now pass a property selector to HasPeriodStart and HasPeriodEnd.
public class MyDbContext : DbContext
{
    public DbSet<Category> Categories { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer(@"<ConnectionString>");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Category>()
            .ToTable(c => c.IsTemporal(t =>
            {
                t.HasPeriodStart(x => x.PeriodStart);
                t.HasPeriodEnd(x => x.PeriodEnd);
            }));
    }
}
Now let's add a row, update it, and read the history back. Note that the OrderBy and the projection just use x.PeriodStart and x.PeriodEnd directly, no more EF.Property<DateTime>(x, "PeriodStart").
using var context = new MyDbContext();

context.Database.EnsureDeleted();
context.Database.EnsureCreated();

Category category = new() { Name = "Category A" };
await context.Categories.AddAsync(category);
await context.SaveChangesAsync();

category.Name = "Category A Updated";
context.Categories.Update(category);
await context.SaveChangesAsync();

foreach (var item in await context.Categories
    .TemporalAll()
    .Where(x => x.Id == category.Id)
    .OrderBy(x => x.PeriodStart)
    .Select(x => new
    {
        Category = x,
        x.PeriodStart,
        x.PeriodEnd
    })
    .ToListAsync())
{
    Console.WriteLine($"Name: '{item.Category.Name}', " + $"Start: '{item.PeriodStart}' - End: '{item.PeriodEnd}'.");
}
And the output:
Name: 'Category A', Start: '13/05/2026 9:53:01 am' - End: '13/05/2026 9:53:01 am'.
Name: 'Category A Updated', Start: '13/05/2026 9:53:01 am' - End: '31/12/9999 11:59:59 pm'.
A couple of things worth calling out:
  • EF Core still owns the values. The CLR properties are configured to be ignored on save, so even if you set them in code, SQL Server's SYSTEM_TIME will overwrite them on insert/update.
  • The current row will always have PeriodEnd set to 31/12/9999 11:59:59 pm, that is SQL Server's sentinel for "still active".
  • All the existing temporal query operators: TemporalAll, TemporalAsOf, TemporalFromTo, TemporalBetween, TemporalContainedIn, keep working exactly as before.
Small change, but a really nice quality-of-life improvement for anyone working with temporal tables.

Hope this helps.

Happy Coding.

Regards,
Jaliya