paste PostgreSQL or JSON, get your schema reviewed like a senior would

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 databaseAnnotation in the application
Works for manual INSERT, ETL, another serviceyesno
updated_at on UPDATEneeds a triggerautomatic
Value available on the entity before flushno (needs a reread)yes
Clock sourcedatabase servereach 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()

Schemint detects created_at/updated_at in your DDL and warns when the value's origin is implicit, plus it offers the audit annotations when generating the Entity.

Related