created_at and updated_at: database DEFAULT or JPA annotation?
Almost every table has created_at and updated_at.
The problem isn't having them, it's not deciding who fills them: the
database (DEFAULT now()) or the application
(@CreationTimestamp/@UpdateTimestamp from Hibernate).
When both fill them, or neither, the familiar symptoms show up:
updated_at null forever, timestamps that diverge between rows
created by the API and rows created by a script, wrong time after someone
changed the server's timezone.
The two strategies
| DEFAULT in the database | Annotation in the application | |
|---|---|---|
| Works for manual INSERT, ETL, another service | yes | no |
| updated_at on UPDATE | needs a trigger | automatic |
| Value available on the entity before flush | no (needs a reread) | yes |
| Clock source | database server | each instance's JVM |
Both work. What doesn't work is mixing them without a rule. Rule of thumb: if
only the application writes to the table, the annotation handles it and the code
stays self-contained; if the table gets writes from outside (jobs, another
system, hand-written SQL), DEFAULT in the database plus a trigger for
updated_at is the only way to guarantee consistency.
The app-side version, spelled out
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private Instant updatedAt;
updatable = false on created_at matters: without
it, any full-entity UPDATE can rewrite the creation time. If you'd rather not
depend on Hibernate-specific annotations, the JPA-standard equivalent is
lifecycle callbacks, same behavior, more code:
@PrePersist void onCreate() { createdAt = updatedAt = Instant.now(); }
@PreUpdate void onUpdate() { updatedAt = Instant.now(); }
The db-side version, spelled out
ALTER TABLE post
ALTER COLUMN created_at SET DEFAULT now();
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER post_touch BEFORE UPDATE ON post
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
The DEFAULT covers inserts from any client; the trigger covers updates from
any client. The price: after an insert or update through JPA, the entity in
memory doesn't have the value the database wrote (Hibernate needs
@Generated or a refresh to see it), and the logic lives in a
migration rather than in the codebase, where fewer people will look for it.
Clock skew, the quiet third participant
With annotations, the timestamp comes from whichever JVM handled the request; with DEFAULT/trigger, from the database server. In a fleet of app instances, JVM clocks drift a little even with NTP, so app-generated timestamps can disagree by milliseconds to seconds across instances, enough to make "order by created_at" and "which write won" occasionally lie. The database clock is a single source. If event ordering matters downstream, that alone is a reason to prefer the db-side strategy for these columns.
timestamp vs timestamptz
Whoever writes the value, store it as an instant. In PostgreSQL,
TIMESTAMP stores a "loose" wall-clock time with no zone;
TIMESTAMPTZ normalizes to UTC on write. For an event instant
(created at, paid at), TIMESTAMPTZ is the right one: it survives a
server with a changed timezone and daylight saving,
the full argument is its own
article. In Java, map it to Instant or
OffsetDateTime; LocalDateTime pairs with
TIMESTAMP and inherits the same problems.
created_at TIMESTAMPTZ NOT NULL DEFAULT now()