In this post, we are going to learn how to map DateOnly and TimeOnly types to SQL with migration.

To download the source code for this article, you can visit our GitHub repository.

Let’s dive in.

DateOnly and TimeOnly Types in .NET

Prior to .NET 6, we primarily had DateTime and DateTimeOffset struct types for handling date and time. As their names imply, such data types present converged date and time values. But in many scenarios, we want to deal with dates only, without caring about time at all, and vice versa.

For example, when we talk about someone’s birthday, we certainly don’t bother about the time of the day. The same goes for the working hours of an employee – it’s pointless to present such data with a date part since a specific date would be irrelevant.

One possible workaround for such cases might be truncating the time part or date part from DateTime or DateTimeOffset values. In that case, we also need special handling to ensure that the value of the remaining part remains unaffected. Even then, we might end up with erroneous results because DateTime/DateTimeOffset also carries timezone information and is not designed to handle absolute Date and Time.

In the end, such workarounds are somewhat like using double data type to hold integer values. Not just that, these types connote the use of both date and time components together, and using them with only partial values can be misleading.

.NET 6 addresses this concern by introducing DateOnly and TimeOnly structs. This enables us to design our data models just the way we mean to:

public class Employee
{
    public int Id { get; set; }

    public string Name { get; set; } = null!;

    public DateOnly BirthDate { get; set; }

    public TimeOnly WorkStartTime { get; set; }

    public TimeOnly WorkEndTime { get; set; }
}

In this Employee model, the data types of BirthDate, WorkStartTime, and  WorkEndTime are clearly consistent with their purpose.

The SQLite data provider ( Microsoft.Data.Sqlite 6.0) also supports these new types out of the box. As a result, we can seamlessly use such entity models in EF Core with SQLite adapters.

Map DateOnly and TimeOnly Types With SQL Server

Unfortunately, EF Core 7 (and earlier versions) with the SQL Server provider doesn’t play well with these new types.

To see the problem first-hand, let’s prepare a database context including our Employee entity set:

public class AppDbContext : DbContext
{
    public DbSet<Employee> Employees => Set<Employee>();

    protected override void OnConfiguring(DbContextOptionsBuilder builder)
    {
        if (builder.IsConfigured)
           return;
        var configuration = new ConfigurationBuilder()
                            .AddJsonFile("appsettings.json")
                            .Build();
        builder.UseSqlServer(configuration.GetConnectionString("Default"));
    }
}

This is a typical EF Core code-first approach to setting up a DbContext. Inside the OnConfiguring() method we configure the SQL Server database provider with the desired ConnectionString

Now we try to scaffold the initial migration file using Package Manager Console, like so:

PM> add-migration Initial_Create

But we encounter an exception:

System.InvalidOperationException: The property 'Employee.BirthDate' could not be mapped because it is of type 'DateOnly', which is not a supported primitive type or a valid entity type. Either explicitly map this property, or ignore it using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'

Clearly, EF Core 7 does not support the mapping of DateOnly and TimeOnly types to SQL Server columns out of the box.

The good news is, both of these data types are supported in EF Core 8.

But is there any way to achieve a working solution with EF Core 7? Yes, there is!

EF Core 7 Support With Value Converter

Our solution ties to one of the great features of EF Core – support for plugging in a custom value converter that handles conversion from/to stored values. This means that if we can convert value objects of unsupported data types to those of supported data types, we can resolve our problem. In our case, DateTime and TimeSpan are the supported default counterparts of DateOnly and TimeOnly types respectively.

Let’s first implement the converter for DateOnly:

public class DateOnlyConverter : ValueConverter<DateOnly, DateTime>
{
    public DateOnlyConverter() : base(
        dateOnly => dateOnly.ToDateTime(TimeOnly.MinValue), 
        dateTime => DateOnly.FromDateTime(dateTime))
    { }
}

We simply extend the generic ValueConverter<> and supply the necessary conversion functions as constructor arguments. The DateOnly struct ships with some From*/To* routines that come in handy for such back-and-forth conversions.

Next, we need the converter for TimeOnly:

public class TimeOnlyConverter : ValueConverter<TimeOnly, TimeSpan>
{
    public TimeOnlyConverter() : base(
        timeOnly => timeOnly.ToTimeSpan(), 
        timeSpan => TimeOnly.FromTimeSpan(timeSpan))
    { }
}

Similar to DateOnly, TimeOnly also provides necessary routines to implement the conversion from/to TimeSpan.

Register Custom Converters

It’s time to register our custom converters with the context model builder:

public class AppDbContext : DbContext
{
    // omitted for brevity
    protected override void ConfigureConventions(ModelConfigurationBuilder builder)
    {
        base.ConfigureConventions(builder);

        builder.Properties<DateOnly>()
            .HaveConversion<DateOnlyConverter>();

        builder.Properties<TimeOnly>()
            .HaveConversion<TimeOnlyConverter>();
    }
}

By overriding the ConfigureConventions() method, we get access to the configuration builder. Subsequently, we register DateOnlyConverter for all DateOnly properties by using the HaveConversion() configurator method. Similarly, we register TimeOnlyConverter for all TimeOnly properties.

Now we are able to scaffold the migration file:

public partial class InitialCreate : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "Employees",
            columns: table => new
            {
                Id = table.Column<int>(type: "int", nullable: false)
                    .Annotation("SqlServer:Identity", "1, 1"),
                Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
                BirthDate = table.Column<DateTime>(type: "datetime2", nullable: false),
                WorkStartTime = table.Column<TimeSpan>(type: "time", nullable: false),
                WorkEndTime = table.Column<TimeSpan>(type: "time", nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Employees", x => x.Id);
            });
    }
}

Nice, we’ve successfully mapped DateOnly/TimeOnly properties to our database columns. But we are not done yet.

Map DateOnly and TimeOnly to Proper SQL Types

From the scaffolded migration file, we see TimeOnly properties are rightly mapped to their proper SQL equivalent of “time“. But DateOnly properties are mapped as “datetime2” which is an equivalent of DateTime.  This is not what we want. Instead, we want to map it as a “date“:

protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
    base.ConfigureConventions(builder);

    builder.Properties<DateOnly>()
        .HaveConversion<DateOnlyConverter>()
        .HaveColumnType("date");

    builder.Properties<TimeOnly>()
        .HaveConversion<TimeOnlyConverter>();
}

Once again, we configure this inside the ConfigureConventions() method. A chaining call to HaveColumnType("date") from the previous configurator of DateOnly does the job!

As we re-scaffold the migration file:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateTable(
        name: "Employees",
        columns: table => new
        {
            Id = table.Column<int>(type: "int", nullable: false)
                .Annotation("SqlServer:Identity", "1, 1"),
            Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
            BirthDate = table.Column<DateTime>(type: "date", nullable: false),
            WorkStartTime = table.Column<TimeSpan>(type: "time", nullable: false),
            WorkEndTime = table.Column<TimeSpan>(type: "time", nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Employees", x => x.Id);
        });
}

And this time we find BirthDate correctly mapped as a “date” column.

Register Custom Comparer

Our CLR to SQL data mapping is in good shape. But we still have an issue with the underlying change-tracking mechanism of EF Core. In our custom converter, we are transforming a struct (DateOnly) with a few properties to a struct (DateTime) with a higher number of properties. This means redundancy in generic comparison between the unused properties, which actually imposes extra overhead on the change-tracking workflow of EF.

We can avoid this overhead by providing complimentary value comparers:

public class DateOnlyComparer : ValueComparer<DateOnly>
{
    public DateOnlyComparer() : base(
        (x, y) => x.DayNumber == y.DayNumber,
        dateOnly => dateOnly.GetHashCode())
    { }
}

We first come up with a value comparer for DateOnly. Such a comparer naturally expects a comparison predicate. In our case, it’s simple enough to compare the DayNumber property of two DateOnly values. We further enhance it by supplying a hash provider function that simply forwards the hash code from the DateOnly instance.

Similarly, we can implement a comparer for TimeOnly by using the Ticks property:

public class TimeOnlyComparer : ValueComparer<TimeOnly>
{
    public TimeOnlyComparer() : base(
        (x, y) => x.Ticks == y.Ticks,
        timeOnly => timeOnly.GetHashCode())
    { }
}

Let’s modify our AppDbContext class to associate these comparers with our custom converters:

protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
    base.ConfigureConventions(builder);

    builder.Properties<DateOnly>()
        .HaveConversion<DateOnlyConverter, DateOnlyComparer>()
        .HaveColumnType("date");

    builder.Properties<TimeOnly>()
        .HaveConversion<TimeOnlyConverter, TimeOnlyComparer>();
}

We just need to specify the comparer alongside the converter registration.

Testing DbContext With DateOnly and TimeOnly Values

Our AppDbContext is now correctly configured to handle DateOnly and TimeOnly values.

We insert a sample employee entry:

var employee = new Employee
{
    Name = "John Doe",
    BirthDate = new DateOnly(2023, 5, 9),
    WorkStartTime = new TimeOnly(8, 30),
    WorkEndTime = new TimeOnly(4, 30)
};

_context.Employees.Add(employee);
_context.SaveChanges();

And then explore the stored record in the database:

We can see that the BirthDate column has no redundant time part and WorkStartTime/WorkEndTime columns have no redundant date part.

Performance Impact of DateOnly and TimeOnly Conversions

When we use a custom value converter, EF Core needs to invoke the converter to convert the property value to the appropriate database representation when writing to the database, and vice versa when reading from the database. This additional conversion step may introduce some overhead and potentially impact performance. However, such converters typically do not involve computationally expensive operations and thus have negligible impact in general. 

Our DateOnlyConverter is based on two built-in functions – DateOnly.FromDateTime() and ToDateTime(). The DateOnly.FromDateTime() method is optimized to create a DateOnly value from a DateTime by extracting only the date portion and ignoring the time part. On the other hand, ToDateTime() assigns a specific time (e.g., midnight) to the DateOnly value to create a DateTime representation.

Such operations are fairly simple and straightforward. The same applies to TimeOnlyConverter. That means, unless we deal with an exceptionally large number of conversions or perform them in a high-throughput scenario, the impact should be minimal and is unlikely to be a performance bottleneck.

However, in general, it’s a good practice to utilize performance profiling when we use custom value converters.

Conclusion

In this article, we have learned how to map DateOnly and TimeOnly types to SQL Server database using EF Core 7. Though EF Core 7 does not support such mapping out of the box, we are able to do it with a few customizations. Thankfully, support for these data types within the SQL Server provider has arrived as of EF Core 8.