After learning JDBC, I was proud of myself. I could connect to a database, run queries and read the results. Then I built a real project with 12 tables, and my code filled up with the same pattern again and again: open a connection, prepare a statement, set five parameters, run it, loop over a ResultSet, copy each column into an object, close everything. Changing one column name meant hunting through dozens of SQL strings.
That's the problem ORM tools were invented to solve, and Hibernate is the most famous one in Java. You describe how your classes map to tables once, and Hibernate writes and runs the SQL for you.
In this post, we'll learn what ORM is and which problem it solves, its pros and cons, what Hibernate is and how it works, how to use it with and without Maven (using PostgreSQL), the common methods you'll use, how Hibernate relates to Jakarta Persistence (JPA), and a step-by-step example in the Spring Framework. I've also added things beginners usually miss: the entity lifecycle, lazy loading, the N+1 problem, caching and versioning.
A version note: I wrote this for Hibernate ORM 7.4 and Spring Framework 7.0. Both need Java 17 or newer. If you work on an older project (Hibernate 5 or 6), the section on older Hibernate methods tells you what changed.
Table of contents
- What is an ORM?
- Pros and cons of ORMs
- What is Hibernate?
- Setting up PostgreSQL for the examples
- The common code: entity and main class
- Using Hibernate without Maven
- Using Hibernate with Maven
- Common Hibernate methods
- Mapping relationships and loading data
- Hibernate vs Jakarta Persistence
- Hibernate in the Spring Framework
- Other things worth knowing
- Best practices
- Which one should I use?
- Common mistakes
- Quick recap
- Try it yourself
What is an ORM?
ORM stands for Object-Relational Mapping. It's a technique (and a kind of tool) that connects the two worlds a Java developer lives in: objects in your code and tables in a relational database. A class maps to a table, an object maps to a row, and a field maps to a column. Instead of writing SQL for every operation, you work with objects, and the ORM does the translating.
The problem: objects and tables do not match
With plain JDBC, you do all the translating by hand. Every query repeats the same steps, and your SQL strings are scattered through the code. That's tiring, but it's not the deepest problem.
The deeper problem is that objects and tables are built on different ideas. This gap has a name: the object-relational impedance mismatch.
OBJECT WORLD (Java) RELATIONAL WORLD (database)
classes, objects, fields tables, rows, columns
references between objects foreign keys and joins
inheritance (Dog extends Animal) no inheritance (flat tables)
identity with == and equals() identity with a primary key
walk the graph: s.getCourse().getTitle() write a JOIN in SQL
collections: List<Student> a separate table with a foreign key
Here's what that means in practice:
-
Inheritance: Java has it, tables don't. How do you store
DogandCat, which both extendAnimal? - Identity: two Java objects can hold the same data but be different objects. In a database, a row is identified by its primary key.
-
Associations: in Java, a
Studentholds a reference to aCourse. In a database, thestudenttable holds a foreign key column. - Navigation: in Java you walk from object to object. In SQL, you join tables.
Writing the code that bridges all this by hand, for every class, is slow and full of bugs.
How an ORM solves it
An ORM does the bridging for you. You describe the mapping once, usually with annotations on your classes. Then the ORM:
- generates the SQL for creating, reading, updating and deleting objects,
- turns query results back into objects,
- handles relationships between objects,
- tracks which objects changed and saves only those changes,
- manages transactions and caching,
- adapts the SQL to each database's dialect.
Java object table row
Student(id=1, name="Asha", marks=88) student: id=1 | name='Asha' | marks=88
| ^
| session.persist(student) |
+--- the ORM writes the SQL for you -----------+ insert into student ...
The same job, with and without an ORM
Saving and loading one student with plain JDBC:
String insert = "INSERT INTO student (name, marks) VALUES (?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(insert)) {
ps.setString(1, student.getName());
ps.setInt(2, student.getMarks());
ps.executeUpdate();
}
String select = "SELECT id, name, marks FROM student WHERE id = ?";
Student found = null;
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(select)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
found = new Student(rs.getLong("id"), rs.getString("name"), rs.getInt("marks"));
}
}
}
The same two jobs with Hibernate:
session.persist(student); // INSERT
Student found = session.find(Student.class, id); // SELECT ... WHERE id = ?
Two lines instead of twenty, and no column names to copy by hand.
Popular ORM tools
In Java:
- Hibernate ORM: the most widely used ORM, and the main topic of this post.
- EclipseLink: another full ORM, and the reference implementation of the JPA standard.
- Spring Data JPA: not an ORM itself, but a layer on top of JPA (usually Hibernate) that writes repository code for you.
Related tools that are not full ORMs: MyBatis (you write the SQL, it maps results to objects), jOOQ (a type-safe way to write SQL in Java) and Spring's JdbcTemplate (less boilerplate for JDBC).
Other languages have their own: Entity Framework (.NET), SQLAlchemy and the Django ORM (Python), ActiveRecord (Ruby), Prisma and TypeORM (JavaScript and TypeScript).
Pros and cons of ORMs
Advantages
- Much less boilerplate. No more connection, statement and result set code for every query.
- You think in objects. Your code stays object-oriented, and SQL is mostly hidden.
-
Relationships are handled for you. You walk from a
Studentto aCourseas if they were ordinary objects. - Portability. The ORM generates SQL for the database you use, so switching databases is often a configuration change.
- Built-in features: change tracking (only modified rows are updated), caching, lazy loading, optimistic locking and schema generation.
- Safer by default. Values are sent as parameters, which protects you from SQL injection.
- One place for mapping. Table and column names live in your entity classes, not in dozens of SQL strings.
- A standard. Code written against JPA works with several ORM providers.
Disadvantages
- A learning curve. Sessions, entity states, fetching strategies and flushing are real concepts to learn.
- Hidden SQL. You don't see the queries, so you can create slow ones without noticing. The famous example is the N+1 problem (covered later).
- Leaky abstraction. You still need to understand SQL, indexes and transactions to tune performance.
- Not ideal for everything. Bulk updates, complex reports and heavy analytics are often better in plain SQL.
- Memory and startup cost. The ORM tracks your objects and builds its mapping metadata when the app starts.
-
"Magic" that surprises. For example, changing a field on a managed object can cause an
UPDATEwithout any save call. -
Overkill for tiny apps. For a few simple queries, JDBC (or
JdbcTemplate) may be enough. - Schema changes need care. Letting the ORM modify a production schema automatically is risky.
The honest summary: an ORM saves a lot of work, but it doesn't remove the need to understand databases.
What is Hibernate?
Hibernate ORM is an open-source ORM framework for Java. Gavin King started it in 2001, and it became the most popular persistence tool in the Java world. Its ideas strongly influenced the JPA standard (more on that later), and today Hibernate is the most widely used JPA implementation. It also adds many extra features of its own.
Some current facts:
- Its Maven groupId is
org.hibernate.orm, and the main artifact ishibernate-core. - Hibernate 7 needs Java 17+ and implements Jakarta Persistence 3.2.
- At the time of writing, the latest stable series is 7.4 (7.4.13.Final), and Hibernate 8.0 (aimed at Jakarta Persistence 4.0) is still in development.
- Since version 7.0, it uses the Apache License 2.0.
How Hibernate fits in
Hibernate doesn't replace JDBC. It sits on top of it. Everything you learned about JDBC still happens underneath:
YOUR APPLICATION (entity classes + Hibernate or JPA API)
|
v
HIBERNATE ORM
- reads your mapping (annotations on the entities)
- turns your calls and HQL queries into SQL for your database (the "dialect")
- tracks changes, caches objects, manages relationships
|
v
JDBC (Connection, PreparedStatement, ResultSet ...)
|
v
JDBC DRIVER (the postgresql jar) --> PostgreSQL
Every query Hibernate runs becomes a PreparedStatement, so values are always sent safely as parameters. Hibernate also finds out which database you're using from the connection and picks the right dialect on its own, so you usually don't configure one.
The core pieces: SessionFactory, Session and Transaction
Three objects do most of the work:
SessionFactory
one per database, created ONCE when the app starts
expensive to create, thread-safe, shared by everyone
|
| openSession()
v
Session (also an EntityManager)
one per unit of work (for example, one web request)
cheap, NOT thread-safe, wraps one JDBC connection
holds the persistence context: a cache of the objects it is tracking
|
| beginTransaction()
v
Transaction --> commit() or rollback()
-
SessionFactory: built once from your configuration and mapping. Think of it as the "factory" that makes sessions. -
Session: your working conversation with the database. You open it, do some work and close it. -
Transaction: groups the work so it succeeds or fails as a unit (the same idea as in the JDBC post). - Persistence context: the "first-level cache" inside every session. Hibernate remembers every object it loaded or saved, and watches it for changes.
The life of an entity
An entity is a Java object that Hibernate manages (one that maps to a table). At any moment, an entity is in one of four states:
new Student("Asha", 88)
|
TRANSIENT a plain object; Hibernate doesn't know it; no row exists
|
| persist()
v
find() / query results --> PERSISTENT (also called managed)
| tracked in the persistence context; changes are saved automatically
| |
session closes, detach(), | remove()
or clear() v
| REMOVED (the row is deleted when the work is flushed)
v
DETACHED has an id and a row exists, but no session is tracking it
|
| merge() --> returns a PERSISTENT copy
Three ideas follow from this picture:
-
Dirty checking. For a persistent entity, Hibernate remembers its original values. At commit time, it compares them with the current values and sends an
UPDATEonly if something changed. You don't call "save" to update a managed object. - Flush. The moment Hibernate sends the pending SQL to the database is called a flush. It happens automatically before a query that might be affected and at commit.
- Identity guarantee. Inside one session, asking for the same row twice gives you the same object.
Setting up PostgreSQL for the examples
All examples use PostgreSQL. The quickest way to get one is Docker:
docker run --name school-db -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=school -p 5432:5432 -d postgres:17
This starts a PostgreSQL server on port 5432 with a database called school, the user postgres and the password secret. (That password is only for local practice. Use a strong one and read it from an environment variable in real projects.)
If you don't use Docker, install PostgreSQL from postgresql.org, then create the database:
psql -U postgres -c "CREATE DATABASE school;"
Our connection details for the rest of the post:
- URL:
jdbc:postgresql://localhost:5432/school - User:
postgres - Password:
secret
The common code: entity and main class
The Java code is the same whether you use Maven or not. Only the way you get the libraries differs.
The entity class
package com.example;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "student")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
private int marks;
protected Student() {
// required by Hibernate
}
public Student(String name, int marks) {
this.name = name;
this.marks = marks;
}
public Long getId() { return id; }
public String getName() { return name; }
public int getMarks() { return marks; }
public void setMarks(int marks) {
this.marks = marks;
}
@Override
public String toString() {
return "Student{id=" + id + ", name='" + name + "', marks=" + marks + "}";
}
}
What each annotation does:
-
@Entity: this class is mapped to a table. -
@Table(name = "student"): the table name. (Without it, Hibernate uses the class name.) -
@Id: the primary key field. -
@GeneratedValue(strategy = IDENTITY): the database generates the id (an auto-increment column). Another common strategy isSEQUENCE, which suits batching better. -
@Column(nullable = false, length = 50): column settings. A field with no annotation (likemarks) is mapped by default, to a column with the same name.
Rules for an entity class:
- It needs an
@Idfield. - It needs a no-argument constructor (it can be
protected, so your own code can't misuse it). - It must not be
final, because Hibernate creates subclasses (proxies) of entities. - Use
jakarta.persistence.*imports. The oldjavax.persistenceis from Hibernate 5 and earlier.
With hbm2ddl.auto set to update (we'll set it below), Hibernate creates a table similar to this one:
create table student (
id bigint generated by default as identity,
marks integer not null,
name varchar(50) not null,
primary key (id)
)
The main class
This program creates a student, reads it, updates it, queries and deletes it, using the classic Session and Transaction style:
package com.example;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
public class Main {
public static void main(String[] args) {
try (SessionFactory sessionFactory = buildSessionFactory()) {
Long id = createStudent(sessionFactory, "Asha", 88);
System.out.println("Saved with id " + id);
System.out.println("Found: " + findStudent(sessionFactory, id));
updateMarks(sessionFactory, id, 95);
System.out.println("After update: " + findStudent(sessionFactory, id));
System.out.println("Top students: " + findTopStudents(sessionFactory, 90));
deleteStudent(sessionFactory, id);
System.out.println("Deleted student " + id);
}
}
private static SessionFactory buildSessionFactory() {
return new HibernatePersistenceConfiguration("school")
.managedClass(Student.class)
.jdbcUrl("jdbc:postgresql://localhost:5432/school")
.jdbcCredentials("postgres", "secret")
.property("hibernate.hbm2ddl.auto", "update")
.createEntityManagerFactory();
}
private static Long createStudent(SessionFactory sf, String name, int marks) {
try (Session session = sf.openSession()) {
Transaction tx = session.beginTransaction();
Student student = new Student(name, marks);
session.persist(student); // INSERT
tx.commit();
return student.getId();
}
}
private static Student findStudent(SessionFactory sf, Long id) {
try (Session session = sf.openSession()) {
return session.find(Student.class, id); // SELECT by primary key
}
}
private static void updateMarks(SessionFactory sf, Long id, int newMarks) {
try (Session session = sf.openSession()) {
Transaction tx = session.beginTransaction();
Student student = session.find(Student.class, id);
student.setMarks(newMarks); // no "update" call: dirty checking
tx.commit(); // the UPDATE is sent here
}
}
private static List<Student> findTopStudents(SessionFactory sf, int minMarks) {
try (Session session = sf.openSession()) {
return session.createQuery(
"from Student s where s.marks >= :min order by s.marks desc",
Student.class)
.setParameter("min", minMarks)
.getResultList();
}
}
private static void deleteStudent(SessionFactory sf, Long id) {
try (Session session = sf.openSession()) {
Transaction tx = session.beginTransaction();
Student student = session.find(Student.class, id);
session.remove(student); // DELETE
tx.commit();
}
}
}
Output (the id may be different on your machine, and Hibernate also prints a few start-up log lines, which are normal):
Saved with id 1
Found: Student{id=1, name='Asha', marks=88}
After update: Student{id=1, name='Asha', marks=95}
Top students: [Student{id=1, name='Asha', marks=95}]
Deleted student 1
Let's go through the new parts:
-
HibernatePersistenceConfigurationis the modern way (Hibernate 7 and Jakarta Persistence 3.2) to configure Hibernate in plain Java code, with no XML file. You give it a name, the entity classes (managedClass), the JDBC URL, the credentials and any extra property. -
hibernate.hbm2ddl.auto = updatetells Hibernate to create or update tables to match your entities. It's handy for learning, but not for production (see the schema section later). -
createEntityManagerFactory()builds and returns theSessionFactory. We use try-with-resources so it's closed when the program ends. -
openSession()gives us aSessionfor one unit of work. Each method opens its own and closes it automatically. -
persist,find,removeare the basic operations, and the update works through dirty checking: we changed a field of a managed object, and Hibernate wrote theUPDATEatcommit(). -
The HQL query (
from Student s where ...) is written against the entity and its fields (Student,s.marks), not the table and column names. The:minis a named parameter, bound safely.
Using Hibernate without Maven
"Without Maven" means you manage the library files yourself. You put the jars in a folder and tell Java where they are.
Step by step without Maven
1. Install the tools. You need a JDK 17 or newer, and PostgreSQL running with the school database (see the setup section).
2. Create the project folders.
hibernate-demo/
|-- lib/ <- all the jar files go here
|-- src/
| +-- com/example/
| |-- Student.java
| +-- Main.java
+-- out/ <- compiled classes will go here
3. Get the jars into lib/. This is the hard part. hibernate-core alone isn't enough. It needs a set of other libraries (for example the Jakarta Persistence API, a logging library, a bytecode library, a parser library and a few more), and those may need others. You have two ways:
-
Option A, fully by hand: go to Maven Central, download
hibernate-core-7.4.13.Final.jar, open its.pomfile to read the dependency list, download each one, and repeat for those too. Then download the PostgreSQL driverpostgresql-42.7.13.jar. Expect roughly 15 to 25 jars, and the exact list changes between versions. -
Option B, use Maven only as a downloader: create a small file called
fetch-pom.xmlin the project folder:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>tmp</groupId>
<artifactId>fetch-jars</artifactId>
<version>1</version>
<dependencies>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>7.4.13.Final</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.13</version>
</dependency>
</dependencies>
</project>
Then run this once. It copies every needed jar, including the transitive ones, into lib/:
mvn -f fetch-pom.xml dependency:copy-dependencies -DoutputDirectory=lib
After that, you can forget Maven and build with plain Java tools.
4. Add the code. Put the Student and Main classes from the previous section into src/com/example/.
5. Compile. The "lib/*" part adds every jar in the folder to the classpath:
mkdir -p out
javac -cp "lib/*" -d out src/com/example/*.java
6. Run. Linux and macOS use : to separate classpath entries, and Windows uses ;:
java -cp "out:lib/*" com.example.Main # Linux and macOS
java -cp "out;lib/*" com.example.Main # Windows
7. Check the result. You'll see the output from the main class section. You can also look in the database:
docker exec -it school-db psql -U postgres -d school -c "\dt"
Schema | Name | Type | Owner
--------+---------+-------+----------
public | student | table | postgres
What this approach costs you:
- You track every jar and version by hand. Upgrading Hibernate means repeating step 3.
- Two libraries needing different versions of a third is your problem to solve.
- Every teammate must set up the same
lib/folder.
If you prefer an XML or properties file for configuration, Hibernate still supports hibernate.properties and hibernate.cfg.xml (read by calling new Configuration().configure()). The code-based approach above just avoids an extra file.
This is exactly why build tools exist, as we saw in the Maven post.
Using Hibernate with Maven
With Maven, you list the two libraries you need, and Maven fetches everything else.
Step by step with Maven
1. Create the project. (If you read my Maven post, this is the same command.)
mvn archetype:generate \
-DgroupId=com.example \
-DartifactId=hibernate-demo \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DarchetypeVersion=1.5 \
-DinteractiveMode=false
You can delete the generated App.java and the sample test, since we won't use them.
2. Edit pom.xml. Set the Java version and add the two dependencies:
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>7.4.13.Final</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.13</version>
<scope>runtime</scope>
</dependency>
</dependencies>
-
hibernate-corealso brings the Jakarta Persistence API with it, so thejakarta.persistence.*imports compile. - The PostgreSQL driver uses
runtimescope, because your code never uses the driver's classes directly, only the standard JDBC and Hibernate APIs. - Use the newest versions you can find on Maven Central. These are the ones I used.
3. Add the code. Put Student.java and Main.java in src/main/java/com/example/.
4. Run it. The exec plugin runs your main class:
mvn compile exec:java -Dexec.mainClass=com.example.Main
5. Check the output. You'll see the same five lines as before, mixed in with Maven's own messages.
To see everything Maven brought in for you, run:
mvn dependency:tree
You'll see hibernate-core with its whole list of transitive libraries underneath. That's the list you had to download by hand in the other approach.
Common Hibernate methods
In Hibernate 7, Session extends the standard JPA EntityManager, so most of the methods have JPA names. Here are the ones you'll use most:
METHOD WHAT IT DOES
persist(entity) insert a NEW entity (it becomes managed)
find(Class, id) load by primary key (null if it doesn't exist)
getReference(Class, id) a lazy proxy, no SQL until you use it
merge(entity) copy a DETACHED entity's state into a managed one, returns it
remove(entity) delete an entity
refresh(entity) reload its state from the database
detach(entity) stop tracking this entity
contains(entity) is this entity managed by the session?
flush() send pending SQL to the database now
clear() detach everything the session tracks
createQuery(hql, Class) run an HQL / JPQL query
createNativeQuery(sql, Class) run plain SQL
beginTransaction() start a transaction
close() close the session
Saving, reading, updating and deleting
// inside an open session, within a transaction
session.persist(new Student("Ravi", 72)); // INSERT
Student s = session.find(Student.class, 1L); // SELECT ... WHERE id = ?
s.setMarks(80); // UPDATE happens at commit (dirty checking)
session.remove(s); // DELETE happens at commit
find vs getReference:
Student a = session.find(Student.class, 999L); // runs a SELECT, returns null if missing
Student b = session.getReference(Student.class, 1L); // returns a proxy, no SQL yet
System.out.println(a);
null
getReference only runs SQL when you actually use the object (for example b.getName()), and if the row doesn't exist, you get an exception at that point. It's useful when you only need a reference, such as setting a foreign key.
persist vs merge is a common source of confusion:
-
persistis for new (transient) entities. After the call, the same object you passed is managed. -
mergeis for detached entities (ones that came from an earlier, closed session). It copies the state into a managed instance and returns that instance. The object you passed in stays detached, so always use the returned one.
Student detached = findStudent(sessionFactory, 1L); // its session is closed: detached
detached.setMarks(70); // changed outside any session
try (Session session = sessionFactory.openSession()) {
Transaction tx = session.beginTransaction();
Student managed = session.merge(detached); // use "managed" from here on
tx.commit(); // the UPDATE is sent
}
Queries with HQL
HQL (Hibernate Query Language) looks like SQL, but works on entities and their fields, not tables and columns. It's a superset of JPQL, the standard JPA query language.
// filter and sort
List<Student> top = session.createQuery(
"from Student s where s.marks >= :min order by s.marks desc", Student.class)
.setParameter("min", 80)
.getResultList();
// a single value
Long count = session.createQuery("select count(s) from Student s", Long.class)
.getSingleResult();
// pagination: rows 0 to 9
List<Student> firstPage = session.createQuery("from Student order by id", Student.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
// bulk update (runs directly in the database)
int changed = session.createMutationQuery("update Student set marks = marks + 5 where marks < 40")
.executeUpdate();
A few points:
- Always use named parameters (
:min) andsetParameter. Never join user input into the query string. - A bulk update goes straight to the database and skips the persistence context, so objects already loaded in the session don't see the change.
- For plain SQL, use
createNativeQuery:
List<Student> result = session.createNativeQuery(
"select * from student where marks > ?1", Student.class)
.setParameter(1, 80)
.getResultList();
- There's also the Criteria API, which builds queries with Java objects instead of strings. It's more verbose, but checked by the compiler.
Managing the persistence context
Remember that each session keeps a cache of the objects it loaded. This means asking for the same row twice gives you the same object, with only one SQL query:
Student s1 = session.find(Student.class, id);
Student s2 = session.find(Student.class, id);
System.out.println(s1 == s2);
true
The tools to control this cache:
-
flush()sends pending changes to the database immediately (they're still inside the transaction and can be rolled back). -
clear()anddetach(entity)stop tracking objects. Useful in long loops, so the session doesn't grow too large. -
refresh(entity)reloads the object from the database, discarding unflushed changes. -
contains(entity)tells you whether an object is managed.
Transactions and the safe pattern
All changes to the database must happen inside a transaction. Use this pattern, so a failure rolls everything back and the session is always closed:
Session session = sessionFactory.openSession();
Transaction tx = session.beginTransaction();
try {
session.persist(new Student("Meera", 95));
tx.commit();
} catch (RuntimeException e) {
tx.rollback();
throw e;
} finally {
session.close();
}
Jakarta Persistence 3.2 also added shortcuts to the factory that do all of this for you (begin, commit, rollback on failure and close):
sessionFactory.runInTransaction(em -> {
em.persist(new Student("Ravi", 72));
});
Long count = sessionFactory.callInTransaction(em ->
em.createQuery("select count(s) from Student s", Long.class).getSingleResult());
runInTransaction runs code that returns nothing, and callInTransaction returns a value. If you use Spring, @Transactional does the same job (we'll see it soon).
Methods from older Hibernate versions
Many tutorials online use older methods. Hibernate 7 removed a number of them, so such code won't compile. Here's the mapping:
OLD (Hibernate 5 and 6) NEW (Hibernate 7)
session.save(x) session.persist(x)
session.update(x) x = session.merge(x)
session.saveOrUpdate(x) persist() for a new entity, merge() for a detached one
session.delete(x) session.remove(x)
session.load(Class, id) session.getReference(Class, id)
session.get(Class, id) session.find(Class, id) (get is deprecated)
CascadeType.SAVE_UPDATE CascadeType.PERSIST and CascadeType.MERGE
javax.persistence.* jakarta.persistence.* (since Hibernate 6)
org.hibernate:hibernate-core org.hibernate.orm:hibernate-core (groupId changed in 6)
If you maintain old code, Hibernate 6.x still has most of the old methods marked as deprecated, which gives you time to migrate.
Mapping relationships and loading data
One-to-many and many-to-one
Real data has relationships. Let's say many students belong to one course:
course student
+----+----------+ +----+-------+-------+-----------+
| id | title | | id | name | marks | course_id | <- foreign key
+----+----------+ +----+-------+-------+-----------+
| 1 | Java 101 |<--------| 1 | Asha | 88 | 1 |
+----+----------+<--------| 2 | Ravi | 72 | 1 |
+----+-------+-------+-----------+
In Java, you map both sides:
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Student> students = new ArrayList<>();
public void addStudent(Student student) {
students.add(student);
student.setCourse(this); // keep both sides in sync
}
}
And in Student, add:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
private Course course;
How to read this:
-
@ManyToOne(inStudent) is the owning side: it has the foreign key columncourse_id. -
@OneToMany(mappedBy = "course")(inCourse) is the inverse side.mappedBysays "the foreign key is managed by thecoursefield over there." -
cascade = ALLpasses operations likepersistandremovefrom a course to its students. -
orphanRemoval = truedeletes a student that you remove from the list. - Always update both sides of the relationship, as
addStudentdoes.
There are also @OneToOne and @ManyToMany (which uses a join table).
Lazy loading and the N+1 problem
When you load a Course, should Hibernate also load all its students right away? That choice is the fetch type:
-
LAZY: load the related data only when you first use it. -
EAGER: load it immediately with the main object.
The defaults are: @OneToMany and @ManyToMany are lazy, while @ManyToOne and @OneToOne are eager. A good habit is to make everything LAZY explicitly and fetch what you need on purpose.
Laziness is powerful, but it has two classic traps.
Trap 1: LazyInitializationException. A lazy collection can only be loaded while its session is open:
Course course;
try (Session session = sessionFactory.openSession()) {
course = session.find(Course.class, 1L);
} // session closed
course.getStudents().size(); // LazyInitializationException
Trap 2: the N+1 problem. Suppose you list every course with its number of students:
List<Course> courses = session.createQuery("from Course", Course.class).getResultList(); // 1 query
for (Course course : courses) {
System.out.println(course.getTitle() + ": " + course.getStudents().size()); // +1 query EACH
}
1 query to load the courses
+ 1 query for the students of course 1
+ 1 query for the students of course 2
+ ...
= N + 1 queries (100 courses means 101 queries!)
The code looks innocent, but it hits the database a hundred times. The most common fix is to tell Hibernate to load them together with a join fetch:
List<Course> courses = session.createQuery(
"from Course c left join fetch c.students", Course.class).getResultList(); // 1 query
Other fixes include @BatchSize (load collections in groups) and entity graphs. Since Hibernate 6, duplicate root entities caused by the join are removed for you. Watching the SQL log (covered later) is the best way to catch N+1 early.
Hibernate vs Jakarta Persistence
What each one is
This confuses almost everyone at first, so here's the key: Jakarta Persistence is a specification (a standard). Hibernate is an implementation of it.
-
JPA (Java Persistence API, now called Jakarta Persistence) defines the interfaces and annotations:
EntityManager,EntityManagerFactory,@Entity,@Id, JPQL and the rules for how they behave. It contains no real working code for talking to a database. - Hibernate provides the working code behind those interfaces, plus many extra features of its own.
It's like the relationship between JDBC and a driver: the standard is the contract, and the library does the work. The names changed over time: JPA started in 2006 as part of Java EE, and when Java EE moved to the Eclipse Foundation it became Jakarta EE. That's why the packages changed from javax.persistence to jakarta.persistence. Hibernate 7 implements version 3.2.
YOUR CODE (uses the standard JPA API: EntityManager, @Entity ...)
|
v
JAKARTA PERSISTENCE (the specification: interfaces and annotations)
| implemented by a "persistence provider":
|
+-- Hibernate ORM (the most popular)
+-- EclipseLink (the reference implementation)
+-- others (OpenJPA, DataNucleus ...)
|
v
JDBC --> database
Hibernate's classes extend the standard ones, so you can use both in the same program:
JAKARTA PERSISTENCE (standard) HIBERNATE (adds more)
EntityManagerFactory SessionFactory (extends it)
EntityManager Session (extends it)
EntityTransaction Transaction (extends it)
JPQL HQL (a superset of JPQL)
persistence.xml hibernate.properties / hibernate.cfg.xml / code
@Entity, @Id, @Column ... plus @NaturalId, @BatchSize, @Formula, @SoftDelete ...
Some things you get only with Hibernate: StatelessSession (no persistence context, great for bulk work), @NaturalId, @BatchSize, filters, soft-delete support and many performance and mapping options.
The same code in both styles
Saving a student with the standard JPA API:
EntityManager em = entityManagerFactory.createEntityManager();
try {
em.getTransaction().begin();
em.persist(new Student("Asha", 88));
em.getTransaction().commit();
} finally {
em.close();
}
And with Hibernate's own API:
Session session = sessionFactory.openSession();
try {
Transaction tx = session.beginTransaction();
session.persist(new Student("Asha", 88));
tx.commit();
} finally {
session.close();
}
They're nearly identical, because Session is an EntityManager. With the standard setup, you create the factory with Persistence.createEntityManagerFactory("school"), which reads a META-INF/persistence.xml file. You can switch from the standard API to Hibernate's features at any time:
Session session = entityManager.unwrap(Session.class);
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
Which one to code against
-
Default to the standard JPA API (
EntityManager, JPQL,jakarta.persistenceannotations). Your code stays portable, and tools like Spring work with it directly. -
Reach for Hibernate-specific features (
Session, HQL features,StatelessSession, Hibernate annotations) only when you need what the standard doesn't offer. - In practice, almost everyone runs JPA on Hibernate, so "Hibernate vs JPA" is really "standard API vs extra features."
Hibernate in the Spring Framework
Using Hibernate on its own, you build the SessionFactory, open sessions, begin and commit transactions and close everything. The Spring Framework takes over all of that:
- It creates and manages the connection pool and the
EntityManagerFactory. - It provides declarative transactions with
@Transactional: you don't writebegin,commitorrollback. - It injects the
EntityManagerwhere you need it. - It can translate JPA and database exceptions into its own consistent exception types.
We'll build a small application with Spring Framework 7 (plain Spring, not Spring Boot), using the standard JPA EntityManager with Hibernate underneath. Spring 7 needs Java 17+, and its HibernateJpaVendorAdapter needs Hibernate ORM 7. (For Spring Framework 6.2, use a Hibernate 6.x version instead.)
Here's the project layout:
spring-hibernate-demo/
|-- pom.xml
+-- src/main/java/com/example/
|-- Student.java <- the entity (same as before)
|-- StudentRepository.java <- talks to the database through the EntityManager
|-- StudentService.java <- business logic; owns the transactions
|-- AppConfig.java <- Spring configuration
+-- App.java <- main method
Step 1: Add the dependencies
In pom.xml, import the Spring BOM (so all Spring modules share one version) and add the libraries:
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-framework-bom</artifactId>
<version>7.0.10</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>7.4.13.Final</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.1.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.13</version>
<scope>runtime</scope>
</dependency>
</dependencies>
What each one is for:
-
spring-context: the core container and annotations. -
spring-orm: Spring's JPA and Hibernate integration (it also bringsspring-jdbcandspring-tx). -
hibernate-core: Hibernate itself. -
HikariCP: the connection pool, as we discussed in the JDBC post. -
postgresql: the driver.
Use the newest versions available when you read this.
Step 2: Create the entity
Use the same Student class from earlier, in the package com.example. Nothing changes. It's plain JPA, so Spring and Hibernate both understand it.
Step 3: Create the repository
The repository is the class that talks to the database. It uses the standard EntityManager, which Spring injects:
package com.example;
import java.util.List;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
@Repository
public class StudentRepository {
@PersistenceContext
private EntityManager entityManager;
public void save(Student student) {
entityManager.persist(student);
}
public Student findById(Long id) {
return entityManager.find(Student.class, id);
}
public List<Student> findByMinMarks(int minMarks) {
return entityManager.createQuery(
"from Student s where s.marks >= :min order by s.marks desc",
Student.class)
.setParameter("min", minMarks)
.getResultList();
}
public void delete(Student student) {
entityManager.remove(student);
}
}
-
@Repositorymarks this class as the data-access layer. Spring registers it as a bean (and, if you add the right post-processor, translates persistence exceptions into Spring'sDataAccessExceptiontypes). -
@PersistenceContextasks Spring to inject anEntityManager. Notice there's no code that opens or closes it, and no transaction code. Spring handles those.
Step 4: Create the service
The service holds the business logic, and this is where transactions belong:
package com.example;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StudentService {
private final StudentRepository repository;
public StudentService(StudentRepository repository) {
this.repository = repository;
}
@Transactional
public Long addStudent(String name, int marks) {
Student student = new Student(name, marks);
repository.save(student);
return student.getId();
}
@Transactional(readOnly = true)
public Student getStudent(Long id) {
return repository.findById(id);
}
@Transactional
public void changeMarks(Long id, int marks) {
Student student = repository.findById(id);
student.setMarks(marks); // dirty checking: UPDATE at commit
}
@Transactional(readOnly = true)
public List<Student> topStudents(int minMarks) {
return repository.findByMinMarks(minMarks);
}
@Transactional
public void removeStudent(Long id) {
repository.delete(repository.findById(id));
}
}
-
@Transactionalon a method means: "start a transaction before this method, commit when it ends normally, roll back if a runtime exception escapes." -
readOnly = trueis a hint that this method only reads, which allows some optimizations. - Constructor injection gives us the repository.
- Look at
changeMarks: it changes a field and never calls "save". Because the student is managed inside the transaction, Hibernate notices the change and writes theUPDATEat commit.
Step 5: Configure Spring
Now we tell Spring how to build the database pieces. We use Java configuration:
package com.example;
import java.util.Properties;
import javax.sql.DataSource;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@ComponentScan("com.example")
@EnableTransactionManagement
public class AppConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/school");
config.setUsername("postgres");
config.setPassword(System.getenv().getOrDefault("DB_PASSWORD", "secret"));
config.setMaximumPoolSize(5);
return new HikariDataSource(config);
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setDataSource(dataSource);
factory.setPackagesToScan("com.example"); // find the @Entity classes
factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
Properties properties = new Properties();
properties.setProperty("hibernate.hbm2ddl.auto", "update");
properties.setProperty("hibernate.show_sql", "true");
factory.setJpaProperties(properties);
return factory;
}
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
Piece by piece:
-
@Configurationmarks a class that defines beans.@ComponentScanfinds our@Repositoryand@Serviceclasses.@EnableTransactionManagementturns on@Transactional. -
dataSource()builds the HikariCP connection pool. The password comes from an environment variable, withsecretas a local fallback. -
LocalContainerEntityManagerFactoryBeanis Spring's way of building the JPAEntityManagerFactory.setPackagesToScanfinds the@Entityclasses, so there's nopersistence.xml.HibernateJpaVendorAdaptertells Spring that Hibernate is our JPA provider. -
jpaPropertiespasses normal Hibernate settings. Here,updatecreates the table andshow_sqlprints the SQL. -
JpaTransactionManageris the object that actually starts and commits transactions for@Transactional.
Step 6: Run the application
package com.example;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class App {
public static void main(String[] args) {
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class)) {
StudentService service = context.getBean(StudentService.class);
Long id = service.addStudent("Asha", 88);
System.out.println("Saved with id " + id);
System.out.println("Found: " + service.getStudent(id));
service.changeMarks(id, 95);
System.out.println("After update: " + service.getStudent(id));
System.out.println("Top students: " + service.topStudents(90));
service.removeStudent(id);
System.out.println("Deleted. Remaining: " + service.topStudents(0).size());
}
}
}
Run it:
mvn compile exec:java -Dexec.mainClass=com.example.App
Output (mixed with the SQL lines that show_sql prints, and some start-up logging):
Saved with id 1
Found: Student{id=1, name='Asha', marks=88}
After update: Student{id=1, name='Asha', marks=95}
Top students: [Student{id=1, name='Asha', marks=95}]
Deleted. Remaining: 0
If you see a message about SLF4J finding no logging provider, that's harmless. Add an SLF4J binding (such as slf4j-simple) if you want to see the pool's logs.
Notice what's missing compared to the plain Hibernate program: no SessionFactory code, no openSession, no beginTransaction, no commit, no try/finally. Spring did all of it.
How Spring and Hibernate work together
The magic is a proxy. When you call a @Transactional method, you don't call the real object directly. You call a proxy that Spring wraps around it:
App code calls: service.changeMarks(1, 95)
|
v
SPRING PROXY around StudentService
1. JpaTransactionManager starts a transaction
(opens an EntityManager and ties it to the current thread)
|
v
StudentService.changeMarks(...) --> StudentRepository --> the injected EntityManager
| (it's a shared proxy that forwards to
| the EntityManager of the current transaction)
v
SPRING PROXY again
2. method finished normally? YES: commit (Hibernate flushes: UPDATE student ...)
NO, a RuntimeException: rollback
A few rules worth remembering:
- By default, Spring rolls back only for unchecked exceptions (
RuntimeExceptionand errors). A checked exception does not roll back, unless you add@Transactional(rollbackFor = Exception.class). - Calling a
@Transactionalmethod from another method in the same class skips the proxy, so no transaction starts. Call it from another bean instead. - Put
@Transactionalon public methods of your service layer, not on private methods or in the repository. - Entities you return from a service are detached after the method ends, so lazy relationships you didn't load can't be read later.
The shortcut: Spring Boot and Spring Data JPA
Writing AppConfig by hand teaches you what's going on. In everyday projects, Spring Boot does all of it automatically, and Spring Data JPA writes the repository for you. Generate a project at start.spring.io with the Spring Data JPA and PostgreSQL Driver dependencies (it picks matching versions). Then your configuration is a few lines in application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/school
spring.datasource.username=postgres
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
And the repository becomes an interface, with no implementation at all:
public interface StudentRepository extends JpaRepository<Student, Long> {
List<Student> findByMarksGreaterThanEqual(int marks);
}
Spring Data creates the implementation, including the query from the method name. Using it:
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
CommandLineRunner demo(StudentRepository repository) {
return args -> {
repository.save(new Student("Asha", 88));
repository.findByMarksGreaterThanEqual(80).forEach(System.out::println);
};
}
}
Student{id=1, name='Asha', marks=88}
Boot auto-configures the connection pool, Hibernate, the transaction manager and the entity scanning. Underneath, it's the same pieces you just built by hand, which is why learning the manual version first makes Boot much easier to understand.
Other things worth knowing
Configuration settings
Hibernate is configured with properties. The ones you'll meet first:
jakarta.persistence.jdbc.url=jdbc:postgresql://localhost:5432/school
jakarta.persistence.jdbc.user=postgres
jakarta.persistence.jdbc.password=secret
hibernate.hbm2ddl.auto=update
hibernate.show_sql=true
hibernate.format_sql=true
You can provide them in several ways, and the more specific one wins:
-
In code, as we did with
HibernatePersistenceConfiguration. - In a
hibernate.propertiesfile on the classpath. - In a
hibernate.cfg.xmlfile (read when you callnew Configuration().configure()). - In a standard JPA
persistence.xmlfile (used withPersistence.createEntityManagerFactory(...)). - In Spring, through
jpaPropertiesor Spring Boot'sapplication.properties.
The hibernate.hbm2ddl.auto values:
-
none: do nothing with the schema. -
validate: check that the tables match the entities, and fail if they don't. -
update: add missing tables and columns. -
create: drop and recreate the tables at start-up. -
create-drop: likecreate, and drop them again when the factory closes.
Caching
Hibernate has two levels of cache:
- First-level cache: always on. It lives in each session (the persistence context). That's why asking for the same row twice gave us the same object.
-
Second-level cache: optional. It's shared by all sessions, and needs a cache provider (for example JCache). You turn it on with
hibernate.cache.use_second_level_cache=trueand mark entities as cacheable. It can reduce database load for data that's read often and changes rarely, but cached data can become stale, so use it carefully.
Optimistic locking with @version
What if two users edit the same row at the same time? Hibernate can detect it with a version column:
@Version
private int version;
Hibernate adds the version to every update:
update student set marks = ?, version = 2 where id = ? and version = 1
If someone else already changed the row (so its version is no longer 1), no row matches, and Hibernate throws an OptimisticLockException instead of silently overwriting their work. You catch it and ask the user to reload and try again. It's called optimistic because it assumes conflicts are rare and doesn't lock rows in the database.
Managing the schema in real projects
hbm2ddl.auto=update is convenient for learning and prototypes, but it's risky for real databases: it can't safely rename or remove columns, and create and create-drop delete your data. In real projects:
- Manage the schema with a migration tool such as Flyway or Liquibase, which applies versioned SQL scripts.
- Set
hibernate.hbm2ddl.autotovalidate(ornone), so Hibernate only checks that your entities match the schema.
Logging the SQL
Seeing the SQL is the best way to learn what Hibernate does, and to catch problems like N+1. The simplest switches are hibernate.show_sql and hibernate.format_sql. In code:
.showSql(true, true, false) // show SQL, format it, don't use color
Typical output (the exact text depends on your version and database):
insert into student (marks,name) values (?,?)
select s1_0.id,s1_0.marks,s1_0.name from student s1_0 where s1_0.id=?
update student set marks=?,name=? where id=?
For more control, use your logging framework: set the logger org.hibernate.SQL to DEBUG to log statements, and org.hibernate.orm.jdbc.bind to TRACE to also see the values bound to the ? parameters (in Hibernate 6 and newer).
Best practices
- Code against the standard JPA API unless you need a Hibernate feature.
-
Make associations lazy (
fetch = FetchType.LAZY), and load related data on purpose withjoin fetch,@BatchSizeor entity graphs. - Watch for N+1 by checking the SQL log during development.
- Keep transactions short and put them in the service layer, not the controller or repository.
-
Use
readOnly = truefor methods that only read. - Use a connection pool such as HikariCP (Spring Boot does this by default).
- Never build queries by joining strings. Use named parameters.
- Don't expose entities directly in web APIs. Use separate data-transfer objects (DTOs), so lazy fields and internal details don't leak.
- Use projections or DTO queries for read-heavy screens, so you don't load full entities you don't need.
-
Be careful with
equalsandhashCodeon entities. Base them on a business key, or handle the generated id carefully. -
Don't use
CascadeType.ALLeverywhere. Cascade only where a child truly belongs to its parent. -
Use
@Versionfor entities that several users can edit. -
Manage the schema with Flyway or Liquibase in real projects, and keep
hbm2ddl.autoatvalidateornone. - Test against a real database (for example with Testcontainers) as well as an in-memory one, since SQL dialects differ.
- Read the SQL, and use plain SQL (native queries, JDBC or jOOQ) for heavy reports and bulk jobs.
Which one should I use?
-
JDBC or an ORM? Use an ORM for applications with many entities, relationships and CRUD screens. Use plain JDBC (or
JdbcTemplate, jOOQ or MyBatis) for simple scripts, heavy reporting and performance-critical SQL. Many projects mix both. -
The Hibernate
Sessionor the JPAEntityManager?EntityManagerby default.Sessionwhen you need Hibernate-only features. - Plain Hibernate, Spring Framework or Spring Boot? Plain Hibernate to learn the basics or for small tools. Spring Framework when you want to wire everything yourself. Spring Boot with Spring Data JPA for most new applications.
-
persistormerge?persistfor new objects,mergefor detached ones (and use the returned object). -
findorgetReference?findwhen you need the data.getReferencewhen you only need a handle, such as to set a foreign key. -
IDENTITYorSEQUENCE?IDENTITYis simple.SEQUENCEallows Hibernate to batch inserts better, and is usually the better choice for write-heavy tables. -
LAZYorEAGER?LAZYalmost always. Fetch what you need explicitly. - HQL, native SQL or Criteria? HQL for most queries, native SQL for database-specific or very complex queries, and Criteria when you must build queries dynamically in a type-safe way.
-
Which
hbm2ddl.auto?updatefor learning,validateornonefor real environments. -
Configuration in code, properties or XML? Code or properties for simplicity.
persistence.xmlif you need a standard JPA setup. In Spring, use Spring's configuration.
Common mistakes
-
The N+1 problem and lazy-loading errors. Looping over a lazy collection fires one query per item, and reading one after the session closed gives a
LazyInitializationException. Fetch what you need up front, and read lazy data inside the transaction. -
Using
createorcreate-dropon real data. These settings drop your tables at start-up. Usevalidateor a migration tool outside of local experiments. -
Forgetting the transaction. Changes made without a transaction (or without
commit) are not saved. In Spring, a missing@Transactionalhas the same effect. -
Following old tutorials.
session.save,update,saveOrUpdate,deleteandload, andjavax.persistenceimports, don't work in Hibernate 7. Usepersist,merge,remove,getReferenceandjakarta.persistence. -
Mixing up
persistandmerge. Callingpersiston a detached entity fails with a "detached entity passed to persist" error. And aftermerge, forgetting to use the returned object means your later changes aren't tracked. -
Entity rules and relationship bugs. A missing no-arg constructor, a
finalentity class or a missing@Idstops Hibernate from starting. And forgetting to set both sides of a bidirectional relationship leaves the foreign keynull.
Quick recap
- ORM maps classes to tables, objects to rows and fields to columns. It solves the object-relational impedance mismatch and removes JDBC boilerplate.
- Pros: less code, object-oriented thinking, relationships, portability, caching and change tracking. Cons: a learning curve, hidden SQL, performance traps like N+1, and it's not ideal for bulk or complex reporting.
-
Hibernate sits on top of JDBC. Its core pieces are the
SessionFactory(one per app), theSession(one per unit of work, with a persistence context) and theTransaction. - An entity is transient, persistent, detached or removed. Hibernate saves changes to persistent entities automatically (dirty checking) at flush or commit.
-
Without Maven, you collect the jars yourself, compile with
javac -cp "lib/*"and run withjava -cp. With Maven, you declarehibernate-coreand the PostgreSQL driver, and Maven does the rest. - Key methods:
persist,find,getReference,merge,remove,refresh,flush,clear,createQueryand HQL. Hibernate 7 removedsave,update,saveOrUpdate,deleteandload. -
Jakarta Persistence is the specification, and Hibernate is the implementation.
SessionextendsEntityManager. Code against JPA by default. - In Spring, you configure a
DataSource, aLocalContainerEntityManagerFactoryBeanwithHibernateJpaVendorAdapter, and aJpaTransactionManager. Then@Transactionalservices and@PersistenceContextrepositories do the rest. Spring Boot with Spring Data JPA automates it all. - Also remember: lazy loading and N+1, caching,
@Version, schema migrations and reading the SQL log.
Try it yourself
Here's a small practice idea: start PostgreSQL with Docker, then run the plain Hibernate program from this post, first with the jar folder approach and then with Maven. Add a Course entity with a one-to-many relationship to Student, save a course with three students and print them. Then turn on SQL logging, write a loop that reproduces the N+1 problem, count the queries, and fix it with join fetch.
For a bonus experiment, rebuild the same application in the Spring Framework, and watch each @Transactional method produce its SQL. Then replace the repository with a Spring Data JPA interface, and see how much code disappears. Finally, add @Version to Student, open two sessions that edit the same row, and trigger an OptimisticLockException on purpose.
If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.
Top comments (0)