Compare Hibernate's @CreationTimestamp and @UpdateTimestamp with letting the database fill created_at/updated_at through a DEFAULT or a trigger. Where is the value produced in each case, and what breaks?
answer
- JVM clock vs database clock
- Bulk JPQL update skips the generator
- Trigger catches every writer, needs @Generated read-back
- Skew breaks updated_at ordering
- Nanos vs column precision
basics
~20 s@CreationTimestamp and @UpdateTimestamp are produced in the JVM by Hibernate when it inserts or updates the entity, so they depend on the app server's clock and are skipped by bulk JPQL updates and native SQL. Database defaults and triggers fire for every writer but need a read-back to appear in the entity.
solid answer
~50 s`@CreationTimestamp` sets the field when Hibernate inserts the row; `@UpdateTimestamp` sets it on insert and again on every UPDATE Hibernate generates for that entity. Both are Java-side value generators — the value comes from the JVM clock, so multiple application nodes with drifting clocks produce non-monotonic timestamps, and the column is only touched when Hibernate writes that row: a bulk `update ... ` JPQL statement, a native SQL update or a direct DBA change silently leaves it stale. A database `DEFAULT current_timestamp` or a trigger produces the value on the server, is consistent across nodes, and applies to every writer including migrations and other applications. The cost is that Hibernate does not see it — you must map the property `@Generated` so Hibernate skips writing it and reads it back, which is an extra round trip per row. Hibernate can also source its timestamps from the database with `@CurrentTimestamp(source = SourceType.DB)`, at the cost of a clock query.
code
java · 17 lines@Entity
public class Product {
@Id @GeneratedValue
private Long id;
@CreationTimestamp
@Column(updatable = false)
private Instant createdAt; // set by Hibernate at INSERT
@UpdateTimestamp
private Instant updatedAt; // set by Hibernate at INSERT and every UPDATE
// maintained by a database trigger for every writer
@Generated(event = {EventType.INSERT, EventType.UPDATE})
@Column(insertable = false, updatable = false)
private Instant dbTouchedAt;
}go deeper
Know the two annotations and that Hibernate sets them in Java when it inserts or updates the entity.
Contrast the producers: JVM clock versus database, and name the bypass paths (bulk JPQL, native SQL, other writers).
Reason about ordering guarantees across nodes, the read-back cost of trigger-maintained columns, and precision mismatches that surface in tests and CDC pipelines.
Treat it as a data-contract decision: if the timestamp drives replication or auditing across systems, it must be enforced where all writers meet — the database — and the ORM mapping follows from that.
## Two different producers Audit timestamps look like one feature but have two possible producers, and the choice determines the failure modes. **JVM-side (Hibernate).** `@CreationTimestamp` and `@UpdateTimestamp` are Hibernate value-generation annotations. At flush, when Hibernate builds the INSERT for a new entity it asks the generator for a value and puts it in the statement; `@UpdateTimestamp` does the same for INSERT *and* for every UPDATE the entity produces. The value comes from the JVM clock, converted to the field's type — `Instant`, `LocalDateTime`, `OffsetDateTime`, or the legacy `java.util.Date`/`Timestamp`. **Database-side.** A column default (`created_at timestamptz not null default now()`) or a trigger produces the value inside the database engine, at the moment the statement executes. ## What breaks with JVM-side timestamps *Clock skew and ordering.* Timestamps across a cluster are only as consistent as NTP. Two nodes can stamp rows such that a later write appears earlier. If anything downstream orders events by `updated_at` — change-data capture, incremental sync, "what changed since X" polling — skew produces skipped or duplicated rows. A database clock gives one monotonic source per instance. *Bypass paths.* The generator runs only in Hibernate's entity write path. `em.createQuery("update Product p set p.price = ...").executeUpdate()` is a bulk statement translated straight to SQL: no entity is loaded, no generator is called, and `updated_at` is not touched. The same is true for native SQL, for Liquibase/Flyway migrations, for another service writing the table, and for a DBA's manual fix. A trigger catches all of these. *Nothing-changed writes.* `@UpdateTimestamp` fires when Hibernate emits an UPDATE for that row. If a change lives elsewhere — you added a child to a one-to-many, or updated a join table — the parent row is not updated and its timestamp does not move. Conversely, if the entity uses dynamic update and nothing is dirty, no UPDATE is emitted at all. *Precision mismatch.* Java gives nanoseconds; many columns store microseconds or milliseconds. The value in memory and the value in the row differ after a round trip, which trips naive equality assertions in tests and any code comparing a freshly-stamped value with a re-read one. Choose the field type and column precision deliberately (`@Column(columnDefinition = "timestamp(6)")` or truncating in the generator). ## What breaks with database-side timestamps *Invisibility.* Hibernate writes what is in memory and does not re-read rows. If the column is mapped normally, Hibernate sends `null` and — depending on whether the default or the trigger wins — either overwrites the database value or gets a value it never sees. The fix is to mark the property `@Generated(event = ...)`, which drops the column from the write statement and reads it back with a returning clause or a follow-up SELECT. That is an extra round trip per row and reduces the benefit of insert batching. *DDL ownership.* The behaviour now lives in migrations rather than the entity, so it is invisible to someone reading the Java code and must be tested against a real database. *Portability.* Trigger syntax is vendor-specific; if the test suite runs on a different engine than production, the behaviour differs unless the same objects are created there too. ## Choosing A practical split: `created_at` is written once and is rarely the basis for cross-node ordering, so JVM-side generation is usually fine. `updated_at`, if anything outside the ORM writes the table, or if it drives incremental replication, belongs in the database with a trigger and a `@Generated` mapping. If everything writes through Hibernate and the timestamps are for human forensics rather than machine ordering, the annotations are simpler and cheaper — no read-back, no trigger to maintain. Hibernate also offers a middle path: `@CurrentTimestamp(source = SourceType.DB)` (and the older `@CreationTimestamp(source = SourceType.DB)`) asks the database for the current timestamp rather than using the JVM clock. That removes skew but adds a clock query, and it still only runs on Hibernate's write path — bulk statements still bypass it.
- You need updated_at to move even when a bulk JPQL update changes the rows. What are your options?Either set the column explicitly in the bulk statement (`set p.updatedAt = :now`), or move the responsibility into the database with a BEFORE UPDATE trigger and map the property as @Generated so Hibernate stops writing it and reads it back. The trigger is the only option that also covers native SQL, migrations and other applications.
- Does @UpdateTimestamp fire when you add an element to a child collection of the entity?No. The generator runs when Hibernate emits an UPDATE for that entity's own table. Inserting a child row or a join-table row does not update the parent row, so the parent timestamp does not move. If the parent's timestamp is meant to represent the whole aggregate, you must dirty the parent deliberately — for example by bumping a version-like field — or maintain the timestamp in the database.
saying these in an interview costs you the question
- Assuming @UpdateTimestamp is maintained by the database
- Believing bulk JPQL or native updates still stamp the column
- Trusting cross-node ordering of JVM-generated timestamps
- Mapping a trigger-maintained column normally, so Hibernate overwrites it with null
- Ignoring that the value in memory and the stored value differ in precision