Spring Boot uses Hibernate as its JPA provider. The spring-boot-starter-data-jpa starter puts Hibernate ORM on the classpath, and Spring Boot builds the EntityManagerFactory, the connection pool and the transaction manager from application.properties. JPA (Jakarta Persistence) is the standard Java API for mapping classes to tables, and Hibernate is the library that implements it and writes the SQL.
We use Spring Boot with Hibernate whenever a Spring Boot application keeps its data in a relational database, such as the repair jobs of a bike shop in MySQL.
Spring Boot 4.1.1 manages Hibernate ORM 7.4.5.Final with Jakarta Persistence 3.2. We set up Spring Boot with Hibernate 7 for a bicycle repair shop and cover the datasource and JPA properties, entities, Spring Data repositories with derived and @Query methods, @Transactional services, the EntityManager and Hibernate Session APIs, SQL logging, a REST endpoint, tests on H2 and MySQL, and what changes when a Spring Boot 3 application moves to Hibernate 7.
The following example adds the starter and calls a repository, a service and the Hibernate Session, with the result of each line as a comment.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
List<RepairJob> done = jobs.findByStatus(RepairStatus.DONE); // [Trek (flat tire, DONE, 15.00), Cube (chain, DONE, 30.00)]
long doneCount = jobs.countByStatus(RepairStatus.DONE); // 2
List<MechanicRevenue> revenue = jobs.revenuePerMechanic(); // [MechanicRevenue[mechanic=Anna, total=15.00], MechanicRevenue[mechanic=Ravi, total=30.00]]
service.complete(5L); // update repair_job set ... status=? where id=? (at commit)
List<RepairJob> byIds = em.unwrap(Session.class)
.findMultiple(RepairJob.class, List.of(1L, 4L)); // [Trek (...), Cube (...)]
Notice that service.complete(5L) never calls save(), yet Hibernate writes an update at commit.
1. How Spring Boot 4 Works With Hibernate 7
Spring Boot does not ship its own ORM. It picks the Hibernate version in its dependency management, starts Hibernate at application startup, and connects it to Spring’s transactions. Each Spring Boot line manages one Hibernate line.
| Spring Boot | Hibernate ORM it manages | Jakarta Persistence |
|---|---|---|
| 3.5.16 | 6.6.53.Final | 3.1 |
| 4.0.8 | 7.2.24.Final | 3.2 |
| 4.1.1 | 7.4.5.Final | 3.2 |
The javax.persistence to jakarta.persistence package change happened with Spring Boot 3, so a Spring Boot 3 application already uses the jakarta imports. The move to Hibernate 7 removes old Hibernate APIs and adds Jakarta Persistence 3.2 features (section 3).
At runtime, our code calls a Spring Data repository or the EntityManager. A Spring proxy around the service opens the transaction, Hibernate turns entity changes into SQL, and JDBC sends it through the HikariCP connection pool.

We can use Hibernate through three APIs, and all three use the same persistence context, which is the set of entities Hibernate loaded or saved in the current transaction.
| API | What we write | Use it for |
|---|---|---|
| Spring Data JpaRepository | An interface; Spring Data generates the implementation | Most reads and writes |
| Jakarta Persistence EntityManager | JPQL and find(), persist(), merge() calls | Queries that do not fit a repository method |
| Hibernate Session | Hibernate-only methods, after unwrap() | Features JPA does not have, such as findMultiple() |
2. Spring Boot Hibernate Example
The following example is a bicycle repair shop. A mechanic has many repair jobs, and each job has a bike model, a problem, a status, a cost and a creation time. The project runs on Java 25, Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Data 2026.0.1), Hibernate ORM 7.4.5.Final, H2 2.4.240 for development and MySQL 9 for one integration test. The complete project contains every file and 23 tests.
2.1. Maven Dependencies
One starter is enough for Hibernate, because spring-boot-starter-data-jpa pulls in hibernate-core, jakarta.persistence-api 3.2.0, Spring Data JPA, Spring’s ORM module and HikariCP. We add the web starter for the REST endpoint and the JDBC drivers.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
We never set a Hibernate version in the POM. The parent manages it, and the startup log confirms which one runs.
HHH000001: Hibernate ORM core version 7.4.5.Final
2.2. Datasource and JPA Properties
Spring Boot reads two groups of properties. The spring.datasource.* properties configure the JDBC connection, and spring.jpa.* configures Hibernate.
# H2 in-memory database for development
spring.datasource.url=jdbc:h2:mem:bikeshop
spring.datasource.username=sa
spring.datasource.password=
# Hibernate and JPA
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
spring.jpa.defer-datasource-initialization=true
# SQL logging with bind parameters
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace
spring.jpa.properties.hibernate.format_sql=true
Each property goes to a different component. Knowing which one helps when a setting seems to have no effect.

The property spring.jpa.hibernate.ddl-auto sets Hibernate’s hibernate.hbm2ddl.auto, which decides what Hibernate does with the tables at startup. Spring Boot defaults it to create-drop for an embedded database such as H2 and to none for any other database.
| Value | At startup | At shutdown | Use it for |
|---|---|---|---|
| create-drop | Drops and creates the tables | Drops the tables | Development, tests |
| create | Drops and creates the tables | Nothing | Demos with data kept after stop |
| update | Adds missing tables and columns | Nothing | Local experiments only |
| validate | Checks tables against entities, fails on a mismatch | Nothing | Production, with Flyway or Liquibase |
| none | Nothing | Nothing | Production |
The other properties need one line each.
- The setting spring.jpa.show-sql=true prints SQL to System.out with a Hibernate: prefix. The logger settings in section 2.7 are better, because they also show the bind values.
- The setting spring.jpa.properties.hibernate.format_sql=true prints each statement on several lines. Any spring.jpa.properties.hibernate.* key goes to Hibernate unchanged.
- The setting spring.jpa.defer-datasource-initialization=true runs data.sql after Hibernate created the tables, so the app starts with two mechanics and four jobs.
- The setting spring.jpa.open-in-view=false turns off the Open EntityManager in View pattern. FAQ 4.2 explains it.
Without spring.jpa.open-in-view, a web application logs a warning at every start.
WARN ... JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
We do not set a dialect (the Hibernate class that knows the SQL flavor of one database). Hibernate reads the database name and version from the JDBC connection and picks the dialect itself.
Database dialect: H2Dialect
Database version: 2.4.240
Database dialect: MySQLDialect
Database version: 9.7.2
Setting spring.jpa.database-platform=org.hibernate.dialect.H2Dialect only adds a deprecation warning, such as HHH90000025: H2Dialect does not need to be specified explicitly using ‘hibernate.dialect’ (remove the property setting and it will be selected by default).
Column names come from the naming strategy. Spring Boot 4 sets Hibernate’s PhysicalNamingStrategySnakeCaseImpl, so the field bikeModel becomes the column bike_model. Spring Boot 3 used CamelCaseToUnderscoresNamingStrategy, which Hibernate 7 deprecated; both produce the same snake_case names. To keep the field names as they are, we set spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl.
2.3. Entities With Jakarta Persistence 3.2
An entity is a class that Hibernate maps to a table. All mapping annotations, such as @Entity and @Id, come from the jakarta.persistence package. Spring Boot finds every @Entity class in the package of the @SpringBootApplication class and below it, so no persistence.xml is needed.
@Entity
public class RepairJob {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bikeModel;
private String problem;
@Enumerated(EnumType.STRING)
private RepairStatus status = RepairStatus.RECEIVED;
@Column(precision = 8, scale = 2, check = @CheckConstraint(constraint = "cost >= 0"))
private BigDecimal cost;
@CreationTimestamp
private Instant createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mechanic_id")
private Mechanic mechanic;
}
@Entity
public class Mechanic {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "mechanic")
private List<RepairJob> jobs = new ArrayList<>();
}
Three details in RepairJob are new or changed in Hibernate 7 and Jakarta Persistence 3.2.
- The attribute check = @CheckConstraint(…) on @Column is new in Jakarta Persistence 3.2. Hibernate adds the check to the DDL, and the database rejects a negative cost.
- Instant is a standard basic type since Jakarta Persistence 3.2. @CreationTimestamp is Hibernate’s annotation that sets the value on insert.
- Since Hibernate 7.4, a @CreationTimestamp column gets not null in the generated DDL.
The one-to-many association has mappedBy on the Mechanic side, so the foreign key column lives in repair_job. FetchType.LAZY on the @ManyToOne loads the mechanic only when the code reads it. Hibernate 7.4 created this table on H2:

create table repair_job (
cost numeric(8,2) check ((cost >= 0)),
created_at timestamp(6) with time zone not null,
id bigint generated by default as identity,
mechanic_id bigint,
bike_model varchar(255),
problem varchar(255),
status enum ('DONE','IN_PROGRESS','RECEIVED'),
primary key (id)
)
2.4. Spring Data Repositories With Derived and @Query Methods
A JpaRepository interface gets save(), findById(), findAll(), count() and delete() without any code. Spring Data creates the implementation at startup, and that implementation calls the EntityManager. For example, the front desk of the shop lists all finished bikes with one findByStatus(DONE) call.
public interface RepairJobRepository extends JpaRepository<RepairJob, Long> {
List<RepairJob> findByStatus(RepairStatus status);
List<RepairJob> findByMechanicNameAndStatusOrderByCostDesc(String name, RepairStatus status);
long countByStatus(RepairStatus status);
boolean existsByBikeModelAndStatusNot(String bikeModel, RepairStatus status);
}
A derived query is a method whose name Spring Data parses into a query. The name starts with find…By, continues with properties joined by And or Or, and ends with an optional OrderBy. Mechanic.name is a nested property, so Spring Data adds the join.
| Method call | Result | SQL Hibernate printed |
|---|---|---|
| findByStatus(DONE) | Trek, Cube | … where rj1_0.status=? |
| findByMechanicNameAndStatusOrderByCostDesc(“Anna”, IN_PROGRESS) | Giant | … left join mechanic m1_0 on m1_0.id=rj1_0.mechanic_id where m1_0.name=? and rj1_0.status=? order by rj1_0.cost desc |
| countByStatus(DONE) | 2 | select count(rj1_0.id) from repair_job rj1_0 where rj1_0.status=? |
| existsByBikeModelAndStatusNot(“Giant”, DONE) | true | select rj1_0.id from repair_job rj1_0 where rj1_0.bike_model=? and rj1_0.status<>? fetch first ? rows only |
When a method name gets too long, or the query needs a join fetch, a projection or an update, we write the JPQL ourselves with @Query. JPQL queries entities and fields, not tables and columns. Spring Boot compiles with -parameters, so :min matches the parameter name without @Param.
@Query("select j from RepairJob j where j.cost >= :min order by j.cost desc")
List<RepairJob> findCostingAtLeast(BigDecimal min);
@Query("select j from RepairJob j join fetch j.mechanic where j.id = :id")
Optional<RepairJob> findWithMechanic(Long id);
@Query("""
select new com.howtodoinjava.springboothibernate.MechanicRevenue(m.name, sum(j.cost))
from RepairJob j join j.mechanic m
where j.status = DONE
group by m.name
order by m.name""")
List<MechanicRevenue> revenuePerMechanic();
@Query("select j.bikeModel || ': ' || j.problem from RepairJob j order by j.id")
List<String> ticketLabels();
@Modifying
@Query("update RepairJob j set j.status = :to where j.status = :from")
int moveAll(RepairStatus from, RepairStatus to);
List<RepairJob> costly = jobs.findCostingAtLeast(new BigDecimal("25")); // Giant 40.00, Cube 30.00, Brompton 25.00
Mechanic ravi = jobs.findWithMechanic(3L).get().getMechanic(); // Ravi, loaded by the same select
List<MechanicRevenue> revenue = jobs.revenuePerMechanic(); // [MechanicRevenue[mechanic=Anna, total=15.00], MechanicRevenue[mechanic=Ravi, total=30.00]]
List<String> labels = jobs.ticketLabels(); // [Trek: flat tire, Giant: brakes, Brompton: gears, Cube: chain]
int moved = jobs.moveAll(RECEIVED, IN_PROGRESS); // 1, update repair_job rj1_0 set status=? where rj1_0.status=?
Four details of the @Query methods matter in practice.
- The join fetch clause loads the mechanic in the same select, so the lazy association is ready after the transaction ends.
- The constructor expression select new …MechanicRevenue(…) creates a Java record per row instead of an entity. The condition where j.status = DONE works without the enum’s class name, because Hibernate resolves the constant from the type of status.
- The || operator is new JPQL syntax in Jakarta Persistence 3.2. Hibernate translates it per database, into ((rj1_0.bike_model||’: ‘)||rj1_0.problem) on H2 and concat(concat(rj1_0.bike_model, ‘: ‘), rj1_0.problem) on MySQL.
- @Modifying marks an update or delete query. It runs directly in the database and skips the persistence context, so entities loaded before it keep their old values until we clear the context.
2.5. Transactions With @Transactional
A transaction groups SQL statements that must succeed or fail together. Spring Boot registers a JpaTransactionManager, and @Transactional on a service method makes Spring’s proxy start a transaction before the method and commit it after the method returns.
@Service
@Transactional(readOnly = true)
public class RepairShopService {
@Transactional
public RepairJob receive(Long mechanicId, String bikeModel, String problem, BigDecimal cost) {
Mechanic mechanic = mechanics.findById(mechanicId)
.orElseThrow(() -> new JobNotFoundException("Mechanic " + mechanicId));
RepairJob job = new RepairJob(bikeModel, problem, cost);
mechanic.addJob(job);
return jobs.save(job);
}
@Transactional
public RepairJob complete(Long jobId) {
RepairJob job = jobs.findById(jobId).orElseThrow(() -> new JobNotFoundException("Job " + jobId));
job.setStatus(RepairStatus.DONE); // no save() call
return job;
}
}
The method complete() never calls save(). findById() returns a managed entity, and at commit Hibernate compares it with the state it loaded. This is called dirty checking, and it writes the change.
select rj1_0.id,rj1_0.bike_model,rj1_0.cost,rj1_0.created_at,rj1_0.mechanic_id,rj1_0.problem,rj1_0.status from repair_job rj1_0 where rj1_0.id=?
update repair_job set bike_model=?,cost=?,mechanic_id=?,problem=?,status=? where id=?
For example, when a customer drops off two bikes at once, the shop saves either both jobs or none of them. The tests check three more transaction rules.
| Case | Code | Result |
|---|---|---|
| Runtime exception | receiveAll() saves “Scott”, then throws for a negative cost | Both inserts rolled back, count() unchanged |
| Read-only transaction | changeCostInReadOnly(id, 99.00) changes a loaded entity | No UPDATE, the cost stays 40.00 |
| No transaction | jobs.findById(1L).get().getMechanic().getName() in a test | LazyInitializationException |
We put @Transactional on the service, not on the controller or the repository. One service method often calls several repositories, and their changes must commit together. Spring Data’s own methods already run in a transaction, so a single save() call works without one. Spring rolls back on a RuntimeException by default, not on a checked exception, unless we add rollbackFor.
2.6. Using EntityManager and the Hibernate Session Directly
Spring Data covers most queries, and for the rest we inject the EntityManager. Spring injects a thread-safe shared proxy that forwards each call to the persistence context of the current transaction, so constructor injection works and no @PersistenceContext field is needed.
@Repository
@Transactional(readOnly = true)
public class RepairJobDao {
private final EntityManager em;
private final EntityManagerFactory emf;
public RepairJobDao(EntityManager em, EntityManagerFactory emf) {
this.em = em;
this.emf = emf;
}
public RepairJob findByBikeModel(String bikeModel) {
return em.createQuery("select j from RepairJob j where j.bikeModel = :model", RepairJob.class)
.setParameter("model", bikeModel)
.getSingleResultOrNull();
}
}
The method getSingleResultOrNull() is new in Jakarta Persistence 3.2. It returns null when no row matches, where getSingleResult() throws NoResultException.
String problem = dao.findByBikeModel("Brompton").getProblem(); // gears
RepairJob missing = dao.findByBikeModel("Nope"); // null
For a Hibernate feature that JPA does not have, we unwrap the Hibernate interface behind the JPA one.
| JPA interface | Unwrap call | Hibernate interface |
|---|---|---|
| EntityManager | em.unwrap(Session.class) | Session |
| EntityManagerFactory | emf.unwrap(SessionFactory.class) | SessionFactory |
Session session = em.unwrap(Session.class);
List<RepairJob> found = session.findMultiple(RepairJob.class, List.of(1L, 4L)); // [Trek (...), Cube (...)]
// select ... from repair_job rj1_0 where rj1_0.id in (?, ?)
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Dialect dialect = sessionFactory.unwrap(SessionFactoryImplementor.class)
.getJdbcServices().getDialect(); // H2Dialect 2.4.240 (MySQLDialect 9.7.2 in the MySQL test)
The method findMultiple() is new in Hibernate 7.0 and loads several entities by id with one in query. We call unwrap() inside a transaction, as the @Transactional DAO does, so the Session we get is the one that holds the current persistence context. Spring also registers the EntityManagerFactory bean under the SessionFactory interface, so @Autowired SessionFactory sessionFactory works without an unwrap.
2.7. Logging SQL With Bind Parameters
The ? marks in a SQL log replace the values. For example, when a repair job gets the wrong cost, the bound values show which number the app sent. Two logger levels print the statement and each bound value.
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace
spring.jpa.properties.hibernate.format_sql=true
DEBUG ... org.hibernate.SQL :
insert
into
repair_job
(bike_model, cost, created_at, mechanic_id, problem, status, id)
values
(?, ?, ?, ?, ?, ?, default)
TRACE ... org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [Specialized]
TRACE ... org.hibernate.orm.jdbc.bind : binding parameter (2:NUMERIC) <- [35.00]
TRACE ... org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP_UTC) <- [2026-10-03T17:25:10.831784Z]
TRACE ... org.hibernate.orm.jdbc.bind : binding parameter (4:BIGINT) <- [2]
TRACE ... org.hibernate.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [brakes]
TRACE ... org.hibernate.orm.jdbc.bind : binding parameter (6:ENUM) <- [RECEIVED]
The logger name is org.hibernate.orm.jdbc.bind since Hibernate 6. The Hibernate 5 name org.hibernate.type.descriptor.sql.BasicBinder, which many older answers show, prints nothing in Hibernate 7. Other Spring Data JPA SQL logging options, such as a proxy datasource, print the SQL with the values inlined.
2.8. Exposing the Repair Jobs Through a REST Endpoint
A REST controller calls the service and returns records, never entities. A record has no lazy association, so the JSON writer cannot trigger a query after the transaction ended.
@RestController
@RequestMapping("/api/jobs")
public class RepairJobController {
@GetMapping
public List<RepairJobView> byStatus(@RequestParam RepairStatus status) {
return service.findByStatus(status).stream().map(RepairJobView::of).toList();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public RepairJobView create(@RequestBody NewRepairJob request) {
RepairJob job = service.receive(request.mechanicId(), request.bikeModel(), request.problem(), request.cost());
return RepairJobView.of(job);
}
@PatchMapping("/{id}/complete")
public RepairJobView complete(@PathVariable Long id) {
service.complete(id);
return RepairJobView.of(service.find(id));
}
}
curl 'localhost:8080/api/jobs?status=DONE'
# [{"id":1,"bikeModel":"Trek","problem":"flat tire","status":"DONE","cost":15.00,"mechanic":"Anna"},
# {"id":4,"bikeModel":"Cube","problem":"chain","status":"DONE","cost":30.00,"mechanic":"Ravi"}]
curl -X POST localhost:8080/api/jobs -H 'Content-Type: application/json' \
-d '{"mechanicId":2,"bikeModel":"Specialized","problem":"brakes","cost":35.00}'
# 201 {"id":5,"bikeModel":"Specialized","problem":"brakes","status":"RECEIVED","cost":35.00,"mechanic":"Ravi"}
curl -X PATCH localhost:8080/api/jobs/5/complete
# {"id":5,"bikeModel":"Specialized","problem":"brakes","status":"DONE","cost":35.00,"mechanic":"Ravi"}
The method service.findByStatus() uses a join fetch query, so the list needs one select for all jobs and their mechanics. An unknown id answers HTTP 404 through @ResponseStatus on JobNotFoundException. We can extend the controller into a full Spring Boot and Hibernate CRUD API on the same service layer.
2.9. Testing on H2 and on MySQL With Testcontainers
Spring Boot 4 moved the test slices into their own modules, so we add the test starters next to the main ones.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@DataJpaTest starts only JPA, Hibernate and an in-memory database, and rolls back each test. In Spring Boot 4 it lives in org.springframework.boot.data.jpa.test.autoconfigure.
@DataJpaTest
class RepairJobRepositoryTest {
@Test
void checkConstraintRejectsNegativeCost() {
Mechanic anna = mechanics.findByName("Anna").orElseThrow();
RepairJob job = new RepairJob("Bmx", "chain", new BigDecimal("-5"));
anna.addJob(job);
assertThatThrownBy(() -> jobs.saveAndFlush(job))
.isInstanceOf(DataIntegrityViolationException.class); // Check constraint violation: "CONSTRAINT_9: "
}
}
H2 is fast, but it is not the production database. Testcontainers starts a real MySQL in Docker, and @ServiceConnection points the datasource at it, so no URL or password is configured.
@SpringBootTest
@Testcontainers
class MySqlRepairJobTest {
@Container
@ServiceConnection
static MySQLContainer mysql = new MySQLContainer("mysql:9");
@Test
void runsTheSameCodeOnMySql() {
assertThat(dao.dialect()).startsWith("MySQLDialect 9.");
Mechanic lena = mechanics.save(new Mechanic("Lena"));
RepairJob job = service.receive(lena.getId(), "Trek", "flat tire", new BigDecimal("15.00"));
service.complete(job.getId());
assertThat(jobs.findByStatus(RepairStatus.DONE)).extracting(RepairJob::getBikeModel).containsExactly("Trek");
}
}
The same entity produces different DDL on MySQL. The enum and check types are kept, and the other column types change to MySQL’s own.
create table repair_job (
cost decimal(8,2) check ((cost >= 0)),
created_at datetime(6) not null,
id bigint not null auto_increment,
mechanic_id bigint,
bike_model varchar(255),
problem varchar(255),
status enum ('DONE','IN_PROGRESS','RECEIVED'),
primary key (id)
) engine=InnoDB
The script data.sql does not run here, because Spring Boot runs SQL scripts only for embedded databases by default. Starting the MySQL container took about 31 seconds on a small sandbox machine with the image already pulled. The project has 23 tests, namely 7 for the repositories, 6 for the service transactions, 4 for the EntityManager and Hibernate APIs, 1 for SQL logging, 3 for the REST endpoints and 2 on MySQL.
3. Upgrading a Spring Boot 3 Application to Hibernate 7
Spring Boot 3.5 manages Hibernate 6.6, and Spring Boot 4 moves to Hibernate 7. Most entity mappings and Spring Data repositories compile unchanged, and most failures come from Hibernate APIs that were deprecated in Hibernate 6 and removed in 7.0, and from a few behavior changes. For example, an old DAO that still calls session.saveOrUpdate() stops compiling after the upgrade. The items in this section come from the Hibernate 7.0 to 7.4 migration guides.
3.1. Removed Session Methods and Annotations
The old Session.save() and its relatives no longer exist, so code that unwraps a Session and calls them fails to compile.
Legacy.java:7: error: cannot find symbol
session.save(job);
^
symbol: method save(RepairJob)
location: variable session of type Session
| Removed in Hibernate 7 | Use instead |
|---|---|
| Session.save() | persist() |
| Session.update() | merge() |
| Session.saveOrUpdate() | persist() for new, merge() for detached entities |
| Session.delete() | remove() |
| Session.load(Class, id) | getReference() |
| CascadeType.SAVE_UPDATE, CascadeType.DELETE (Hibernate’s enum) | JPA CascadeType.PERSIST, MERGE, REMOVE |
| @Where, @WhereJoinTable | @SQLRestriction, @SQLJoinTableRestriction |
| @Proxy, @LazyToOne, @LazyCollection, @Loader, @SelectBeforeUpdate | No direct replacement; use FetchType, entity graphs or fetch profiles |
| Hibernate’s @Table, @Index, @ForeignKey, @OrderBy | The JPA annotations with the same names |
| EmptyInterceptor | Implement Interceptor itself |
Spring Data’s save() is not affected. It calls persist() for a new entity and merge() for an existing one.
3.2. Behavior Changes That Show Up in Spring Boot Apps
These changes compile fine but can change what an existing application does.
| Change | Since | What we notice |
|---|---|---|
| Stricter mapping checks | 7.0 | Startup fails on misplaced or conflicting annotations that Hibernate 6 ignored |
| No implicit PERSIST cascade through @Id or @MapsId | 7.0 | Add cascade = PERSIST on such associations |
| Detached entities cannot be refreshed or locked | 7.0 | refresh() on a detached entity throws IllegalArgumentException |
| Native queries return java.time types | 7.0 | Results are LocalDateTime instead of java.sql.Timestamp |
| Bulk update on an @Immutable entity | 7.0 | Throws an exception instead of logging a warning |
| getSingleResult() when the same entity comes back in several rows | 7.3 | Throws instead of removing the duplicates |
| @CreationTimestamp and @UpdateTimestamp columns | 7.4 | Generated DDL adds not null |
| Paging with a collection join fetch | 7.4 | The limit runs in SQL instead of in memory |
| MySQL default hibernate.max_fetch_depth of 2 | 7.4 | Removed; set it if an application relied on it |
Hibernate 7 is also licensed under the Apache License 2.0 instead of the LGPL, and it needs Java 17 or newer.
3.3. Jakarta Persistence 3.2 Features We Can Use Now
Jakarta Persistence 3.2 adds features that work with Hibernate 7 in any Spring Boot 4 application.
| Feature | Example |
|---|---|
| || string concatenation in JPQL | select j.bikeModel || ‘: ‘ || j.problem from RepairJob j |
| left(), right(), replace(), cast() functions | left(j.bikeModel, 3) |
| union, intersect, except in JPQL | Combine two queries into one result |
| id() and version() functions | select id(j) from RepairJob j |
| getSingleResultOrNull() | null instead of NoResultException |
| Check constraints and comments on @Table and @Column | @Column(check = @CheckConstraint(constraint = “cost >= 0”)) |
| Instant and Year as basic types | private Instant createdAt; |
| Java records as @Embeddable | An address record inside an entity |
| find() options and getReference(entity) | em.find(RepairJob.class, id, LockModeType.PESSIMISTIC_WRITE) |
| EntityManagerFactory.runInTransaction() | For plain JPA code; in Spring we keep @Transactional |
The example uses ||, getSingleResultOrNull(), the check constraint and Instant, and the other rows come from the Jakarta Persistence 3.2 specification.
3.4. Spring Boot 4 Changes Around JPA
A few Spring Boot 4 changes also touch JPA code.
- The default physical naming strategy is Hibernate’s PhysicalNamingStrategySnakeCaseImpl instead of CamelCaseToUnderscoresNamingStrategy; table and column names stay the same.
- JPA auto-configuration moved into new modules. @EntityScan moved to org.springframework.boot.persistence.autoconfigure, and @DataJpaTest to org.springframework.boot.data.jpa.test.autoconfigure.
- Test slices need their own starters, such as spring-boot-starter-data-jpa-test.
- Testcontainers 2 renamed its modules (testcontainers-mysql) and moved MySQLContainer to org.testcontainers.mysql.
4. Spring Boot Hibernate FAQs
4.1. How Do I Use a Newer Hibernate Version Than Spring Boot Manages?
We override the hibernate.version property that the Spring Boot parent defines. Every Hibernate module then uses that version.
<properties>
<hibernate.version>7.4.11.Final</hibernate.version>
</properties>
HHH000001: Hibernate ORM core version 7.4.11.Final
The repository and Hibernate API tests passed with 7.4.11.Final. Stay within the minor version that Spring Boot manages, because Spring Boot is tested against it; a jump such as 7.4 to a future 8.0 can break the auto-configuration.
4.2. Should I Set spring.jpa.open-in-view to false?
Yes, for new applications. With the default true, Spring keeps the EntityManager open for the whole web request, so a controller or the JSON writer can still load a lazy association after the service returned. Those queries run outside the service transaction and hold a database connection until the response is written. With false, the same access throws a LazyInitializationException at once.
RepairJob job = jobs.findById(1L).orElseThrow(); // transaction ends here
String name = job.getMechanic().getName();
// org.hibernate.LazyInitializationException: Could not initialize proxy [com.howtodoinjava.springboothibernate.Mechanic#1] - no session
The fix is to load what the caller needs inside the service, for example with join fetch, and to return records instead of entities.
4.3. Do I Need to Set spring.jpa.database-platform or hibernate.dialect?
No. Hibernate 7 detects the dialect and the database version from the JDBC connection, which gives H2Dialect for H2 2.4.240 and MySQLDialect for MySQL 9.7.2 in the example. Setting it by hand only logs HHH90000025. The setting is needed only when Hibernate cannot connect at startup, for example when hibernate.boot.allow_jdbc_metadata_access=false is set.
4.4. Is ddl-auto=update Safe for Production?
No. update adds missing tables and columns, but it never drops or renames anything, and it cannot move data. For production, we manage the schema with Flyway or Liquibase and set ddl-auto=validate or none. Spring Boot already defaults to none for a non-embedded database.
4.5. Why Does create-drop on MySQL Log a “Table doesn’t exist” Warning?
On the first start, Hibernate tries to drop the foreign key of a table that does not exist yet.
GenerationTarget encountered exception accepting command : Error executing DDL "alter table repair_job drop foreign key FKdbhe8p2rgc8j6nl4ey82iwh5e" via JDBC [Table 'test.repair_job' doesn't exist]
The warning is harmless. The tables are created right after it, and the tests pass.
4.6. Should I Use Spring Data Repositories or the EntityManager?
Both, in the same application. Repositories cover CRUD, derived queries and @Query methods with the least code. The EntityManager fits queries built at runtime, and the unwrapped Session gives access to Hibernate-only features. All three share one transaction and one persistence context, so mixing them in a service method is safe. A hand-written DAO built only on the EntityManager is shown in Spring MVC and Hibernate CRUD Example.
5. Conclusion
With Spring Boot 4.1.1, spring-boot-starter-data-jpa adds Hibernate 7.4.5, and a few properties configure the datasource, the schema tool, SQL logging and open-in-view. Repositories with derived and @Query methods cover most data access, @Transactional services set the commit boundaries, and unwrap() returns the Session and SessionFactory when we need Hibernate’s own API. A Spring Boot 3 application moving to Hibernate 7 mainly replaces the removed Session methods and Hibernate annotations.
6. References
- Spring Boot 4: SQL Databases
- Spring Boot 4: Data Access How-to Guides
- Spring Boot 4: Managed Dependency Coordinates
- Spring Boot 4: Testcontainers
- Spring Data JPA: Query Methods
- Spring Data JPA: Transactionality
- Spring Framework 7: Using @Transactional
- Hibernate ORM 7.0 Migration Guide
- Hibernate ORM 7.4 Migration Guide
- What’s New in Hibernate ORM 7.0
- Hibernate ORM 7.4 User Guide
- Jakarta Persistence 3.2
- Testcontainers MySQL Module
Happy Learning !!