A temporal resource remembers everything. Every record is spread across many rows, one for each period of its history, and you can read it as of any moment. Ash's temporal resources guide covers what they are and how to use them. This page is about what AshPostgres does underneath, and what it needs from you.

What you need

PostgreSQL 18 or later. That's where periods arrived in primary keys and foreign keys, which is what all of this is built on. Your repo has to say it's targeting 18:

def min_pg_version do
  %Version{major: 18, minor: 0, patch: 0}
end

You also need the btree_gist extension. A temporal primary key is a GiST exclusion constraint under the hood, and btree_gist is what lets it compare ordinary columns like id:

def installed_extensions do
  ["ash-functions", "btree_gist"]
end

Forget either one, and your temporal resource won't compile. The error tells you which.

What ends up in your database

Run mix ash_postgres.generate_migrations and you get:

  • A period column, a tstzrange holding the period each row is valid for.
  • PRIMARY KEY (id, valid_at WITHOUT OVERLAPS), so a record can have as many rows as it likes, but never two that are valid at the same instant.
  • PERIOD foreign keys for relationships between temporal resources. A subscription can't point at a plan for a stretch of time when that plan didn't exist.
  • Identities that are unique at every instant, rather than across all of time. Two members can't share an email at the same moment, but an email that one member gave up can go to another.

How a write splits a row

Say a subscription has been on bronze since January, and you change it to gold as of March 1. Nothing gets overwritten. The bronze row is cut off at March 1, and a gold row picks up from there:

before:  [Jan 1, ∞)  bronze

after:   [Jan 1, Mar 1)  bronze
         [Mar 1, ∞)      gold

The SQL standard has a statement for exactly this: UPDATE ... FOR PORTION OF. Here's a plot twist, though. It was slated for PostgreSQL 19, and temporal resources were first built on it. Then we found that PostgreSQL had reverted it before release: two writes to the same row at the same time could quietly lose part of one of them.

So AshPostgres does the split itself. Each write is a single statement that writes the slice it covers, and puts back the rest of the row as new rows next to it. A destroy works the same way, except the slice it covers is simply gone. When PostgreSQL ships FOR PORTION OF for real, AshPostgres will switch to it.

When two writes collide

That lost-update problem doesn't go away just because we're doing the split ourselves, so here's how AshPostgres handles it.

Every temporal write locks the rows it's about to split, and checks that they're still the rows it expected to find. Usually they are, and the write goes ahead. But if another transaction got there first and changed one of them, the write backs off without writing anything, and tries again. From then on it holds on to every lock it takes, so it doesn't keep losing its place in line. Two upserts that race to create the same record are sorted out the same way: the one that loses, retries.

You'll almost never notice. It takes a lot of transactions hammering the same part of the same record at once to make a write retry more than a couple of times. If one ever loses 25 times in a row, it gives up with AshPostgres.Temporal.WriteConflict. Nothing was written, so it's safe to just try the action again.

That's all under READ COMMITTED, which is what you're using unless you've changed it. Under REPEATABLE READ or SERIALIZABLE, PostgreSQL raises a serialization failure for these collisions itself, and retrying is up to you.

One thing to keep in mind: the locking only happens for writes that go through Ash. If something else writes to your temporal tables with raw SQL at the same moment, it can still step on Ash's writes.

If you set this up before

Migrations generated by earlier versions of AshPostgres only added the temporal keys on PostgreSQL 19, and quietly fell back to plain ones on anything older. If you ran them on PostgreSQL 18, your tables have the plain keys, which means nothing is stopping two versions of a record from overlapping.

Here's how to check:

SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'your_table'::regclass;

If the primary key doesn't mention WITHOUT OVERLAPS, write a migration that drops it and adds PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) in its place. Do the same for identities (they should be EXCLUDE USING gist constraints) and for foreign keys to other temporal resources (they should mention PERIOD).