← haandev

Time is a property of the column

Not every attribute needs a history, but every attribute with a history needs its own table. Date, Darwen and Lorentzos proved the second half twenty years ago and left it to the schema designer. The engine should keep that table, and once it does, the future can be written down today.

1. The problem: future-dated changes

In mid-December a request arrives from HR: "Everyone's salary goes up 10% from January 1st. Payroll has to run with the new salaries on January 1st, but we need to see them in the system now."

The request is usually met in one of two ways. The first is a job that runs an UPDATE at midnight on January 1st. Until it runs, the new salaries exist nowhere; "we need to see them now" is solved with a spreadsheet. The second is to write every change as a new row with an effective date. ERP systems have done this for decades; in PeopleSoft almost every table carries an EFFDT column in its key. The price is that every query grows this pattern:

WHERE j.effdt = (
    SELECT MAX(j2.effdt) FROM job j2
    WHERE j2.emplid = j.emplid AND j2.effdt <= CURRENT_DATE
)

When the pattern is forgotten in one query, nothing fails; the query shows next year's salary as today's. And the row holds more than the salary: department and title live there too, and every salary change copies them into a new row.

The two solutions represent the future differently. The job holds the future as an action: something to be done when the time comes. The effective-dated row holds it as a fact: something already true, just not yet in effect. An action can fail; a fact can't. The second is the right representation. The problem is that the whole burden of using it falls on the application.

AS AN ACTION · A MIDNIGHT JOBsalary50,00055,000UPDATE at 00:00must run once, on timeDec 15: the new salaries live in a spreadsheetDec 1Dec 15Jan 1Feb 1AS A FACT · AN EFFECTIVE-DATED COLUMNsalary50,00055,000Dec 15: both rows writtenDec 20 reads 50,000Jan 3 reads 55,000on January 1st nothing is written; only the point being read moves
The same change, held two ways. A job is an action that has to happen at midnight; an effective-dated row is a fact that was already true on December 15th and simply comes into effect.

2. Why sixth normal form?

A timeless employee table, employee(id, name, salary, department), is already in fifth normal form. Every attribute depends on the key alone; when a salary changes, one cell changes. There is nothing to fix.

Add time and the table is still in 5NF. The key becomes (id, during), and every attribute depends on that key. But look at Ayşe's three rows:

id name salary department during
42 Ayşe 50,000 Sales [2024-01, 2025-01)
42 Ayşe 55,000 Sales [2025-01, 2025-05)
42 Ayşe 55,000 Marketing [2025-05, ∞)

"Ayşe" is written three times. The fact "her salary has been 55,000 since January 2025" appears in no row; it is split across two, because the department changed in May. Two independent facts, salary and department, are forced to share one interval, and so they cut each other into pieces.

The classical normal forms can't see this repetition, because it doesn't come from a dependency between attributes. The forms from 1NF to 5NF were defined between 1970 and 1979, by Codd and then Fagin, for how attributes depend on each other. The repetition here comes from something else: each attribute has its own lifespan, and the table imposes one lifespan on all of them.

In Temporal Data and the Relational Model (2003), Date, Darwen and Lorentzos defined 6NF for exactly this: a key and at most one other attribute. Ayşe's table splits in three, and each table gets a new row only where its own attribute changes.

ONE ROW PER CHANGE · 5NF WITH TIMEnamesalarydepartmentAyşe50,000SalesAyşe55,000SalesAyşe55,000Marketing3 rows · grey values are repeats · "55,000 since 2025-01" is in no single row2024-012025-012025-05∞ →ONE TABLE PER ATTRIBUTE · 6NFnameAyşesalary50,00055,000departmentSalesMarketing5 rows · each table splits only where its own attribute changes
The top table is in 5NF and still repeats itself: the salary change and the department change cut every row. In 6NF each attribute keeps its own lifespan.

3. The proposal: a column-level declaration

The book left 6NF as a schema design rule: the designer does the decomposition, the designer writes the queries. 6NF applied by hand is the PeopleSoft pattern ten times over, which is why nobody applies it.

My proposal is to move that responsibility into the engine:

CREATE TABLE employee (
    id            bigint PRIMARY KEY,
    name          text NOT NULL,
    salary        numeric EFFECTIVE DATED,
    department_id bigint  EFFECTIVE DATED REFERENCES department
);

EFFECTIVE DATED is a property of a column, like NOT NULL or DEFAULT. The syntax is invented; no engine has it in this form. The user sees a single table with four columns. The engine keeps a separate table for every effective-dated column:

employee                (id, name)
employee$salary         (id, salary, valid daterange)
employee$department_id  (id, department_id, valid daterange)
-- in every side table: no overlapping intervals for the same id

name is timeless and stays in the main table. This is the idea the whole essay rests on: not every attribute needs a history, but every attribute with a history needs its own table. Applying 6NF only to the columns where time actually flows, rather than to the whole schema, is not a step back from the theory. The reason for 6NF was attributes that change at different times; there is no reason to split off an attribute that never changes.

WHAT YOU DECLARE AND QUERYemployeeidKEYnamesalaryEFFECTIVE DATEDdepartment_idEFFECTIVE DATEDone table, four columnsWHAT THE ENGINE STORESemployeeid · nameemployee$salaryid · salary · validemployee$department_idid · department_id · validtimeless columns stay in the main table;in each side table, no two intervals of one id overlap
The declaration changes the storage, not the table the user sees. Every column with a history gets its own table; every column without one stays where it was.

Intervals are half-open: [start, end). "From January 1st until further notice" is written [2027-01-01, ∞); infinity is not a fake date like 9999-12-31 but the absence of an upper bound. There is no NULL in 6NF either: a missing value is a missing row for that point in time.

4. Reading: query planning

The user writes an ordinary query:

SELECT name, salary
FROM employee
WHERE salary > 50000;

It runs at the session's effective point. By default that point is today, but it can be given explicitly: FROM employee AS OF EFFECTIVE DATE '2027-01-15'.

The planner's rule is two sentences:

  1. An effective-dated column in the projection is planned as a LEFT JOIN. If there is no value at that point, the row doesn't disappear; the column comes back empty.
  2. An effective-dated column in the WHERE clause is planned as an EXISTS. The predicate is evaluated on the value in effect at that point.

In both cases the join condition is the same: s.id = e.id AND s.valid @> :asof. This is not a "latest record" lookup but a range containment test, answered by an index on (id, valid) at close to the cost of an equality. The correlated MAX subquery of the PeopleSoft pattern disappears.

The rules are a starting point. The rest is done by transformations the planner already knows:

1 · WHAT YOU WRITESELECT name, salaryFROM employeeWHERE salary > 500002 · WHAT THE RULES PRODUCESELECT e.name, s.salaryFROM employee eLEFT JOIN employee$salary sON s.id = e.idAND s.valid @> :asofWHERE EXISTS (SELECT 1FROM employee$salary s2WHERE s2.id = e.idAND s2.valid @> :asofAND s2.salary > 50000)3 · WHAT THE PLANNER RUNSSELECT e.name, s.salaryFROM employee eJOIN employee$salary sON s.id = e.idAND s.valid @> :asofAND s.salary > 50000an ordinary query, read atthe session's effective pointrule 1: projection → LEFT JOINrule 2: WHERE → EXISTSthe predicate rejects an empty value,so both fold into one INNER JOINand SELECT name FROM employee never touches employee$salary: join elimination
The two rules are only the starting point; they are correct, not fast. Speed comes from transformations every planner already has: outer join simplification and join elimination.

Two edge cases show how the rules have to be written. WHERE salary IS NULL means "no salary on record at that point", and it must be planned as NOT EXISTS, not EXISTS; rule 2 applies only to predicates that reject an empty value. Second: every effective-dated column in a query is read at the same point. It should not be possible to write a query that reads the salary as of January 15th and the department as of today. That is exactly the most common silent bug in hand-written effective dating: the date condition is added to one join and forgotten on the next.

5. Writing: future-dated updates

December's request becomes a single statement:

UPDATE employee
SET salary = salary * 1.10
EFFECTIVE FROM DATE '2027-01-01';

For each employee the engine finds the interval that contains 2027-01-01, splits it in two, and writes the new value into the right-hand part. Department, title and name are not copied; only employee$salary changes.

This simple-looking operation hides two decisions, and both have to be made explicitly.

Which date's salary is the salary on the right-hand side? If Ayşe already has a promotion on record effective December 1st, "10% from January 1st" means 10% on top of her January 1st salary. An engine that reads today's value silently undoes the promotion. The right-hand side of the assignment has to be evaluated at the point where the change takes effect.

What happens to later plans? If a change is already on record for March, there are two reasonable meanings. The new value holds until the next recorded change, and the March plan survives; or it holds until further notice, and the March plan is removed. HR wants both, on different days. The syntax has to tell them apart (UNTIL NEXT, ONWARD); the engine must not choose silently.

EFFECTIVE FROM 2027-01-01beforewhat is on record50,00060,000 · promotion70,000 · March planUNTIL NEXTMarch plan kept50,00060,00066,00070,000ONWARDMarch plan removed50,00060,00066,00070,000 removedread todaypromotion undone50,00060,00055,00070,000Nov 1Dec 1Jan 1Mar 1May 1the new value comes from the salary in effect on January 1st: 60,000 × 1.10 = 66,000
One UPDATE, two decisions. Read the right-hand side at the effective date, not today, or the promotion disappears; and say whether the new value stops at the next plan or runs onward over it.

6. Constraints: an effective-dated column cannot be unique

The proposal's strictest rule is this: an EFFECTIVE DATED column can never be UNIQUE, in any form. It cannot be a PRIMARY KEY, it cannot carry a UNIQUE constraint, and it cannot be the target of another table's foreign key. The engine rejects the combination when the schema is defined. There are three reasons.

Identity is timeless. Every side table points back to the main table by key. If the key could change over time, then knowing whose salary a row holds would first require knowing at which date to read the identity.

The meaning is ambiguous. On an effective-dated column, UNIQUE could mean unique at every point or unique across all time. These are different constraints; one word carrying both means the engine chooses silently.

The violation happens in the future. Say Mehmet is planned to give up an email address on January 1st, and Ayşe is assigned the same address from January 1st. When Mehmet's plan is pushed to February, the correction collides with a record of Ayşe's that hasn't happened yet. Combined with future-dated records, uniqueness weaves an invisible web of dependencies between rows.

If "the same address can't belong to two people at once" is a real rule, the relationship is turned around. The address lives in its own table with a timeless key, and its holder is that table's effective-dated attribute:

CREATE TABLE email_address (
    address  text PRIMARY KEY,
    holder   bigint EFFECTIVE DATED REFERENCES employee
);

Because a side table accepts no overlapping intervals for the same key, an address has at most one holder at any point. Uniqueness isn't declared separately; it follows from the key being timeless.

UNIQUE ON AN EFFECTIVE-DATED COLUMNemployee.emailMehmetsales@acmeAyşesales@acmeconflictNov 1Jan 1Feb 1Mar 1postponing Mehmet collides with Ayşe's future rowTHE ADDRESS HOLDS ITS HOLDERemail_address.holder · sales@acmeplannedMehmetAyşepostponedMehmetAyşeNov 1Jan 1Feb 1Mar 1one key, one lane: two holders at once can't be written
On the left, uniqueness is a constraint between rows that breaks in the future. On the right, it falls out of the structure: a side table never holds two overlapping intervals for one key.

7. What exists today

Every piece exists somewhere.

ApproachTime is kept perFuture-dated factsDone by the engine
SQL:2011 application timerowyesyes
Anchor Modelingattributenot its focusno, a generated schema
Datomicattributeno, transaction time onlyyes
XTDBrowyesyes
This proposalattributeyesyes, in the planner

No system offers all three together: time at the level of the attribute, facts dated in the future, and planning in the engine. The content of the proposal is to bring those three into one place.

8. Limits

  1. Planning the future needs bitemporal data. If 10% is entered on December 15th and corrected to 8% on December 20th, the question "what had we planned on December 15th?" can't be answered with effective time alone; it needs recording time too. The proposal extends naturally by adding SQL:2011's system versioning to the side tables, but that is outside the scope of this essay.
  2. Derived systems hear nothing. On January 1st nothing is written, so no search index, cache or CDC pipeline receives an event either. The engine has to emit separate "this value is now in effect" events.
  3. Wide reads are expensive. Join elimination makes an unselected column free; it doesn't make a selected one free. SELECT * on a table with twenty effective-dated columns means twenty range lookups. The proposal is for schemas where a few columns genuinely flow with time.
  4. The ban on uniqueness forces modelling work. SQL:2011's WITHOUT OVERLAPS already supports "unique at every point"; this proposal deliberately refuses it. The price is that some schemas have to be turned around as in section 6.
  5. This is a design proposal, not an implementation. How cleanly the rules fit into an existing planner, and how interval splitting behaves under concurrency, are open questions.

9. Conclusion

Date, Darwen and Lorentzos showed that in temporal data, attributes that change at different times have to live in separate tables. The reason the idea stayed in a book for twenty years is not that it was wrong, but that applying it was left to the user.

My proposal is to move that responsibility into the engine. A column is declared EFFECTIVE DATED; the engine keeps its side table; the planner turns projections into LEFT JOINs and predicates into EXISTS; a future-dated UPDATE splits that one column's interval and nothing else. In return, an effective-dated column can never be unique, and identity stays timeless.

Not every attribute needs a history, but every attribute with a history needs its own table. And in a database that does this, January 1st is an ordinary day: the data doesn't change; only the point it is read from moves forward.

Sources

Temporal relational theory

Normal forms

Standards and engines

Applying 6NF in practice

Note: the syntax in this essay (EFFECTIVE DATED, AS OF EFFECTIVE, EFFECTIVE FROM, UNTIL NEXT, ONWARD) is invented for the proposal; no engine implements it in this form.