You add @JdbcTypeCode(SqlTypes.JSON) to a field because the docs say that’s the modern way to store a map as JSON, run the app, and get Could not find a FormatMapper for the JSON format. You reorder an enum’s constants during a refactor and nothing breaks — nothing throws, nothing logs, the tests still pass — and three weeks later a support ticket says a customer’s order status is wrong. Both of these are annotation-catalogue problems dressed up as mysteries. This article is not another list of what @Column and @OneToMany do; it’s what actually goes wrong with them in Hibernate 7.4.5, reproduced and verified against real boot logs and generated SQL.
Verified against: Hibernate ORM 7.4.5.Final, Spring Boot 4.1.1, Jakarta Persistence 3.2.0, Java 25, H2 2.4.240. Every boot log and result line below is quoted verbatim from the companion repo’s test output.
| Part | Best suited for |
|---|---|
| Part 1 — Beginner | the annotations every entity needs, and the ones that quietly went away in Jakarta Persistence 3.2 |
| Part 2 — Intermediate | the four traps that pass code review and fail in production: enums, JSON columns, access strategy, and equals/hashCode |
| Part 3 — Advanced | what Jakarta Persistence 3.2 actually added, and a bootstrap failure that has nothing to do with any single annotation |
Part 1 — The foundation, and what quietly changed
Every entity needs an identity and a table. In Hibernate 7 that’s still @Entity, @Table, @Id, and @Column for anything beyond the defaults — schema, precision on a BigDecimal, an explicit length. None of that changed. What did change is more interesting than what’s in the annotation catalogue:
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "products", schema = "inventory")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "prod_seq")
@SequenceGenerator(name = "prod_seq", sequenceName = "product_id_seq", allocationSize = 50)
private Long id;
@Column(name = "product_sku", unique = true, nullable = false, length = 50)
private String sku;
@Column(precision = 10, scale = 2)
private BigDecimal price;
protected Product() {}
public Product(String sku, BigDecimal price) {
this.sku = sku;
this.price = price;
}
}
| Category | Annotation | Use it for |
|---|---|---|
| Identity | @Id | the primary key |
@GeneratedValue | PK strategy — prefer explicit IDENTITY or SEQUENCE over AUTO, which can silently fall back to the slower TABLE strategy on some dialects | |
@MapsId | a @OneToOne child sharing its parent’s primary key — see the code example below | |
@NaturalId | a business key for L1/L2-cached lookups — the full story is in the natural IDs guide, not here | |
| Core mapping | @Entity / @Table | mark and place the class |
@Column | name, nullability, length, precision | |
@Version | optimistic locking | |
| Relationships | @ManyToOne / @OneToMany | owning vs. collection side; default @OneToMany to mappedBy + LAZY |
@JoinColumn | the FK column | |
@Enumerated | see Part 2 — the default is the trap | |
@Lob | large character/binary data (CLOB/BLOB) | |
@Embedded / @Embeddable | groups related fields (an Address, say) into a reusable, table-less component | |
| Specialized | @JdbcTypeCode | JSON and other specialized JDBC types — see Part 2 for the dependency it doesn’t advertise |
@Formula | a read-only, DB-computed expression | |
@BatchSize | batch collection fetches |
@MapsId shares a primary key between a parent and child in a @OneToOne, which avoids a redundant surrogate column on the child table:
@Entity
public class UserProfile {
@Id private Long id; // same value as User.id
@OneToOne @MapsId
@JoinColumn(name = "user_id")
private User user;
}
The annotation that quietly changed status is @Temporal. javap -v on jakarta.persistence-api-3.2.0.jar shows it carrying @Deprecated(since = "3.2") at the bytecode level — not a Javadoc note, an actual deprecation flag. It was never required for java.time types (LocalDate, LocalDateTime, OffsetDateTime, Instant all map natively), and now it’s formally on the way out. Putting it on a java.time field anyway doesn’t fail, and it isn’t silent either — Hibernate logs one WARN per annotated field, every boot:
HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] at com.ankurm.hibernatedemo.persistenceannotations.TemporalOnLocalDateEntity.eventDate.
The mapping itself is unaffected — the field round-trips identically with or without the annotation — so this is purely a startup-log cleanliness issue after a Hibernate upgrade, not a correctness one. Keep @Temporal only where you’re genuinely still mapping java.util.Date or Calendar; drop it everywhere else and the WARN goes away.
@Lob and @Embedded/@Embeddable round out the basics: @Lob marks a field for CLOB/BLOB storage (large text or binary that doesn’t belong in @JdbcTypeCode(SqlTypes.JSON)’s structured-data lane), and @Embeddable groups a handful of fields — an address, a money amount — into a reusable value type with no table of its own. Neither has a sharp trap verified for this rewrite, so they get a line, not a section.
Relationship annotations (@OneToMany, @ManyToOne, mappedBy, cascade, orphanRemoval) and fetch strategy (why FetchType.EAGER turns one query into an unplanned join chain, when JOIN FETCH is the fix, and what @NamedEntityGraph buys you over both) are deliberately not re-covered here — they get the full-depth treatment, with runnable examples, in the association mappings guide. Repeating a thinner version of that content here was the exact overlap this rewrite was asked to remove.
Part 2 — Four traps that pass review and fail in production
The enum default nobody notices until it’s wrong
@Enumerated defaults to ORDINAL — storing the enum’s position, not its name. Everyone knows to recommend EnumType.STRING instead. What’s rarely shown is what happens if you don’t: insert a row with SHIPPED (ordinal 1 in a 3-constant enum), then read the same physical row back through a second version of the enum that had a new constant, PENDING_REVIEW, inserted before SHIPPED. No exception, anywhere:
RESULT[enum-ordinal-default]: stored ordinal for SHIPPED (V1 ordering) = 1
RESULT[enum-ordinal-default]: same row re-read through V2 (PENDING_REVIEW inserted before SHIPPED) enum ordering = PENDING_REVIEW -- no exception thrown, silently resolves to the WRONG constant.
This is exactly the “someone added a constant to the enum without a migration” scenario. ORDINAL doesn’t warn you it’s fragile — it just quietly returns a different, valid-looking constant. Always set @Enumerated(EnumType.STRING) explicitly; there is no scenario where the default is the safer choice.
@JdbcTypeCode(SqlTypes.JSON) needs a dependency the starter doesn’t give you
@JdbcTypeCode(SqlTypes.JSON) on a Map<String, Object> field is the modern replacement for the deprecated @Type, and it works cleanly on H2 2.4.240 — but only once a JSON mapper is actually on the classpath. Against a bare spring-boot-starter-data-jpa + H2 dependency set, with no Jackson anywhere, it fails outright at write time:
org.hibernate.HibernateException: Could not find a FormatMapper for the JSON format, which is
required for mapping JSON types. JSON FormatMapper configuration is automatic, but requires that
you have either Jackson or a JSONB implementation like Yasson on the class path.
Most real applications never hit this because spring-boot-starter-web pulls Jackson in anyway — which is exactly why it’s easy to miss in a guide and then surprise you in a JPA-only module. Adding it explicitly fixes it:
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.1.5</version>
</dependency>
RESULT[jdbctypecode-json-h2]: persisted+loaded details={color=red, qty=5}
RESULT[jdbctypecode-json-h2]: H2 column type for the JSON field = JSON
Watch the groupId. Boot 4.1.1’s BOM names the bill of materialstools.jackson:jackson-bom, but the actualjackson-databindmodule publishes undertools.jackson.core, nottools.jackson. A guessedgroupId:jackson-databindcoordinate fails to resolve. Hibernate 7.4.5 ships bothJackson3JsonFormatMapperand the older Jackson-2JacksonJsonFormatMapperside by side, so either major version works once it’s actually present.
Mixed access: a setter you don’t want, and a getter that runs twice
The article this rewrite replaces never discussed @Access at all, and mixing field and property access on the same entity has two symptoms worth knowing before you hit them cold. An entity with FIELD access by default (because @Id sits on a field) can still expose one attribute via a PROPERTY-access getter — useful for a derived, computed value:
@Access(AccessType.PROPERTY)
@Column(name = "computed_label")
public String getComputedLabel() {
getterCallCount++;
return rawValue == null ? null : rawValue.toUpperCase();
}
// Hibernate's PROPERTY-access strategy requires a setter even for a logically
// read-only derived attribute -- omitting it throws PropertyNotFoundException at boot.
public void setComputedLabel(String ignored) {
// no-op: computedLabel is derived from rawValue, never written directly
}
Leave that setter out and boot fails immediately with PropertyNotFoundException: Could not locate setter method for property 'computedLabel' — even though the attribute is never meant to be written. A no-op setter is the standard workaround, not a design flaw in your entity.
The second symptom is subtler: a PROPERTY-access getter with a side effect runs more than once per flush. Instrumenting the getter with a call counter:
RESULT[mixed-access]: getComputedLabel() call count before persist=0, after commit/flush=2 (PROPERTY-access attributes are read via the getter at flush time, not via a backing field)
Two calls for one insert — once for dirty-check comparison, once for the actual write. Any “just compute it in the getter” PROPERTY-access attribute pays that cost every flush, not once per logical read. If the computation is expensive, cache it or move it off PROPERTY access entirely.
The equals/hashCode trap, reproduced end to end
The advice to avoid surrogate-id-based equals()/hashCode() is standard, but the actual failure is worth seeing once. An entity relies on default, identity-based equality with a @GeneratedValue id that starts out null:
@Entity
public class IdentityHashSetEntity {
@Id @GeneratedValue
private Long id;
private String label;
// Deliberately NOT overriding equals()/hashCode().
}
Add an instance to a HashSet while id is still null, persist() it, then look the same reference up in the same set:
RESULT[id-based-equals-hashset-trap]: after persist(), e.getId()=1, set.contains(e) = false (same reference, same set, only the hash code changed)
RESULT[id-based-equals-hashset-trap]: manual iteration foundByIteration = true -- confirms equals() itself still works; it's HashSet's bucket indexing that is now wrong.
contains() comes back false for an object that is, by every other measure, sitting right there in the set. equals() itself is fine; what broke is the hash bucket the object was filed under before id existed. This is the mechanism behind the standard advice, not just a reason to trust it:
The safe pattern: base equals()/hashCode() on a natural business key set at construction time (an email, ISBN, or UUID), never on the surrogate @GeneratedValue id, and never mix in Lombok’s @Data on an entity with a bidirectional association — its generated toString()/equals() walk every field, including the other side of the relationship, which is the most common cause of a StackOverflowError in a Post/Comment-shaped model. Use explicit @Getter/@Setter instead and hand-write both methods.
Part 3 — What’s actually new in 3.2, and a failure that isn’t about any one annotation
Jakarta Persistence 3.2 added three things worth knowing, each confirmed present via javap on jakarta.persistence-api-3.2.0.jar and exercised against H2:
@EnumeratedValue lets an enum control its own persisted representation through a designated field, instead of the ordinal or the name:
@Enumerated(EnumType.STRING) // still required, even with @EnumeratedValue present
private Priority priority;
public enum Priority {
LOW("L"), MEDIUM("M"), HIGH("H");
@EnumeratedValue // @Target(FIELD) only -- cannot go on a getter
private final String code;
Priority(String code) { this.code = code; }
}
RESULT[jpa32-enumeratedvalue]: raw DB value for HIGH = 'H' (neither ordinal '2' nor name 'HIGH' -- the @EnumeratedValue-annotated code 'H')
Note it still needs @Enumerated(EnumType.STRING) on the field — ORDINAL remains JPA’s overall default even with @EnumeratedValue present, and leaving it off fails boot with @EnumeratedValue for EnumType.ORDINAL must be placed on a field whose type is byte, short, or int.
TypedQuery.getSingleResultOrNull() returns null for a zero-row match instead of throwing NoResultException — a small change that removes a try/catch a lot of DAO code has carried for years:
RESULT[jpa32-getsingleresultornull]: query matching zero rows via getSingleResultOrNull() = null (getSingleResult() would have thrown NoResultException here)
JPQL constructor expressions can target a Java record now, matched positionally through its canonical constructor exactly like a regular DTO class:
public record PriorityCountView(Priority priority, long total) {}
RESULT[jpa32-record-constructor-expression]: [PriorityCountView[priority=HIGH, total=2], PriorityCountView[priority=LOW, total=1]]
And a story worth a callout on its own, because it will stop your entire application context and the message doesn’t point at the actual cause:
Two entity classes in different packages with the same simple name kill the whole context. Hibernate identifies entities by simple class name unless you set@Entity(name = "...")explicitly. Two@Entityclasses named, say,Order— one inbilling, one inshipping— fail bootstrap withDuplicateMappingException: ... share the entity name 'Order' (entity names must be distinct). This happened for real while building this repo’s test fixtures. It has nothing to do with any annotation covered above and everything to do with package layout in a growing codebase — the fix is an explicit@Entity(name = "BillingOrder")on one of them, not a mapping change.
One more classic caution this rewrite didn’t re-verify with a dedicated test: Hibernate’s default AUTO flush mode synchronizes the database before query execution, which is correct for consistency but can become a real bottleneck in tight write-heavy loops. Treat that as standard, well-documented Hibernate behavior rather than a new finding — worth knowing, not worth a callout.
FAQ
@Column(nullable = false) vs. @NotNull? Different layers. @Column(nullable = false) is a JPA DDL directive — it shapes the generated schema. @NotNull is Jakarta Bean Validation, checked in memory before Hibernate emits SQL. Using both is normal: @NotNull fails fast without a round-trip, @Column(nullable = false) backs it up at the schema level in case something bypasses validation.
Should I use GenerationType.AUTO? Generally no — it hands the dialect the choice, which can land on the slower TABLE strategy on some databases. Be explicit: IDENTITY for MySQL-style auto-increment, SEQUENCE for Postgres, Oracle, and SQL Server.
Is @Temporal still needed? No, not for java.time types — see Part 1 for what leaving it on actually does (a boot-time WARN, not a failure).
Should you keep reaching for the full annotation catalogue?
Most of the annotations here are unavoidable — you need an @Id and a @Column or two on every entity. The ones worth pausing on before you add them are the ones in Part 2: @Enumerated without an explicit STRING, @JdbcTypeCode(SqlTypes.JSON) without checking your dependency tree for an actual JSON mapper, and @Access(PROPERTY) on anything with a side effect in the getter. None of those fail loudly. That’s the actual theme of this article: the dangerous annotations aren’t the ones that throw, they’re the ones that quietly do the wrong thing and let your test suite pass anyway.
Further reading
- Repo: Chapter 05 — JPA persistence annotations, with the full test suite behind every claim above
- Hibernate annotations vs. XML mappings — when the mapping itself, not the annotation, should live in XML
- Association mappings in Hibernate 7 —
@OneToMany,@ManyToMany,@JoinTablein depth - Hibernate 7 natural IDs —
@NaturalIdlookups, L1/L2 cache behavior, and the equals/hashCode nuance a natural key introduces - Hibernate 7 date & time mapping — full guide to java.time types, timezones, and JDBC 4.2
- Official Hibernate 7 User Guide — Domain Model
- Jakarta Persistence 3.2 Specification
No Comments yet!