Your standard application generally only tracks what is true right now. Updates overwrite previous data, and asking questions about the past requires either introducing a mechanism to track the previous state of your application (versions), or reshaping your entire mental model of application development (event-driven/event-sourcing). Temporal resources are the answer.
Temporal resources are a new, experimental feature in Ash that make dealing with changes over time
boring. You can read it as of any moment, write to it as of any moment (yes, including the future), without changing
how you use or write your application (almost) at all. Filters, relationships, aggregates, calculations.
All of it from one option, as_of.
I announced the new feature at Goatmire Elixir in a talk called Time Travel for Normies, and I've rebuilt it here for the web. These pixel examples are based on a live multi-player game that had the audience split into two teams, competing for territory while traveling and using powerups that travelled through time. You had to be there for that one.
How we keep history today
There's no shortage of ways to keep history around. Lets compare what I would consider the "main four":
- A paper trail copies each version of a record into a versions table as it changes. AshPaperTrail does this for you.
- An event-driven design records what happened as events, alongside your regular tables, typically driving other behavior in your application via events.
- Event sourcing makes the events the source of truth, and works out the current state from them.
- AshEvents sits somewhere in the middle: every action is recorded as an event you can replay, while your tables stay the source of truth.
They can all tell you what happened. Fewer make it easy to see what the world looked like at a given moment, none of them let you ask about the past with an ordinary query, and none of them let you make a change that only takes effect later.
| Paper trail | Event driven | Event sourcing | AshEvents | Temporal resources | |
|---|---|---|---|---|---|
| Know what happened, and who did it | ✓ | ◐ | ✓ | ✓ | ✓ |
| See the state of the world at a past time | ◐ | ✕ | ◐ | ◐ | ✓ |
| Ask about the past in simple queries | ✕ | ✕ | ✕ | ✕ | ✓ |
| Make changes in the future | ✕ | ✕ | ✕ | ✕ | ✓ |
| Current state lives in tables | ✓ | ✓ | ◐ | ✓ | ✓ |
| Your app code stays “ordinary” | ✓ | ◐ | ✕ | ✓ | ✓ |
Four lines of code
To make a resource temporal, give it a temporal section:
use Ash.Resource,
domain: TemporalDemo.Canvas,
data_layer: AshPostgres.DataLayer
temporal do
strategy :context
attribute :valid_at
end
attributes do
uuid_primary_key :id
attribute :x, :integer, allow_nil?: false
attribute :y, :integer, allow_nil?: false
attribute :color, Color, default: :white
end
That's it. Every row now has a period, valid_at: the span of time
it's valid for, from one instant up to (but not including) another, or forever. Each record
is now split up across multiple rows, each row showing what the value of each column was for a
given span of time. The migration generator sets the table up so no two of them can overlap.
You never have to set valid_at yourself.
To read or write it at any moment other than now, pass as_of. Leave it out and you're reading
and writing now, so code that doesn't care about time doesn't change at all.
Lets take a tour
Here is a small canvas that three people, Alice, Bob and Carol, have been painting for the last hour. It's 09:45. Every paint is a dot on the timeline, and the white line shows the time that we are reading at (the `as_of`).
Writing
A write without as_of happens now. Alice paints (4, 1) blue, and it lands on the
timeline at 09:45 like any other update. Nothing new.
Canvas.paint_pixel!(4, 1, :blue, actor: alice)
%Pixel{x: 4, y: 1, color: :blue, ...}
Writing the past
Carol remembers she painted (0, 4) yellow at 09:12, but never recorded it. Pass
as_of, and the paint lands at 09:12.
Canvas.paint_pixel!(0, 4, :yellow,
actor: carol,
as_of: ~U[2026-09-30 09:12:00Z]
)
%Pixel{x: 0, y: 4, color: :yellow, ...}
We just rewrote history. Every moment from 09:12 onward now has a yellow pixel at (0, 4), including now, so it shows up on the canvas straight away.
Writing the future
Bob wants (4, 1) to turn pink, but not until 09:50. We don't see it now, because it happens in the future 😎.
Canvas.paint_pixel!(4, 1, :pink,
actor: bob,
as_of: ~U[2026-09-30 09:50:00Z]
)
%Pixel{x: 4, y: 1, color: :pink, ...}
The canvas doesn't change, and nothing has to run at 09:50 to make it happen. When 09:50 comes around, reads simply start seeing pink.
Reading the past
Reads take as_of too. Ask for the canvas as of 09:25, and you get exactly what it looked
like then: Carol's yellow pixel from 09:12 is there, and nothing painted after 09:25 is.
Canvas.canvas!(as_of: ~U[2026-09-30 09:25:00Z])
[
%Pixel{x: 1, y: 1, color: :blue, ...},
%Pixel{x: 2, y: 1, color: :blue, ...},
%Pixel{x: 1, y: 2, color: :yellow, ...},
%Pixel{x: 2, y: 2, color: :blue, ...},
%Pixel{x: 0, y: 4, color: :yellow, ...},
# ...and the 31 still white
]
Any query
It isn't just "get by id". Any query can run as of a moment, with filters, sorts and counts, so asking how many pixels were blue at 09:25 is one line.
Pixel
|> Ash.Query.filter(color == :blue)
|> Ash.count!(as_of: ~U[2026-09-30 09:25:00Z])
3
Relationships
as_of is passed down to all load statements, the way tenant is, so
related records come from the same moment. Load who painted each pixel, and you get who had painted
it as of 09:25.
Canvas.canvas!(
as_of: ~U[2026-09-30 09:25:00Z],
load: [:painted_by]
)
[
%Pixel{x: 1, y: 1, painted_by: %User{name: "Alice"}, ...},
%Pixel{x: 2, y: 1, painted_by: %User{name: "Bob"}, ...},
%Pixel{x: 1, y: 2, painted_by: %User{name: "Carol"}, ...},
...
]
Aggregates
Aggregates follow this pattern too. Count the pixels for each painter, and the
leaderboard as of 09:25 shows it. Leave out as_of, and it's the leaderboard now.
count :pixels_owned, :pixels
Accounts.leaderboard!(as_of: ~U[2026-09-30 09:25:00Z])
[
%User{name: "Alice", pixels_owned: 2, ...},
%User{name: "Bob", pixels_owned: 1, ...},
%User{name: "Carol", pixels_owned: 2, ...}
]
Calculations
Calculations run as of the moment, and inside them, now() and ago() mean
the moment you asked about rather than the wall clock. Someone is active? if they've
painted in the last ten minutes, so as of 09:25 that means since 09:15, and the window moves with
the moment.
calculate :active?, :boolean,
expr(exists(pixels, updated_at > ago(10, :minute)))
Accounts.leaderboard!(load: :active?,
as_of: ~U[2026-09-30 09:25:00Z])
[
%User{name: "Alice", active?: true, ...},
%User{name: "Bob", active?: false, ...},
%User{name: "Carol", active?: false, ...}
]
History
Every period of a record is a version of it, so its history is already in the table. With AshPaperTrail's temporal inline mode (more on that below), you can load it like any relationship, and as of any moment, too.
Canvas.get_pixel!(1, 1,
as_of: ~U[2026-09-30 09:25:00Z],
load: :paper_trail_versions
)
%Pixel{
x: 1,
y: 1,
paper_trail_versions: [
%Pixel{color: :white, ...},
%Pixel{color: :blue, ...}
],
...
}
How it works
PostgreSQL does the hard work for us. PostgreSQL 18 added the SQL:2011 building blocks for application-time periods, and PostgreSQL 19 will finish the job. Here's one pixel, at (3, 7), in the table underneath.
The rows for pixel (3, 7)
Periods and keys
valid_at is a range of timestamps. The primary key includes it
WITHOUT OVERLAPS, so a pixel can have many rows, but never two that are valid at the same time.
CREATE TABLE pixels (
id uuid NOT NULL,
x bigint NOT NULL,
y bigint NOT NULL,
color text NOT NULL,
valid_at tstzrange NOT NULL,
PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
);
Painting the pixel pink at 09:05 inserts a row that is valid from then on.
INSERT INTO pixels (x, y, color, valid_at)
VALUES (3, 7, 'pink', '[09:05,)');
FOR PORTION OF
Painting it blue at 10:12 doesn't overwrite that row. UPDATE ... FOR PORTION OF changes
only the part of the row's period from 10:12 onward, so Postgres splits it in two: pink until 10:12,
and blue from then on.
UPDATE pixels
FOR PORTION OF valid_at FROM '10:12' TO NULL
SET color = 'blue'
WHERE x = 3 AND y = 7;
A portion in the middle
A portion doesn't have to run to infinity. Painting it yellow from 09:30 to 09:45 changes a piece out of the middle of the pink row, and Postgres splits the original row into two around it.
UPDATE pixels
FOR PORTION OF valid_at FROM '09:30' TO '09:45'
SET color = 'yellow'
WHERE x = 3 AND y = 7;
Deleting a portion
DELETE works the same way. Deleting 11:00 to 11:30 leaves a gap: the pixel doesn't exist
for those thirty minutes, and comes back after.
DELETE FROM pixels
FOR PORTION OF valid_at FROM '11:00' TO '11:30'
WHERE x = 3 AND y = 7;
Try it yourself
Reading is the easy part: as of a moment, it's whichever row's period contains that moment,
valid_at @> '10:00'. Drag along the timeline to read the pixel as of any time. Then
change its history, and watch Postgres split the rows.
Read
You won't write any of that SQL yourself. AshPostgres generates the period column,
PRIMARY KEY (id, valid_at WITHOUT OVERLAPS), PERIOD foreign keys for
relationships between temporal resources, and identities that are unique at every instant rather than
across all of time. Writes automatically get FOR PORTION OF statements, and read actions
filter on the period.
AshPaperTrail: temporal inline mode
A temporal resource already keeps every version of every record, so a separate versions table would be
an unnecessary copy. AshPaperTrail's new :temporal_inline mode records all of the same information
you might track in paper_trail, but on each temporal row itself.
temporal do
strategy :context
attribute :valid_at
end
paper_trail do
mode :temporal_inline
change_tracking_mode :previous_values
store_action_name? true
belongs_to_actor :user, MyApp.Accounts.User,
domain: MyApp.Accounts
metadata :reason_for_change, :string
end
Caveats
This is experimental, and there are some sharp edges to know about.
-
It needs PostgreSQL 19, which is still in beta.
Temporal writes are
UPDATEandDELETE ... FOR PORTION OF, which are new in 19. - Every read is a single point in time. There's no read across a range of times. Explaining why is complex but, put simply, there are weird mind bending and complex timey-wimey things you'd have to do to model this correctly. Things we might actually attempt at a later date.
-
No cascades on
PERIODforeign keys. Postgres forbidsON DELETEandON UPDATEactions on them, so you have to cascade in your application instead. - The actor is taken as it is. You supply your actor, so anything in your policies that read from the actor (but not things that filter on the data), don't know about the past or future. It is on you to provide actor data appropriate for the time that a change is happening, or to write your policies to account for this. This is also something we may work on improving in later versions.
- Changes, validations and preparations must be temporal safe. Anything that runs during a temporal action must explicitly indicate what changes and validations are safe to run with a time that is anything other than "now". Ash's built-in ones are all temporal safe.
Try it out
The temporal resources guide has more information. Hop in the Discord if you'd like to chat more.
Thanks to Mike Buhot, whose talk It's time! PostgreSQL 19 for temporal data gave me the idea for mine. And if you'd like the whole talk's worth of slides, they're all here.