DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Why ORMs Exist: The Object-Relational Mismatch

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?

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
Enter fullscreen mode Exit fullscreen mode

Here's what that means in practice:

  • Inheritance: Java has it, tables don't. How do you store Dog and Cat, which both extend Animal?
  • 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 Student holds a reference to a Course. In a database, the student table 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 ...
Enter fullscreen mode Exit fullscreen mode

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"));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The same two jobs with Hibernate:

session.persist(student);                           // INSERT
Student found = session.find(Student.class, id);    // SELECT ... WHERE id = ?
Enter fullscreen mode Exit fullscreen mode

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 Student to a Course as 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 UPDATE without 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 is hibernate-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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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 UPDATE only 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
Enter fullscreen mode Exit fullscreen mode

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;"
Enter fullscreen mode Exit fullscreen mode

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 + "}";
    }
}
Enter fullscreen mode Exit fullscreen mode

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 is SEQUENCE, which suits batching better.
  • @Column(nullable = false, length = 50): column settings. A field with no annotation (like marks) is mapped by default, to a column with the same name.

Rules for an entity class:

  • It needs an @Id field.
  • 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 old javax.persistence is 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)
)
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let's go through the new parts:

  • HibernatePersistenceConfiguration is 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 = update tells 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 the SessionFactory. We use try-with-resources so it's closed when the program ends.
  • openSession() gives us a Session for one unit of work. Each method opens its own and closes it automatically.
  • persist, find, remove are the basic operations, and the update works through dirty checking: we changed a field of a managed object, and Hibernate wrote the UPDATE at commit().
  • 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 :min is 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
Enter fullscreen mode Exit fullscreen mode

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 .pom file to read the dependency list, download each one, and repeat for those too. Then download the PostgreSQL driver postgresql-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.xml in 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
 Schema |  Name   | Type  |  Owner
--------+---------+-------+----------
 public | student | table | postgres
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • hibernate-core also brings the Jakarta Persistence API with it, so the jakarta.persistence.* imports compile.
  • The PostgreSQL driver uses runtime scope, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
null
Enter fullscreen mode Exit fullscreen mode

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:

  • persist is for new (transient) entities. After the call, the same object you passed is managed.
  • merge is 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
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

A few points:

  • Always use named parameters (:min) and setParameter. 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();
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode
true
Enter fullscreen mode Exit fullscreen mode

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() and detach(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();
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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         |
                             +----+-------+-------+-----------+
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

And in Student, add:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
private Course course;
Enter fullscreen mode Exit fullscreen mode

How to read this:

  • @ManyToOne (in Student) is the owning side: it has the foreign key column course_id.
  • @OneToMany(mappedBy = "course") (in Course) is the inverse side. mappedBy says "the foreign key is managed by the course field over there."
  • cascade = ALL passes operations like persist and remove from a course to its students.
  • orphanRemoval = true deletes a student that you remove from the list.
  • Always update both sides of the relationship, as addStudent does.

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
   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!)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Which one to code against

  • Default to the standard JPA API (EntityManager, JPQL, jakarta.persistence annotations). 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 write begin, commit or rollback.
  • It injects the EntityManager where 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

What each one is for:

  • spring-context: the core container and annotations.
  • spring-orm: Spring's JPA and Hibernate integration (it also brings spring-jdbc and spring-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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • @Repository marks 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's DataAccessException types).
  • @PersistenceContext asks Spring to inject an EntityManager. 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));
    }
}
Enter fullscreen mode Exit fullscreen mode
  • @Transactional on a method means: "start a transaction before this method, commit when it ends normally, roll back if a runtime exception escapes."
  • readOnly = true is 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 the UPDATE at 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Piece by piece:

  • @Configuration marks a class that defines beans. @ComponentScan finds our @Repository and @Service classes. @EnableTransactionManagement turns on @Transactional.
  • dataSource() builds the HikariCP connection pool. The password comes from an environment variable, with secret as a local fallback.
  • LocalContainerEntityManagerFactoryBean is Spring's way of building the JPA EntityManagerFactory. setPackagesToScan finds the @Entity classes, so there's no persistence.xml. HibernateJpaVendorAdapter tells Spring that Hibernate is our JPA provider.
  • jpaProperties passes normal Hibernate settings. Here, update creates the table and show_sql prints the SQL.
  • JpaTransactionManager is 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());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it:

mvn compile exec:java -Dexec.mainClass=com.example.App
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A few rules worth remembering:

  • By default, Spring rolls back only for unchecked exceptions (RuntimeException and errors). A checked exception does not roll back, unless you add @Transactional(rollbackFor = Exception.class).
  • Calling a @Transactional method from another method in the same class skips the proxy, so no transaction starts. Call it from another bean instead.
  • Put @Transactional on 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
Enter fullscreen mode Exit fullscreen mode

And the repository becomes an interface, with no implementation at all:

public interface StudentRepository extends JpaRepository<Student, Long> {

    List<Student> findByMarksGreaterThanEqual(int marks);
}
Enter fullscreen mode Exit fullscreen mode

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);
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
Student{id=1, name='Asha', marks=88}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You can provide them in several ways, and the more specific one wins:

  • In code, as we did with HibernatePersistenceConfiguration.
  • In a hibernate.properties file on the classpath.
  • In a hibernate.cfg.xml file (read when you call new Configuration().configure()).
  • In a standard JPA persistence.xml file (used with Persistence.createEntityManagerFactory(...)).
  • In Spring, through jpaProperties or Spring Boot's application.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: like create, 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=true and 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;
Enter fullscreen mode Exit fullscreen mode

Hibernate adds the version to every update:

   update student set marks = ?, version = 2 where id = ? and version = 1
Enter fullscreen mode Exit fullscreen mode

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.auto to validate (or none), 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
Enter fullscreen mode Exit fullscreen mode

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=?
Enter fullscreen mode Exit fullscreen mode

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 with join fetch, @BatchSize or 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 = true for 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 equals and hashCode on entities. Base them on a business key, or handle the generated id carefully.
  • Don't use CascadeType.ALL everywhere. Cascade only where a child truly belongs to its parent.
  • Use @Version for entities that several users can edit.
  • Manage the schema with Flyway or Liquibase in real projects, and keep hbm2ddl.auto at validate or none.
  • 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 Session or the JPA EntityManager? EntityManager by default. Session when 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.
  • persist or merge? persist for new objects, merge for detached ones (and use the returned object).
  • find or getReference? find when you need the data. getReference when you only need a handle, such as to set a foreign key.
  • IDENTITY or SEQUENCE? IDENTITY is simple. SEQUENCE allows Hibernate to batch inserts better, and is usually the better choice for write-heavy tables.
  • LAZY or EAGER? LAZY almost 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? update for learning, validate or none for real environments.
  • Configuration in code, properties or XML? Code or properties for simplicity. persistence.xml if you need a standard JPA setup. In Spring, use Spring's configuration.

Common mistakes

  1. 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.
  2. Using create or create-drop on real data. These settings drop your tables at start-up. Use validate or a migration tool outside of local experiments.
  3. Forgetting the transaction. Changes made without a transaction (or without commit) are not saved. In Spring, a missing @Transactional has the same effect.
  4. Following old tutorials. session.save, update, saveOrUpdate, delete and load, and javax.persistence imports, don't work in Hibernate 7. Use persist, merge, remove, getReference and jakarta.persistence.
  5. Mixing up persist and merge. Calling persist on a detached entity fails with a "detached entity passed to persist" error. And after merge, forgetting to use the returned object means your later changes aren't tracked.
  6. Entity rules and relationship bugs. A missing no-arg constructor, a final entity class or a missing @Id stops Hibernate from starting. And forgetting to set both sides of a bidirectional relationship leaves the foreign key null.

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), the Session (one per unit of work, with a persistence context) and the Transaction.
  • 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 with java -cp. With Maven, you declare hibernate-core and the PostgreSQL driver, and Maven does the rest.
  • Key methods: persist, find, getReference, merge, remove, refresh, flush, clear, createQuery and HQL. Hibernate 7 removed save, update, saveOrUpdate, delete and load.
  • Jakarta Persistence is the specification, and Hibernate is the implementation. Session extends EntityManager. Code against JPA by default.
  • In Spring, you configure a DataSource, a LocalContainerEntityManagerFactoryBean with HibernateJpaVendorAdapter, and a JpaTransactionManager. Then @Transactional services and @PersistenceContext repositories 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)