Introduction
Temporal data is useful whenever an application needs to answer a simple but important question: "What did this data look like at a particular point in time?"
In a normal table, an UPDATE replaces the previous value. Once that happens, the application may have no easy way to determine what the previous state looked like.
Temporal tables solve this problem by keeping historical versions of rows.
PostgreSQL 19 introduces temporal constraints and temporal table functionality that makes this area particularly interesting for developers working with PostgreSQL and Entity Framework Core. PostgreSQL 19 Beta 3 also includes fixes related to the new FOR PORTION OF temporal-table syntax, making the beta a useful release to test against before relying on the feature in production.
For .NET developers, however, there is an important distinction to understand: PostgreSQL support for temporal functionality and EF Core's provider-level support are separate concerns. A database feature being available in PostgreSQL does not automatically mean EF Core can model and manage it with the same APIs developers may know from SQL Server.
That makes PostgreSQL 19 temporal tables an excellent feature to explore carefully.
What Are Temporal Tables?
A temporal table keeps information about how rows changed over time.
Imagine an employee table:
EmployeeId | Name | Department | Salary
-----------|------------|------------|--------
101 | Rahul | Engineering| 85000
Later, the employee moves to another department:
EmployeeId | Name | Department | Salary
-----------|------------|------------|--------
101 | Rahul | Management | 90000
A traditional table only shows the current state.
A temporal design can preserve the history:
EmployeeId | Department | Salary | Valid From | Valid To
-----------|-------------|--------|------------|---------
101 | Engineering | 85000 | Jan 1 | Jun 30
101 | Management | 90000 | Jul 1 | Open
Now the application can answer questions such as:
What department was the employee in last month?
What was the salary before the change?
Which records were valid during a particular period?
Did two records overlap in time?
What was the state of the system at a historical point?
This is especially useful for auditing, financial systems, subscriptions, contracts, inventory, pricing, and other domains where historical state matters.
PostgreSQL 19 Temporal Support
PostgreSQL 19 adds temporal constraints based around SQL-standard temporal functionality.
One important addition is support for temporal primary and unique constraints using WITHOUT OVERLAPS.
A simplified example looks like:
CREATE TABLE employee_assignments
(
employee_id integer,
valid_period daterange NOT NULL,
CONSTRAINT employee_assignment_pk
PRIMARY KEY (employee_id, valid_period WITHOUT OVERLAPS)
);
The idea is that PostgreSQL can enforce that the time periods associated with a particular key do not overlap.
For example:
Employee 101
-------------------------------
Jan 01 - Mar 31 Valid
Apr 01 - Jun 30 Valid
Jul 01 - Sep 30 Valid
But this should not be allowed:
Employee 101
-------------------------------
Jan 01 - Jun 30 Valid
Apr 01 - Aug 31 Overlap
Temporal constraints move an important part of temporal data correctness into the database.
Why Temporal Constraints Matter
Without database enforcement, an application may have to perform a check before inserting a new period.
For example:
SELECT 1
FROM employee_assignments
WHERE employee_id = 101
AND valid_period && daterange(
DATE '2026-04-01',
DATE '2026-08-31',
'[]'
);
The application could then reject the new record if an overlap exists.
The problem is that application-level checks can introduce race conditions.
Two requests could execute the check at almost the same time:
Request A Request B
--------- ---------
Check overlap Check overlap
No overlap No overlap
Insert Insert
\ /
\ /
Invalid overlap
A database constraint is much stronger because PostgreSQL itself becomes responsible for enforcing the rule.
Understanding WITHOUT OVERLAPS
WITHOUT OVERLAPS is designed for temporal keys.
Consider:
CREATE TABLE product_prices
(
product_id integer,
valid_period daterange NOT NULL,
PRIMARY KEY (
product_id,
valid_period WITHOUT OVERLAPS
)
);
This expresses an important business rule:
A product cannot have two overlapping price periods.
For example:
INSERT INTO product_prices
(product_id, valid_period)
VALUES
(10, daterange('2026-01-01', '2026-03-31', '[]'));
A second non-overlapping period is acceptable:
INSERT INTO product_prices
(product_id, valid_period)
VALUES
(10, daterange('2026-04-01', '2026-06-30', '[]'));
But an overlapping period should violate the temporal constraint.
This is much cleaner than implementing all overlap rules manually in application code.
The FOR PORTION OF Syntax
Another important part of PostgreSQL's temporal work is FOR PORTION OF.
It is intended for changing only a particular portion of a row's valid time.
Conceptually, suppose a contract is valid for an entire year:
Contract
Jan ----------------------------- Dec
Valid
The application wants to change the contract only during a smaller period:
Jan ----- Jun ---- Sep ---------- Dec
Updated
The FOR PORTION OF syntax allows an update to target a specific temporal portion rather than treating the entire historical period as one ordinary row update.
This is one of the areas that PostgreSQL 19 Beta 3 specifically addressed with fixes around temporal-table behavior.
Because PostgreSQL 19 is a beta release, this is exactly the kind of feature that should be tested against realistic workloads rather than immediately treated as production-ready functionality.
What Does This Mean for EF Core?
This is where .NET developers need to be careful.
EF Core supports different database providers, and provider capabilities are not identical.
For example, SQL Server has long had EF Core support for SQL Server temporal tables through APIs such as:
modelBuilder.Entity<Employee>()
.ToTable("Employees", b =>
b.IsTemporal());
A developer familiar with this API might reasonably assume that the same model configuration will work with PostgreSQL.
It should not be assumed.
PostgreSQL temporal functionality and EF Core PostgreSQL provider support need to be evaluated separately.
The database may support a feature that the provider does not yet expose through EF Core's high-level model-building APIs.
A Practical EF Core Model
Suppose we have:
public class EmployeeAssignment
{
public int EmployeeId { get; set; }
public string Department { get; set; } = string.Empty;
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
}
A simple EF Core configuration might be:
public class EmployeeAssignmentConfiguration
: IEntityTypeConfiguration<EmployeeAssignment>
{
public void Configure(
EntityTypeBuilder<EmployeeAssignment> builder)
{
builder.HasKey(x => new
{
x.EmployeeId,
x.StartDate,
x.EndDate
});
builder.Property(x => x.Department)
.HasMaxLength(200);
}
}
This models the application entity, but it does not automatically create PostgreSQL's temporal semantics.
That distinction is important.
Using Raw SQL for Database-Specific Features
When a PostgreSQL feature is not directly represented by the EF Core provider's model API, migrations can use database-specific SQL.
For example:
migrationBuilder.Sql("""
ALTER TABLE employee_assignments
ADD CONSTRAINT employee_assignment_pk
PRIMARY KEY (
employee_id,
valid_period WITHOUT OVERLAPS
);
""");
This approach can be useful when the application deliberately depends on PostgreSQL-specific capabilities.
However, it also means the database design is now partly outside EF Core's provider-independent abstraction.
That is not necessarily bad.
It simply means the team needs to document the dependency clearly.
Mapping PostgreSQL Range Types
PostgreSQL range types are particularly useful for temporal data.
For example:
daterange
can represent:
2026-01-01 → 2026-03-31
An EF Core model might represent a period using an appropriate PostgreSQL range mapping supported by the PostgreSQL EF Core provider.
Conceptually:
public class ProductPrice
{
public int ProductId { get; set; }
public NpgsqlRange<DateTime> ValidPeriod { get; set; }
public decimal Price { get; set; }
}
The exact CLR type and mapping should be verified against the version of the PostgreSQL EF Core provider being used.
This is another reason beta testing matters: provider support can evolve independently from the PostgreSQL server itself.
Testing Temporal Behavior
A good temporal-table test suite should not only verify that records can be inserted.
It should test the actual business rules.

Join the conversation! Your thoughts help the community grow.