← Spring Boot & Java Frameworks

Spring Data JPA Specifications and Dynamic IN Predicates: How Hibernate's QueryPlanCache Explodes

Your elegant, composable filter system is generating 14,847 unique JPQL strings. Allow me to show you the heap dump.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 28 min read
Each viewport width generated a unique query plan. We have run out of cache.

Good afternoon. Your filter page is composing an OOM, one Specification at a time.

I have been reviewing your product search page. The one with the sidebar filters — categories, brands, colors, sizes, price range. It is a well-built page. The Specifications are clean. The controller composes them with a care I genuinely admire. Each predicate is a small, testable function. The .and() chains read like a declaration of intent.

It is also, I regret to inform you, filling your heap with tens of thousands of cached query plans.

Spring Data JPA Specifications are a genuinely good idea. You define small, composable predicate functions. You combine them with .and() and .or(). Your filter UI sends a set of optional parameters, and the controller stitches together only the relevant predicates. The pattern is clean, testable, and maintainable. It appears in the official Spring Data documentation, in Baeldung tutorials, in conference talks. It is the recommended approach. I am not here to argue otherwise.

I am here because the recommended approach has a memory characteristic that the documentation does not mention, the tutorials do not warn about, and the conference talks do not cover. And by the time you discover it, your heap dump will be 800 MB of query plan cache entries.

The general case of Hibernate's QueryPlanCache consuming heap via IN-clause variants is well documented — the QueryPlanCache OOM guide covers it in the detail it deserves. A single @Query method with a variable-length list parameter generates one cache entry per distinct list size. That alone is worth attending to.

But Specifications introduce a second dimension that elevates the problem from linear to combinatorial. Not only does each IN clause vary in length, but each combination of active filters produces a structurally different JPQL string. You are multiplying filter-combination variants by parameter-length variants. The result is multiplicative, and the heap pays the full cost.

The Specification pattern that every tutorial teaches

Consider a product catalog with five optional filters. This is the standard Spring Data JPA pattern — you will find nearly identical code in the official Spring Data JPA documentation.

ProductSpecification.java
// A perfectly reasonable ProductSpecification class.
// Every Spring Boot tutorial teaches you to build these.

public class ProductSpecification {

    public static Specification<Product> hasCategories(List<Long> categoryIds) {
        return (root, query, cb) ->
            root.get("category").get("id").in(categoryIds);
    }

    public static Specification<Product> hasBrands(List<Long> brandIds) {
        return (root, query, cb) ->
            root.get("brand").get("id").in(brandIds);
    }

    public static Specification<Product> hasColors(List<String> colors) {
        return (root, query, cb) ->
            root.get("color").in(colors);
    }

    public static Specification<Product> hasSizes(List<String> sizes) {
        return (root, query, cb) ->
            root.get("size").in(sizes);
    }

    public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) ->
            cb.between(root.get("price"), min, max);
    }
}

Each method returns a Specification<Product> — a lambda that produces a JPA Predicate from the CriteriaBuilder. They are meant to be composed. This is the selling point of the pattern: you write each filter once, test it in isolation, and combine them as needed.

And the controller that composes them:

ProductController.java
@GetMapping("/products")
public Page<Product> search(
        @RequestParam(required = false) List<Long> categories,
        @RequestParam(required = false) List<Long> brands,
        @RequestParam(required = false) List<String> colors,
        @RequestParam(required = false) List<String> sizes,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(required = false) BigDecimal maxPrice,
        Pageable pageable) {

    Specification<Product> spec = Specification.where(null);

    if (categories != null && !categories.isEmpty()) {
        spec = spec.and(ProductSpecification.hasCategories(categories));
    }
    if (brands != null && !brands.isEmpty()) {
        spec = spec.and(ProductSpecification.hasBrands(brands));
    }
    if (colors != null && !colors.isEmpty()) {
        spec = spec.and(ProductSpecification.hasColors(colors));
    }
    if (sizes != null && !sizes.isEmpty()) {
        spec = spec.and(ProductSpecification.hasSizes(sizes));
    }
    if (minPrice != null && maxPrice != null) {
        spec = spec.and(ProductSpecification.priceBetween(minPrice, maxPrice));
    }

    return productRepository.findAll(spec, pageable);
}

// How many unique JPQL strings can this generate?
//
// 5 optional filters. Each is present or absent: 2^5 = 32 combinations.
// 4 of those filters are IN clauses with variable-length lists.
// If each IN clause varies from 1-50 elements:
//   50 * 50 * 50 * 50 * 32 = 200,000,000 potential unique query strings.
//
// In practice, your users won't hit all 200 million.
// But 10,000-50,000 unique strings in a busy e-commerce app?
// That's a normal Tuesday.

Five optional filters. Four of them accept variable-length lists. The comment at the bottom does the arithmetic: 200 million potential unique query strings. Your actual traffic will generate a fraction of that. But "a fraction of 200 million" is still a number that should concern you.

I should note what makes this particularly insidious. This is not poorly written code. This is not an anti-pattern. This is the recommended pattern, implemented correctly, and it produces catastrophic cache behavior as a direct consequence of how Hibernate's query compilation works. The developer who wrote this code did everything right. The framework did not.

Why Specifications are worse than @Query for cache pressure

The reason Specifications are more dangerous than plain @Query methods for cache explosion is structural, and the distinction matters.

A @Query method always produces the same query shape. The SQL structure is fixed at compile time — only the parameter count varies when you pass a list. If you have a method findByIdIn(List<Long> ids), Hibernate generates one JPQL template and the only variation is whether the IN clause contains 3 or 7 or 42 parameters. One dimension of variation.

A Specification composes the query at runtime. The structure itself changes based on which predicates are active. A request with categories + brands produces a different JPQL string than a request with categories + colors. Not just different parameter values — a different query entirely, with different WHERE clause structure, different predicate count, different join paths. This is a second dimension of variation, and it multiplies with the first.

Generated JPQL from CriteriaBuilder
// What CriteriaBuilder actually generates under the hood.
//
// Request 1: categories=[1,2,3], brands=[5,7], colors=null, sizes=null, price=null
// Hibernate JPQL:
SELECT generatedAlias0
FROM Product AS generatedAlias0
WHERE (generatedAlias0.category.id IN (1L, 2L, 3L))
  AND (generatedAlias0.brand.id IN (5L, 7L))

// Request 2: categories=[1,2,3,4], brands=[5,7], colors=null, sizes=null, price=null
// Hibernate JPQL (note: ONE extra category):
SELECT generatedAlias0
FROM Product AS generatedAlias0
WHERE (generatedAlias0.category.id IN (1L, 2L, 3L, 4L))
  AND (generatedAlias0.brand.id IN (5L, 7L))

// Request 3: categories=[1,2,3], brands=[5], colors=["red","blue"], sizes=null, price=null
// Hibernate JPQL (different filter combination entirely):
SELECT generatedAlias0
FROM Product AS generatedAlias0
WHERE (generatedAlias0.category.id IN (1L, 2L, 3L))
  AND (generatedAlias0.brand.id IN (5L))
  AND (generatedAlias0.color IN ('red', 'blue'))

// Three requests. Three distinct JPQL strings.
// Three separate QueryPlanCache entries.
// Three separate parse-validate-compile cycles.
// Three separate PostgreSQL prepared statements.

Three requests. Three distinct JPQL strings. Three separate entries in the QueryPlanCache. And each one went through a full parse-validate-compile cycle before being stored.

Three dimensions of variation are at work here:

  1. Filter presence: Which predicates are included (25 = 32 structural shapes from 5 optional filters)
  2. List length: How many elements each IN clause contains (50 sizes per filter, if elements range from 1 to 50)
  3. Interaction: Every combination of the above multiplied together

A single @Query method with one IN clause and list sizes from 1 to 50 generates 50 cache entries. The same logic expressed as Specifications with four IN-clause filters generates up to 32 * 504 = 200,000,000 unique strings. The QueryPlanCache default of 2,048 entries will be saturated almost instantly, and the cache will churn continuously — evicting plans you need moments later, re-parsing them, evicting again.

Inside the QueryPlanCache: why the full string is the key

To understand why this cannot be fixed at the cache layer, you need to see how the cache actually works.

QueryPlanCache internals
// Inside Hibernate's QueryPlanCache: how the cache key works.
//
// The cache key is the FULL JPQL/HQL string. Not a hash. Not a template.
// The entire string, character for character.
//
// QueryPlanCache.java (simplified from Hibernate 5.x source):
//
// public class QueryPlanCache {
//     private final BoundedConcurrentHashMap<String, HQLQueryPlan> queryPlanCache;
//     private final BoundedConcurrentHashMap<ParameterMetadataKey, ParameterMetadata>
//         parameterMetadataCache;
//
//     public HQLQueryPlan getHQLQueryPlan(String queryString, ...) {
//         HQLQueryPlan plan = queryPlanCache.get(queryString);
//                                           // ^^^^^^^^^^^ the FULL string
//         if (plan == null) {
//             plan = new HQLQueryPlan(queryString, ...);  // expensive
//             queryPlanCache.put(queryString, plan);
//         }
//         return plan;
//     }
// }
//
// This means:
//   "WHERE x IN (1, 2, 3)"       -> cache miss
//   "WHERE x IN (1, 2, 3, 4)"    -> cache miss
//   "WHERE x IN (1, 2)"          -> cache miss
//
// Three strings. Three cache entries. Three parse cycles.
// The values are baked into the key. There is no parameterization.

The cache key is the complete JPQL string. Not a normalized form. Not a template with parameter placeholders. The entire string, including the inlined literal values that CriteriaBuilder.in() places directly into the query text.

This is a deliberate design choice by Hibernate, not a bug. The query plan depends on the query structure, and Hibernate cannot know whether two different-length IN clauses will produce the same execution plan. So it caches conservatively: different string, different plan. The correctness is unimpeachable. The memory consequences, for dynamic query builders like Specifications, are severe.

This also explains why the problem is invisible to most debugging approaches. The queries return correct results. Each individual query is fast. The application works perfectly for weeks or months. The cache fills gradually. The GC pressure increases gradually. And then, one afternoon during a flash sale when your filter UI sees 4x normal traffic, the heap runs out.

The silent accumulation: why this goes unnoticed

I have diagnosed this issue in production systems that ran without incident for months. The cache does not announce itself. There is no log message when the 2,048th entry is added. There is no warning when eviction begins. There is no metric, in Spring Boot's default actuator endpoints, that tracks QueryPlanCache size or churn rate.

What you observe instead is a gradual deterioration of unrelated metrics. Response times at the 95th and 99th percentile creep upward. GC pause times increase. Memory usage grows over days. Operators attribute this to traffic growth, to new features, to seasonal load. They increase the heap. The problem scales with them.

The two most dangerous characteristics of this issue are its patience and its proportionality. It does not crash your application the first day. It grows exactly in proportion to how many distinct filter combinations your users explore. A filter UI with 3 options and 10 items each might never trigger it. A filter UI with 5 options and 50 items each is a certainty. And the transition from "fine" to "crisis" happens not when you add the fifth filter, but when your traffic volume crosses the threshold where most filter combinations get exercised within a single JVM lifecycle.

This is the worst kind of production issue: one that correlates with success. The more popular your filter page becomes, the faster the cache fills.

Reproducing the explosion with JMeter and VisualVM

This is not theoretical. Here is how to reproduce it in 15 minutes on your local machine.

JMeter reproduction setup
// Reproducing the cache explosion with JMeter.
// This test script generates realistic filter traffic.

// 1. Create a CSV file with filter combinations:
// categories,brands,colors,sizes
// "1,2,3","5,7","",""
// "1,2,3,4","5,7","",""
// "1,2,3","5","red,blue",""
// "1,2,3,4,5,6,7","5,7,12","red","S,M,L"
// ... (500 rows of realistic combinations)

// 2. JMeter Thread Group configuration:
//    Threads: 50
//    Ramp-up: 10 seconds
//    Loop count: 100
//    Total requests: 5,000

// 3. Connect VisualVM to the running Spring Boot process.
//    Navigate to: Monitor > Heap
//    Watch for the sawtooth pattern:
//      - Heap climbs steadily (cache filling)
//      - GC fires (partial relief)
//      - Heap climbs higher (cache refilling)
//      - GC fires more frequently
//      - Heap plateau keeps rising

// 4. After the test, trigger a heap dump:
jmap -dump:live,format=b,file=spec-cache.hprof <pid>

// 5. Open in Eclipse MAT.
//    Leak Suspects report will point directly to:
//    org.hibernate.engine.query.spi.QueryPlanCache

The key observation in VisualVM is the heap sawtooth. Under normal operation, a Spring Boot application's heap follows a gentle sawtooth: allocations climb, GC collects, heap drops. The period is seconds to minutes. The amplitude is modest.

Under Specification cache explosion, the sawtooth accelerates. GC fires every few seconds. Each collection recovers less memory. The floor keeps rising. The application spends 15-30% of its CPU time in GC before it finally runs out of room entirely.

GC log showing cache pressure
// GC log entries during Specification cache explosion.
// Enable with: -Xlog:gc*:file=gc.log:time,uptime,level,tags
//
// [2026-03-05T14:22:01.447+0000] GC(412) Pause Young (Normal)
//   (G1 Evacuation Pause) 1847M->1702M(2048M) 12.441ms
// [2026-03-05T14:22:01.891+0000] GC(413) Pause Young (Normal)
//   (G1 Evacuation Pause) 1849M->1714M(2048M) 14.221ms
// [2026-03-05T14:22:02.204+0000] GC(414) Pause Young (Normal)
//   (G1 Evacuation Pause) 1852M->1728M(2048M) 16.883ms
// [2026-03-05T14:22:02.488+0000] GC(415) Pause Young (Concurrent Start)
//   (G1 Evacuation Pause) 1856M->1741M(2048M) 18.102ms
// [2026-03-05T14:22:02.502+0000] GC(416) Concurrent Mark Cycle
//
// Notice three things:
//   1. GC interval: 400ms apart (healthy apps: 10-60 seconds)
//   2. Recovery shrinking: 145MB -> 135MB -> 124MB -> 115MB
//   3. Floor rising: 1702M -> 1714M -> 1728M -> 1741M
//
// The cache entries are long-lived objects promoted to old gen.
// Young GC cannot collect them. Each cycle reclaims less.
// When the floor reaches -Xmx, you get a Full GC. Then another.
// Then: java.lang.OutOfMemoryError: Java heap space.

The GC log makes the progression unmistakable. Healthy applications show GC intervals of 10-60 seconds with consistent recovery. Under cache pressure, the interval collapses to sub-second, recovery diminishes with each cycle, and the floor rises inexorably toward your -Xmx ceiling.

You do not need to wait for the OOM. The performance degradation from GC thrashing begins well before the heap is full. A 2 GB heap with 800 MB consumed by QueryPlanCache entries leaves 1.2 GB for everything else — but the constant allocation and eviction of cache entries generates enough GC work to increase p99 latency by 40-200%. Your SLAs break before your JVM does.

Reading the heap dump

After 5,000 requests with 500 realistic filter combinations, the heap dump tells the story with precision:

Eclipse MAT dominator tree
-- Eclipse MAT: Dominator Tree after 5,000 specification queries
--
-- Class                                          | Retained Heap
-- -----------------------------------------------+--------------
-- SessionFactoryImpl                             | 847 MB
--   QueryPlanCache                               | 831 MB
--     BoundedConcurrentHashMap                   | 828 MB
--       HQLQueryPlanKey entries                  | 14,847
--       ParameterMetadataKey entries             | 14,847
--
-- Total unique JPQL strings generated: 14,847
-- From just 5,000 requests with 500 filter combinations.
--
-- Why more entries than combinations?
-- Each filter combo with different list LENGTHS is a new string.
-- categories=[1,2,3] + brands=[5,7] is one entry.
-- categories=[1,2,3,4] + brands=[5,7] is another entry.
-- Same filters. Different list sizes. Different cache keys.
--
-- Memory per entry (average): 55.9 KB
-- 14,847 entries * 55.9 KB = 830 MB
--
-- Your -Xmx was 1024m. You are at 81% heap just from query plans.

14,847 unique JPQL strings from 500 filter combinations. The multiplication comes from list-length variance within each combination. A user who selects 3 categories and 2 brands generates a different cache entry than one who selects 4 categories and 2 brands. Same filter combination, different parameter counts, different JPQL, different cache key.

At 55.9 KB per entry, those 14,847 entries consume 830 MB. On a heap configured for 1,024 MB, you are already in crisis. And this was from a modest test — 50 threads, 5,000 requests. Production traffic with real users exploring a filter UI will generate far more combinations over the course of a day.

Eclipse MAT's OQL query language lets you inspect the cache contents directly, which is particularly useful for identifying which Specifications are generating the most variants:

Eclipse MAT OQL for cache inspection
-- Eclipse MAT OQL to inspect the cache contents directly.
-- This helps you identify which Specifications are the worst offenders.

-- Count entries by query prefix (first 80 chars):
SELECT toString(key).substring(0, 80) AS query_prefix,
       COUNT(*) AS entry_count
FROM java.util.HashMap$Node
WHERE toString(key) LIKE 'SELECT%Product%'
GROUP BY toString(key).substring(0, 80)
ORDER BY entry_count DESC;

-- Typical output:
-- query_prefix                                                   | entry_count
-- ---------------------------------------------------------------+------------
-- SELECT generatedAlias0 FROM Product AS generatedAlias0 WHERE ( | 4,231
-- SELECT generatedAlias0 FROM Product AS generatedAlias0 WHERE g | 3,892
-- SELECT count(generatedAlias0) FROM Product AS generatedAlias0  | 3,412
-- SELECT generatedAlias0 FROM Product AS generatedAlias0 ORDER B | 2,109
--
-- Note the count query: Pageable adds a count query variant for
-- every data query variant. Your cache entries just doubled.

Notice the count query variants in the output. When you use Pageable with Specifications — and you almost certainly do — Hibernate generates both a data query and a SELECT count(*) query for each request. Both go through CriteriaBuilder. Both produce unique JPQL strings. Both are cached separately. Your effective variant count doubles.

Cache growth by filter configuration

The growth is multiplicative. Each additional IN-clause filter multiplies the cache pressure by the range of list sizes for that filter:

Filter configurationTheoretical variantsActual cache entriesHeap usage
1 IN clause, fixed length11~56 KB
1 IN clause, 1-50 elements5050~2.7 MB
2 IN clauses, 1-50 each2,5002,500~137 MB
3 IN clauses, 1-50 each125,000~2,048*~112 MB*
4 IN clauses, 1-50 each6,250,000~2,048*~112 MB*
4 IN + 2 optional range~25,000,000~2,048*~112 MB*

* Capped at QueryPlanCache default max size of 2,048. Entries are continuously evicted and re-parsed, causing GC churn without reducing unique variant count.

The cap at 2,048 is not protection — it is a different kind of damage. When the cache is full and new entries arrive faster than old ones expire, the cache churns. Every cache miss triggers a full JPQL parse-validate-compile cycle. The CPU cost of constant recompilation replaces the memory cost of unbounded growth. You have traded an OOM for sustained CPU waste.

Hibernate 5 vs Hibernate 6: same problem, different internals

If you are running Spring Boot 3.x, you are on Hibernate 6. The internal machinery changed significantly. The vulnerability did not.

Hibernate 5 vs 6 cache comparison
// Hibernate 6.x: SqmInterpretationCache replaces QueryPlanCache.
// The structure changed. The vulnerability did not.

// Hibernate 5.x:
//   QueryPlanCache (BoundedConcurrentHashMap)
//     Key: full JPQL/HQL string
//     Default max: 2,048 entries
//     Property: hibernate.query.plan_cache_max_size

// Hibernate 6.x:
//   SqmInterpretationCache (BoundedConcurrentHashMap)
//     Key: full HQL/JPQL string (still the full string)
//     Default max: 2,048 entries (same default)
//     Property: hibernate.query.plan_cache_max_size (same property)
//
//   + QueryInterpretationCache for native SQL
//     Key: full SQL string
//     Separate cache, separate memory

// What changed:
//   - The AST model changed from HQL AST to SQM (Semantic Query Model)
//   - The compilation step is different internally
//   - The memory per entry shifted slightly (SQM trees vs HQL trees)
//
// What did NOT change:
//   - The cache key is still the full query string
//   - Variable-length IN clauses still produce unique strings
//   - CriteriaBuilder still inlines values into the query
//   - The combinatorial explosion is identical
//
// If anything, Hibernate 6's SQM trees can be slightly larger per entry
// because the semantic model carries more type metadata.

Hibernate 6 replaced the HQL AST model with the Semantic Query Model (SQM), a more sophisticated representation that carries richer type metadata. The QueryPlanCache was renamed to SqmInterpretationCache. The compilation pipeline is entirely different internally.

None of this matters for the problem at hand. The cache key is still the full query string. CriteriaBuilder.in() still inlines values into that string. Variable-length lists still produce unique strings. The combinatorial explosion from Specifications is identical in both versions.

If anything, Hibernate 6's SQM trees carry slightly more metadata per entry, making each cached plan marginally larger. The improvement in query compilation speed partially offsets the recompilation cost of cache churn — but "faster recompilation of queries that should never have been recompiled" is not, I must observe, an improvement one aspires to.

The configuration property hibernate.query.plan_cache_max_size works in both versions. The in_clause_parameter_padding property works in both versions. The fixes described in this article apply regardless of which Hibernate version you are running.

"Sort by total_exec_time, not mean_exec_time. Total time reveals which queries consume the most cumulative resource — and cumulative resource is what determines whether your database is healthy or struggling."

— from You Don't Need Redis, Chapter 18: The PostgreSQL Performance Decision Framework

Why parameter padding helps — and where it falls short

The standard fix for IN-clause cache explosion is Hibernate's in_clause_parameter_padding, which rounds parameter counts to the next power of two. If you are unfamiliar with this setting, the companion article on QueryPlanCache OOMs covers it in full.

# application.properties — parameter padding helps, but...
spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true

# With padding, 1-50 element IN clauses collapse to 6 variants:
#   1, 2, 4, 8, 16, 32, 64 (50 rounds up to 64)
#
# So the math improves:
#   1 IN clause:   6 cache entries (was 50)
#   2 IN clauses:  36 entries (was 2,500)
#   3 IN clauses:  216 entries (was 125,000)
#   4 IN clauses:  1,296 entries (was 6,250,000)
#
# But here's the Specification twist:
# EACH COMBINATION OF ACTIVE FILTERS is a different query structure.
# 5 optional filters = 32 structural variants.
# 4 IN clauses with 6 padded sizes each, across 32 structural variants:
#   32 * 6^4 = 41,472 potential unique JPQL strings.
#
# Padding reduced the per-IN-clause explosion.
# It did NOT reduce the structural explosion.
# You still have 32 query shapes multiplied by size variants.

Padding reduces the per-IN-clause dimension from 50 variants to 6. That is a dramatic improvement — a 88% reduction in cache entries per IN clause. But it does not address the structural dimension — the 32 different query shapes from 5 optional filters. You go from 200 million potential variants to 41,472. Better. Not solved.

For applications with 2-3 optional IN-clause filters, padding alone is often sufficient. The cache stays under 1,000 entries, heap usage stays reasonable, and you can move on with your afternoon. But for applications with 4+ dynamic IN-clause filters — which is common in e-commerce search, reporting dashboards, admin panels, and any domain with faceted navigation — padding alone leaves you with a cache that is still thousands of entries large and still churning.

There is also a subtlety with padding that matters for Specifications specifically. Padding works by adding duplicate values to the end of the list, rounding up to the next power of two. A list of [1, 2, 3] becomes [1, 2, 3, 3]. This changes the query semantics slightly — the duplicate value appears in the IN clause — but because IN semantics are set-based, duplicates do not affect the result. The query returns the same rows.

However, the padding is applied at the Hibernate level, after CriteriaBuilder has already constructed the query. This means the cache key includes the padded form. Two original lists of [1, 2, 3] and [1, 2, 3, 4] both pad to size 4, producing the same cache key structure. This is exactly why padding helps — it collapses nearby sizes into the same structure.

Enable padding regardless. It is the right first step. But for Specification-heavy applications, you need more.

In fairness to the Specification pattern

I should be honest about the limits of this critique, because a waiter who overstates his case is no waiter at all.

The Specification pattern exists because the alternative — writing one native query per filter combination — scales poorly in code complexity. Five optional filters produce 32 combinations. Writing and maintaining 32 native queries is not realistic. The Specification pattern's composability is not a luxury; it is the only sane way to handle combinatorial filter logic in application code.

Furthermore, the cache explosion only becomes a practical problem under specific conditions. You need variable-length IN clauses (not just equality checks), you need several of them (3+ to see meaningful multiplication), and you need enough traffic to exercise a significant portion of the combinatorial space within one JVM lifecycle. Many applications never hit all three conditions.

If your Specifications use only equality predicates — cb.equal(root.get("status"), status) — the parameter length dimension does not apply. Your cache entries are bounded by the structural dimension alone: 2n for n optional filters. Five optional equality filters produce at most 32 cache entries. That is nothing.

The problem is specific to IN-clause predicates with user-determined list lengths. If your users cannot control how many items appear in each filter — if your categories always come as a fixed set from a dropdown, never as a multi-select — the length dimension collapses. If your lists are always short (under 5 items) and padding is enabled, the length dimension is a small constant multiplier.

The advice in this article is most relevant when: you have 3 or more IN-clause filters, those filters accept user-selected multi-value input, and the value counts routinely exceed 10-20 items. If that describes your application, the combinatorial explosion is real and the fixes are necessary. If it does not, enabling in_clause_parameter_padding and monitoring your heap may be the entirety of what you need.

Structural fixes: rewriting the query layer

There are three code-level approaches that address the combinatorial explosion. Each operates on a different dimension of the problem, and each involves a genuine trade-off. I shall present them in order of increasing effectiveness and increasing disruption.

Approach 1: Manual padding in Specification methods

If you want to keep the Specification pattern, you can pad the input lists yourself before passing them to CriteriaBuilder.in():

Manual padding in Specification methods
// Fix: Rewrite specifications to use parameterized CriteriaBuilder.In
// instead of Specification.in() with literal values.

public class ProductSpecification {

    public static Specification<Product> hasCategories(List<Long> categoryIds) {
        return (root, query, cb) -> {
            // BAD: This inlines the values, producing unique JPQL per list size
            // return root.get("category").get("id").in(categoryIds);

            // BETTER: Use a ParameterExpression to keep the query structure stable
            // Unfortunately, CriteriaBuilder doesn't natively support
            // parameterized IN clauses with variable-length lists.
            //
            // The real fix is architectural: normalize at the query layer.
            // Option 1: Convert to a subquery with a temp table
            // Option 2: Use a fixed-size array parameter with ANY()
            // Option 3: Let the proxy normalize it (see below)

            // PRACTICAL FIX for most teams:
            // Pad manually to next power of 2 before passing to the spec
            List<Long> padded = padToNextPowerOfTwo(categoryIds);
            return root.get("category").get("id").in(padded);
        };
    }

    private static <T> List<T> padToNextPowerOfTwo(List<T> values) {
        if (values.isEmpty()) return values;
        int target = Integer.highestOneBit(values.size() - 1) << 1;
        if (target < values.size()) target = values.size();
        List<T> padded = new ArrayList<>(values);
        T last = values.get(values.size() - 1);
        while (padded.size() < target) {
            padded.add(last);
        }
        return padded;
    }
}

This eliminates the parameter-length dimension but preserves the structural dimension. You still get 32 query shapes from 5 optional filters, but each shape has predictable parameter counts. The cache holds at most 32 * 64 = 41,472 entries in the absolute worst case, and in practice far fewer because most filter combinations are rare.

The advantage is minimal disruption: your Specifications stay composable, your controller stays unchanged, and each Specification method simply adds a padding call before the .in(). The disadvantage is that 41,472 is still a large number. If your cache is capped at 2,048, you will still see churn. Less violent churn. But churn.

Approach 2: Structural collapse with a single Specification

The structural dimension — the 32 query shapes — comes from the conditional inclusion of predicates. If you always include all predicates, using tautologies (1=1) for absent filters, the structure is fixed:

Single Specification with structural collapse
// Reducing the structural dimension: always-present predicates.
// Instead of 32 query shapes, you get exactly 1.

public class ProductSpecification {

    // Single specification that always includes ALL filter clauses.
    // Absent filters use a tautology (1=1) instead of being omitted.
    public static Specification<Product> withFilters(
            List<Long> categoryIds,
            List<Long> brandIds,
            List<String> colors,
            List<String> sizes,
            BigDecimal minPrice,
            BigDecimal maxPrice) {

        return (root, query, cb) -> {
            List<Predicate> predicates = new ArrayList<>();

            // Always present. When empty, use a tautology.
            if (categoryIds != null && !categoryIds.isEmpty()) {
                predicates.add(root.get("category").get("id")
                    .in(padToNextPowerOfTwo(categoryIds)));
            } else {
                predicates.add(cb.conjunction()); // 1=1
            }

            if (brandIds != null && !brandIds.isEmpty()) {
                predicates.add(root.get("brand").get("id")
                    .in(padToNextPowerOfTwo(brandIds)));
            } else {
                predicates.add(cb.conjunction());
            }

            if (colors != null && !colors.isEmpty()) {
                predicates.add(root.get("color")
                    .in(padToNextPowerOfTwo(colors)));
            } else {
                predicates.add(cb.conjunction());
            }

            // ... same pattern for sizes, price range

            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }
}

// Result: ONE query structure. Always 5 predicates.
// With padding, max variants = 6^4 = 1,296 (just the IN-clause sizes).
// Without the 32x structural multiplier.
//
// Trade-off: you lose the elegant per-filter composability.
// The single method is less testable in isolation.
// But the cache math is dramatically better.

This eliminates the structural dimension entirely. With padding, you get 64 = 1,296 cache entries maximum. Without the 32x structural multiplier, the cache holds comfortably within the default 2,048 cap.

The trade-off: you lose the per-filter composability that makes Specifications elegant. The single method is harder to test in isolation. And the tautology predicates, while harmless to query results, add a small amount of parsing overhead for predicates that could have been omitted. PostgreSQL's planner is smart enough to eliminate 1=1 tautologies, so the execution plan is unaffected — but the JPQL string is longer, and the Hibernate compilation step processes more predicate nodes.

For most teams, this is the right balance: dramatically better cache behavior with a modest reduction in code elegance.

Approach 3: PostgreSQL ANY(array) with a native query

The most effective approach replaces IN clauses with PostgreSQL's ANY(array) operator, which accepts a single array parameter of any length:

ANY(array) native query approach
// The PostgreSQL-native approach: ANY(array) instead of IN (list)
// This produces ONE query string regardless of array length.

@Repository
public interface ProductRepository extends JpaRepository<Product, Long>,
                                           JpaSpecificationExecutor<Product> {

    // Instead of Specification-based IN clauses, use a native query
    // with PostgreSQL's ANY() operator for the dynamic filters:
    @Query(value = """
        SELECT p.* FROM products p
        WHERE (:categoryIds IS NULL OR p.category_id = ANY(CAST(:categoryIds AS bigint[])))
          AND (:brandIds IS NULL OR p.brand_id = ANY(CAST(:brandIds AS bigint[])))
          AND (:colors IS NULL OR p.color = ANY(CAST(:colors AS text[])))
          AND (:sizes IS NULL OR p.size = ANY(CAST(:sizes AS text[])))
          AND (:minPrice IS NULL OR p.price >= :minPrice)
          AND (:maxPrice IS NULL OR p.price <= :maxPrice)
        """, nativeQuery = true)
    Page<Product> searchProducts(
        @Param("categoryIds") Long[] categoryIds,
        @Param("brandIds") Long[] brandIds,
        @Param("colors") String[] colors,
        @Param("sizes") String[] sizes,
        @Param("minPrice") BigDecimal minPrice,
        @Param("maxPrice") BigDecimal maxPrice,
        Pageable pageable);
}

// This produces EXACTLY ONE query string. Always.
// ONE cache entry in Hibernate. ONE prepared statement in PostgreSQL.
// The array parameter handles variable-length input without
// changing the query structure.
//
// Trade-off: you lose the composability of Specifications.
// For complex filter UIs, this is often the right trade.

This produces exactly one query string, regardless of which filters are active or how many items each contains. One cache entry. One prepared statement. The variable-length data travels as a single array parameter.

The trade-off is real: you lose Specification composability entirely for this query. If your filter logic is genuinely complex — nested OR groups, conditional joins, subqueries — the native query approach requires writing that complexity by hand. For straightforward filter UIs where each parameter is an optional AND predicate, the native query is cleaner and dramatically more cache-friendly.

A concern I encounter frequently: does ANY(array) perform differently than IN (list) at the PostgreSQL level? For scalar columns with B-tree indexes — which is what you have in a typical filter scenario — the answer is no.

Indexing: IN vs ANY(array)
-- Important: ANY(array) has different indexing requirements than IN.
--
-- IN (1, 2, 3) uses a standard B-tree index effectively:
CREATE INDEX idx_products_category ON products (category_id);
-- PostgreSQL sees: category_id IN (1, 2, 3) and does a bitmap index scan
-- or multiple index lookups merged together. Efficient.

-- ANY(ARRAY[1, 2, 3]) also uses a standard B-tree index:
CREATE INDEX idx_products_category ON products (category_id);
-- PostgreSQL sees: category_id = ANY('{1,2,3}') and does the same thing.
-- Same index. Same plan. No performance difference.

-- Where they DIVERGE: GIN indexes on array columns.
-- If category_id were an array column (integer[]), you would need:
CREATE INDEX idx_products_categories_gin ON products USING GIN (categories);
-- But in our case, category_id is a scalar column.
-- The array is on the parameter side, not the column side.
-- Standard B-tree indexes work identically for both IN and ANY.

-- Verify with EXPLAIN ANALYZE:
EXPLAIN ANALYZE
SELECT * FROM products
WHERE category_id = ANY(ARRAY[1, 2, 3, 4, 5]);

-- Bitmap Heap Scan on products
--   Recheck Cond: (category_id = ANY ('{1,2,3,4,5}'::bigint[]))
--   ->  Bitmap Index Scan on idx_products_category
--       Index Cond: (category_id = ANY ('{1,2,3,4,5}'::bigint[]))

PostgreSQL uses the same index lookup strategy for both. The execution plans are identical. The only difference is at the Hibernate layer: one produces a single query string, the other produces thousands.

For query plan analysis, the ANY(array) approach also simplifies diagnosis considerably. One plan to examine instead of thousands. One set of statistics in pg_stat_statements instead of a fragmented mess. One entry to monitor, tune, and reason about.

Observing the damage in pg_stat_statements

The heap dump shows the Hibernate side. PostgreSQL has its own view of the problem, and it is worth consulting both.

Detecting IN-clause variant explosion in PostgreSQL
-- Detecting the explosion in pg_stat_statements.
-- Run this after a load test or during production peak traffic.

SELECT
    left(query, 100) AS query_prefix,
    calls,
    rows,
    round(mean_exec_time::numeric, 2) AS avg_ms,
    round(total_exec_time::numeric, 0) AS total_ms,
    (length(query) - length(replace(query, '$', ''))) AS param_count
FROM pg_stat_statements
WHERE query LIKE '%products%'
  AND query LIKE '%IN%'
ORDER BY param_count DESC
LIMIT 25;

-- Typical output showing the explosion:
--
-- query_prefix                                        | calls | avg_ms | param_count
-- ----------------------------------------------------+-------+--------+------------
-- SELECT p.* FROM products p WHERE p.category_id IN ( | 127   | 3.41   | 47
-- SELECT p.* FROM products p WHERE p.category_id IN ( | 89    | 2.98   | 32
-- SELECT p.* FROM products p WHERE p.category_id IN ( | 342   | 1.87   | 16
-- SELECT p.* FROM products p WHERE p.category_id IN ( | 1204  | 1.22   | 8
-- SELECT p.* FROM products p WHERE (p.category_id IN  | 67    | 4.12   | 39
-- SELECT p.* FROM products p WHERE (p.category_id IN  | 203   | 3.87   | 24
--
-- Notice: same table, same columns, same logical intent.
-- PostgreSQL is maintaining a separate prepared plan for each.
-- Each plan consumes ~40-200 KB of backend memory PER CONNECTION.
-- 50 connections * 500 plan variants = 25,000 cached plans.

Each unique prepared statement from Hibernate becomes a separate entry in pg_stat_statements and a separate plan in PostgreSQL's per-connection plan cache. Fifty connections each caching 500 plan variants means 25,000 cached execution plans across the server — consuming backend memory that could be used for shared_buffers, work_mem, or simply not existing.

A more targeted query can quantify the total variant count per table, which tells you exactly how fragmented your query patterns have become:

Quantifying variant explosion per table
-- Quantifying the total variant count per table.
-- This tells you how bad the explosion actually is.

SELECT
    regexp_replace(
        left(query, 60),
        '\$[0-9]+', '$N', 'g'
    ) AS normalized_prefix,
    COUNT(*) AS variant_count,
    SUM(calls) AS total_calls,
    round(AVG(mean_exec_time)::numeric, 2) AS avg_ms,
    round(SUM(total_exec_time)::numeric, 0) AS combined_time_ms
FROM pg_stat_statements
WHERE query LIKE '%products%'
GROUP BY normalized_prefix
ORDER BY variant_count DESC
LIMIT 10;

-- normalized_prefix                                      | variant_count | total_calls
-- -------------------------------------------------------+--------------+------------
-- SELECT p.* FROM products p WHERE (p.category_id IN ($N | 847           | 28,441
-- SELECT count(*) FROM products p WHERE (p.category_id I | 847           | 28,441
-- SELECT p.* FROM products p WHERE (p.category_id IN ($N | 312           | 9,102
-- SELECT p.* FROM products p WHERE (p.brand_id IN ($N, $ | 204           | 6,847
--
-- 847 variants of the same logical query. Each planned separately.
-- And notice the count(*) mirror: Pageable doubles every variant.

The pg_stat_statements view also makes the Pageable doubling visible. For every data query variant, there is a matching count(*) variant. The 847 variants in the output become 1,694 cached plans. Hibernate's Specification layer is generating two distinct query plans per user request, and each one fragments independently across the combinatorial space.

If you see this pattern — hundreds of near-identical entries differing only in parameter count — your Hibernate Specification layer is leaking structural complexity through to the database. The database is doing its job faithfully. It is treating each unique query as a unique query, because that is what it was told to do. The responsibility lies with the layer that generated the variation.

Where a proxy normalizes what Specifications cannot

Gold Lapel sits between your Spring Boot application and PostgreSQL, observing every query at the wire protocol level.

# What Gold Lapel does with Specification-generated query variants:
#
# Hibernate sends (via JDBC prepared statements):
#   Parse: SELECT p.* FROM products p WHERE p.category_id IN ($1,$2,$3)
#          AND p.brand_id IN ($4,$5)
#   Parse: SELECT p.* FROM products p WHERE p.category_id IN ($1,$2,$3,$4)
#          AND p.brand_id IN ($5,$6)
#   Parse: SELECT p.* FROM products p WHERE p.category_id IN ($1,$2,$3)
#          AND p.brand_id IN ($4,$5) AND p.color IN ($6,$7,$8)
#
# Gold Lapel's prepared statement promotion recognizes these as
# the same structural family. The proxy normalizes IN-clause
# parameter lists into array parameters:
#
#   All variants -> SELECT p.* FROM products p
#                   WHERE p.category_id = ANY($1::bigint[])
#                   AND p.brand_id = ANY($2::bigint[])
#                   AND p.color = ANY($3::text[])
#
# One prepared statement. One PostgreSQL plan.
# The array carries the variable-length data as a single parameter.
#
# This is the same transformation as the ANY(array) rewrite above,
# but applied automatically at the wire protocol layer.
# No query changes. No Specification rewrites.
# No losing the composability you built your filter system around.

The proxy performs the IN-to-ANY normalization automatically. Hibernate generates WHERE category_id IN ($1, $2, $3). Gold Lapel rewrites it to WHERE category_id = ANY($1::bigint[]) before it reaches PostgreSQL. The three positional parameters become a single array parameter. The query structure stabilizes.

Impact on the Specification pattern

This is where the proxy approach differs fundamentally from the code-level fixes. You keep your Specifications. You keep CriteriaBuilder. You keep the composability. You keep the testability. The controller code does not change. The Specification methods do not change. Hibernate continues to generate as many JPQL variants as it likes — the proxy normalizes them all at the wire protocol level before they reach PostgreSQL.

The Hibernate-side QueryPlanCache still fills with variants, which is a genuine limitation of this approach. The proxy cannot reach into Hibernate's memory and collapse the cache. What it does is eliminate the PostgreSQL-side multiplication: instead of 25,000 cached plans across your connection pool, PostgreSQL sees 32 normalized queries. The Hibernate-side cache pressure remains, but it is bounded by the plan_cache_max_size setting, and the CPU cost of recompilation is spent in Hibernate (which is fast) rather than in PostgreSQL (which is slower per plan).

For most applications, the PostgreSQL-side normalization is the larger benefit. Hibernate's recompilation cost is measured in microseconds per query. PostgreSQL's plan cache memory consumption is measured in megabytes per connection. The proxy addresses the more expensive side of the equation.

MetricNo fixPadding onlyWith proxy
Unique JPQL strings (5 filters, 1-50 items)~25,000,000~41,47232
QueryPlanCache heap usage800+ MB~112 MB~1.7 MB
PostgreSQL prepared plans per connection~2,048 (capped)~1,296~32
GC pressure from cache churnSevereModerateNegligible
Code changes requiredN/A1 config lineConnection string

The "32" in the proxy column represents the 32 structural query shapes from 5 optional filters — those still vary because different predicates produce different SQL. But within each shape, all parameter-length variants collapse to one. And 32 entries consuming 1.7 MB is a rounding error on a 2 GB heap.

Enable in_clause_parameter_padding as well. It reduces the work the proxy has to do, and it protects you if the proxy is ever temporarily unavailable. Defense in depth. Belt and suspenders. The sort of redundancy that keeps the heap quiet at 3 AM.

The recommended configuration

For Spring Boot applications using Specifications with IN-clause predicates, the full defense requires attention at three layers. Apply all of them.

Hibernate configuration (application.properties):

  • spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true — collapses parameter-length variants to powers of two
  • spring.jpa.properties.hibernate.query.plan_cache_max_size=256 — bounds the cache to a reasonable size for most applications; lower values mean less memory but more recompilation
  • spring.jpa.properties.hibernate.default_batch_fetch_size=25 — controls IN-clause sizes for association loading, which is a separate source of IN-clause variants that compounds with the Specification variants

Code review:

  • Audit every Specification that calls .in() — these are your cache-explosion sources
  • For filter UIs with 4+ optional IN-clause parameters, consider the ANY(array) native query approach or the structural collapse approach
  • For simpler cases (2-3 IN-clause filters with modest list sizes), manual padding in the Specification method is sufficient
  • Check whether Pageable count queries are doubling your cache entries — consider a @QueryHints approach or a custom count query to reduce the variant multiplication

Monitoring:

  • Add -XX:+HeapDumpOnOutOfMemoryError to your JVM arguments — always, in every environment, without exception
  • Monitor pg_stat_statements for IN-clause variant proliferation — the variant count query shown earlier is an excellent health check to run weekly
  • Enable hibernate.generate_statistics=true periodically in staging to audit cache hit rates; a hit rate below 80% means the cache is churning
  • Watch your GC logs for the sawtooth pattern described above — rising floor, shrinking recovery, accelerating frequency

Three config properties, a targeted code review, and a monitoring habit. That is the entirety of it. Your Specifications remain composable. Your heap remains yours. And your PostgreSQL plan cache holds 32 entries instead of 25,000.

I should mention one final thing. The filter page that brought you here — the one with the sidebar and the five optional parameters — is a good page. The code is clean. The user experience is right. The Specification pattern was the right choice for organizing the query logic. The only thing it needed was someone to look at the heap and notice what the composability was costing. Now you have. The rest is configuration.

Frequently asked questions

Terms referenced in this article

The subject repays further study. The cache explosion you have just resolved sits alongside several other JPA performance traps I have catalogued in the Spring Boot PostgreSQL optimization guide — each of which shares the property of being invisible until it is expensive.