java5 min read

Hibernate Tutorial: Learn ORM from Scratch (2026)

Hibernate Tutorial: Learn ORM from Scratch (2026)

Published:  |  Category: Java  |  Reading time: ~15 min
Hibernate Tutorial: Learn ORM from Scratch (2026)

Hibernate is the most mature JPA implementation and the de-facto standard for object-relational mapping in Java. It maps database tables to Java objects, generates SQL, and manages a first-level cache that reduces redundant database round trips. I have worked on Hibernate-backed systems handling billions of rows, and the difference between a well-tuned mapping and a naive one is measured in orders of magnitude of performance.

This tutorial covers entity mapping, relationships, fetching strategies, caching, and the Hibernate query APIs. The goal is to help you avoid the N+1 query problem and other common ORM pitfalls that slow down production applications.

Entity Mapping Fundamentals

Every entity class maps to a database table using @Entity and @Table. The @Id field defines the primary key, and @GeneratedValue chooses the ID generation strategy. Use SEQUENCE generation for PostgreSQL/Oracle and IDENTITY for MySQL to align with database-native features.

JPA 2.2 supports Java 8 date/time types, Optional, and Stream. Use LocalDate, LocalDateTime, and Duration directly in entities. Avoid java.util.Date — it is mutable and its API is error-prone.

@Entity
@Table(name = "customers")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq")
    @SequenceGenerator(name = "customer_seq", allocationSize = 100)
    private Long id;

    @Column(nullable = false, length = 150)
    private String name;

    @Column(unique = true)
    private String email;

    @CreationTimestamp
    private LocalDateTime createdAt;
}

Relationships: @OneToMany, @ManyToOne, @ManyToMany

Mapping relationships correctly is the hardest part of Hibernate. @ManyToOne is the owning side of a bidirectional OneToMany/ManyToOne relationship; it holds the foreign key column. Always use LAZY fetching for collections to avoid loading entire tables into memory.

@ManyToMany should be a last resort. A join table with extra columns (like created_at or role) requires an intermediate entity. Model it as two @OneToMany relationships pointing to an explicit join entity instead.

@Entity
@Table(name = "orders")
public class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List lines = new ArrayList<>();
}

// Bidirectional helper
public void addLine(OrderLine line) {
    lines.add(line);
    line.setOrder(this);
}

Fetching Strategies: Lazy vs Eager

LAZY fetching loads associated entities only when accessed for the first time. EAGER loads them immediately, often causing massive join queries or multiple select statements. Default to LAZY for all associations and use JOIN FETCH in queries when you know you need the related data.

The N+1 query problem occurs when iterating over a collection triggers a separate query for each element. Fix it with entity graphs, batch fetching (@BatchSize), or join fetches. Monitor SQL logs during development to catch N+1 before it reaches production.

// JPQL with JOIN FETCH
@Query("SELECT c FROM Customer c JOIN FETCH c.orders WHERE c.id = :id")
Customer findWithOrders(@Param("id") Long id);

// Entity graph approach
@NamedEntityGraph(name = "Customer.orders",
    attributeNodes = @NamedAttributeNode("orders"))
@Entity
public class Customer { ... }

// Usage: @EntityGraph("Customer.orders") on repository method

Caching: First-Level, Second-Level, Query Cache

Hibernate's first-level cache (persistence context) caches entities within a session — repeated reads of the same ID return the cached instance without SQL. The second-level cache is optional and shared across sessions; configure it with Hazelcast, Redis, or EHCache for entities that are read frequently and rarely modified.

Query cache stores query results, but it invalidates on any write to the related tables. Use it sparingly for reference data like country lists or product categories. For most use cases, database query performance + first-level cache is sufficient.

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "customer")
public class Customer { ... }

// persistence.xml or application.properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory

Inheritance Mapping Strategies

Hibernate supports three inheritance strategies. SINGLE_TABLE stores all subclasses in one table with a discriminator column — fast queries but nullable columns. JOINED creates separate tables per class with joins — normalized but slower reads. TABLE_PER_CLASS creates independent tables — no joins but violates foreign key uniqueness.

SINGLE_TABLE is the default and works well when subclasses share most fields. Use JOINED when subclasses have many unique fields. Avoid TABLE_PER_CLASS unless you know what you are doing — it breaks identity generation and polymorphic queries.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING)
public abstract class Payment {
    @Id private Long id;
    private BigDecimal amount;
}

@Entity
@DiscriminatorValue("CREDIT_CARD")
public class CreditCardPayment extends Payment {
    private String cardLastFour;
    private String cardholderName;
}

Batch Processing and Stateless Sessions

For bulk operations, Hibernate's StatelessSession bypasses the persistence context entirely — no caching, no dirty checking, and no cascading. This makes it significantly faster for batch inserts and updates. Combine with JDBC batch size configuration for optimal throughput when processing thousands of records.

Use scrollable result sets with stateless sessions for read-only batch processing. For write-heavy batch jobs, flush and clear the persistence context periodically to prevent OutOfMemoryErrors from accumulated managed entities.

StatelessSession session = sessionFactory.openStatelessSession();
Transaction tx = session.beginTransaction();

for (int i = 0; i < records.size(); i++) {
    Product product = new Product(records.get(i));
    session.insert(product);
    
    if (i % 50 == 0) {
        session.flush();
        session.clear();
    }
}
tx.commit();
session.close();

// JDBC batch configuration
session.createSQLQuery("INSERT INTO products (name, price) VALUES (?, ?)")
    .setParameter(0, "Laptop")
    .setParameter(1, 999.99)
    .executeUpdate();

Frequently Asked Questions

What is the N+1 query problem and how do I fix it?

N+1 happens when Hibernate executes one query to load entities and then N additional queries to load associations for each entity. Fix it with JOIN FETCH, @EntityGraph, or @BatchSize. Enable Hibernate's SQL logging to detect it.

Should I use Hibernate or plain JDBC?

Hibernate excels when you have complex object graphs and want to reduce boilerplate. Use JDBC or JdbcTemplate for bulk operations, batch inserts, or when you need fine-grained SQL control. Many projects use both — Hibernate for reads, JDBC for writes.

What is the difference between merge and update?

update() reattaches a detached entity and throws an exception if the entity already exists in the session. merge() copies state from a detached entity to a managed entity and returns the managed instance, handling both new and existing entities safely.

Why is my Hibernate query slow?

Common causes: N+1 queries, missing database indexes, eager fetching of large collections, and SELECT * pulling unnecessary columns. Enable slow query logging, examine generated SQL, and add proper indexes. Use the Hibernate Statistics API for diagnostics.

Originally published on Ayodhyyya. Last updated June 1, 2026.