Query Plan Caching and PreparedStatement Reuse
Query Plan Caching and PreparedStatement Reuse
Every JPQL or Criteria query that Hibernate executes goes through parsing, semantic analysis, and SQL generation before it reaches the database. This compilation step is expensive. Hibernate caches the result to avoid repeating it.
The Lie
Query compilation is fast. Hibernate handles caching transparently. You do not need to think about it.
The Reality
Hibernate maintains a query plan cache (QueryPlanCache in Hibernate 5, QueryInterpretationCache in Hibernate 6). When you execute a JPQL query, Hibernate:
- Checks the plan cache using the JPQL string as the key
- If cached: reuses the parsed AST and generated SQL
- If not cached: parses JPQL into an AST, validates against the metamodel, generates SQL, caches the result
For a stable application with a fixed set of JPQL queries, the cache reaches steady state quickly. Every query hits the cache after the first execution.
The problem starts with dynamic queries, particularly IN clauses with varying parameter counts.
The Evidence
// BAD: IN clause with variable parameter count
public List<Product> findByIds(List<Long> ids) {
return entityManager.createQuery(
"SELECT p FROM Product p WHERE p.id IN :ids", Product.class)
.setParameter("ids", ids)
.getResultList();
}
// When called with 3 ids:
// JPQL: SELECT p FROM Product p WHERE p.id IN :ids
// SQL: select ... from products p where p.id in (?, ?, ?)
//
// When called with 7 ids:
// JPQL: SELECT p FROM Product p WHERE p.id IN :ids
// SQL: select ... from products p where p.id in (?, ?, ?, ?, ?, ?, ?)
//
// The JPQL string is the same, but the generated SQL differs.
// Hibernate 6 caches based on the JPQL + parameter binding shapes.
// Each distinct parameter count creates a new cache entry.
//
// With ids.size() ranging from 1 to 1000:
// Up to 1000 cache entries for ONE logical query.
Each cache entry consumes memory (the parsed AST, the generated SQL string, parameter binding metadata). With multiple IN-clause queries and varying parameter counts, the plan cache grows unboundedly.
Hibernate 6 introduced IN clause parameter padding to mitigate this:
spring:
jpa:
properties:
hibernate:
query:
in_clause_parameter_padding: true
With padding enabled, Hibernate rounds up the parameter count to the next power of 2:
- 3 parameters → padded to 4:
IN (?, ?, ?, ?) - 7 parameters → padded to 8:
IN (?, ?, ?, ?, ?, ?, ?, ?) - 13 parameters → padded to 16
- 100 parameters → padded to 128
The last parameter value is repeated to fill the padding. This reduces 1000 possible cache entries to ~10 (powers of 2 from 1 to 1024).
The Fix
1. Enable IN Clause Parameter Padding
spring:
jpa:
properties:
hibernate:
query:
in_clause_parameter_padding: true
plan_cache_max_size: 2048 # default, adjust if needed
2. Monitor Plan Cache Size
// BETTER: Expose plan cache statistics
@Component
public class HibernateCacheMetrics {
@Autowired
private EntityManagerFactory emf;
@Scheduled(fixedRate = 60000)
public void logCacheStats() {
SessionFactory sf = emf.unwrap(SessionFactory.class);
Statistics stats = sf.getStatistics();
log.info("Query plan cache - hit: {}, miss: {}, put: {}",
stats.getQueryPlanCacheHitCount(),
stats.getQueryPlanCacheMissCount(),
stats.getQueryPlanCachePutCount());
}
}
Enable statistics:
spring:
jpa:
properties:
hibernate:
generate_statistics: true # Disable in production after tuning
A healthy application shows a high hit rate (>95%) after warmup. If the miss count keeps climbing, you have cache pollution from dynamic queries.
3. Use Named Queries for Static JPQL
Named queries are parsed and validated at startup, not at first execution. They enter the plan cache immediately and never cause a miss.
@Entity
@NamedQuery(
name = "Order.findByStatusAndDateRange",
query = """
SELECT o FROM Order o
WHERE o.status = :status
AND o.createdAt BETWEEN :start AND :end
"""
)
public class Order {
// ...
}
// Usage:
List<Order> orders = entityManager
.createNamedQuery("Order.findByStatusAndDateRange", Order.class)
.setParameter("status", OrderStatus.CONFIRMED)
.setParameter("start", startDate)
.setParameter("end", endDate)
.getResultList();
Spring Data @Query annotations are effectively named queries. They are parsed at application startup, validated against the entity model, and cached.
4. PreparedStatement Caching at the Connection Pool Level
The database also caches execution plans for PreparedStatements. HikariCP can cache PreparedStatements per connection:
spring:
datasource:
hikari:
data-source-properties:
cachePrepStmts: true # MySQL
prepStmtCacheSize: 250 # MySQL
prepStmtCacheSqlLimit: 2048 # MySQL
# PostgreSQL uses server-side prepared statements
# controlled by prepareThreshold (default: 5)
For PostgreSQL with the JDBC driver, prepared statements are promoted to server-side prepared statements after 5 executions (configurable via prepareThreshold). Server-side prepared statements skip query parsing and planning on the database side.
The Cost Model
| Issue | Impact | Fix |
|---|---|---|
| IN clause cache pollution (1000 entries) | ~5-15 MB heap, increasing miss rate | Parameter padding: reduces to ~10 entries |
| Dynamic JPQL string concatenation | New cache entry per unique string | Use parameterized queries, never concatenate |
| Plan cache too small | Frequent evictions, repeated parsing | Increase plan_cache_max_size |
| Missing PreparedStatement caching | Database re-parses every query | Enable connection pool PS cache |
JPQL compilation takes 0.5-5ms per query depending on complexity. For a cache hit, the cost is a HashMap lookup (~0.001ms). At 1000 queries/second, a 100% cache miss rate adds 0.5-5 seconds of cumulative overhead per second. The plan cache is not optional for high-throughput applications.