CodeGym /Kursy /Hibernate deep-dive /Hibernate Statistics: useful counters

Hibernate Statistics: useful counters

Hibernate deep-dive
Poziom 26, Lekcja 1
Dostępny

1. The role of Statistics next to the SQL log

If the SQL log is a surveillance camera recording, then Hibernate Statistics are the counters at the entrance: “how many people came in,” “how many times the door opened,” “how many times the guard ran for coffee.” Video gives details and context, but it is hard to quickly compare two runs of the same scenario with it. Counters do not tell the whole story, but they provide a compact picture that can be repeated and compared without guesswork.

The SQL log has already shown the shape of the problem. Now we want to understand the scale: is it one heavy query, a series of secondary selects, or just the feeling that “something is a bit too much.” This is where counters come in.

In practice it looks like this: you run the same read case (for example, “the active products catalog page”) in two implementation variants. The SQL log tells you what was executed, while Statistics answers how many times and at what scale. And that “how many” very quickly catches classic problems: N+1, unexpected collection loads, materializing too large of a graph, and even cases where flush happens during a “read.”

A very important point: Statistics do not replace the SQL log. They are like a short summary after reading a novel. If the novel is bad, the summary will look like this: “many pages, little meaning.” But if you are auditing the data layer, the summary is still needed — otherwise you will keep rewatching the “video” from scratch every time.

2. Enabling Statistics with a profile

Hibernate Statistics are not enabled “all the time” for one simple reason: collecting metrics costs money. It adds a bit of overhead to every query and to many internal ORM operations. In a training project, that is fine — we are learning how to measure. In a production service, you usually do not want statistics enabled all the time: it is too easy to make the system a little slower “for the sake of pretty graphs.”

In Commerce Persistence Lab we live by profiles. So the typical and correct way is to enable statistics with a separate stats profile, so that the application is “ordinary” by default, and turns into a lab for diagnostics when needed.

A minimal setting in application-stats.yml may look like this:

spring:
  jpa:
    properties:
      hibernate:
        # Enable Hibernate statistics collection (otherwise all counters will stay at zero)
        generate_statistics: true

This setting itself enables statistics collection. After that, you decide how exactly to read them: manually through Statistics in code (this is our main path today) or also through the logger if you want to see a summary in the logs. But the logger is a fickle thing: you get a lot of text, and right now we need exactly a “slice” of five numbers for one scenario.

And yes, it is normal if you enabled the stats profile and the application became a little more “heavy” on each request. At this point it is not a bug and not “Hibernate is slow again,” but a conscious price for the microscope: when you turn on measurement, you always add a small measurement cost.

3. Accessing Statistics in Spring Boot

Up to this point we talked a lot about “Hibernate,” but our code is still in Spring Boot, and at the surface level we have JPA (EntityManager, EntityManagerFactory). So the logical question is: where is the object that gives us statistics hidden in this layered pie?

The answer is simple: Statistics live on Hibernate SessionFactory. In a Spring Boot application with JPA, we usually have EntityManagerFactory, and it can be “unwrapped” back to the Hibernate level via unwrap(...). This is one of the few cases where we deliberately go below the JPA abstraction — because we need Hibernate-specific diagnostics.

The most direct way (anywhere you have EntityManagerFactory) looks like this:

import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;

// "Fall through" from the JPA abstraction into Hibernate to gain access to statistics
Statistics statistics = entityManagerFactory
        .unwrap(SessionFactory.class) // get Hibernate SessionFactory
        .getStatistics();             // obtain the statistics object (counters for the entire SessionFactory)

To avoid copying this code across the project, it is more convenient to define it once as a Spring Bean and then simply inject Statistics into the needed services. This is not “mandatory architecture,” but a training infrastructure that makes labs shorter and clearer.

Configuration example:

import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class HibernateStatsConfig {

    @Bean
    Statistics hibernateStatistics(EntityManagerFactory emf) {
        // Inject Statistics as a bean so we do not duplicate unwrap(...) across the project
        return emf.unwrap(SessionFactory.class).getStatistics();
    }
}

Now anywhere in the project (for example, in com.example.commerce.labsupport) you can simply accept Statistics in the constructor. Yes, this is a “global” object for the whole SessionFactory. And that is exactly why we will keep emphasizing: measure one scenario, clear the counters before the measurement, and do not do this in a multi-threaded battle where “everyone optimizes everything at once.”

4. Taking a snapshot without self-deception

Hibernate Statistics have a tricky feature: they seem simple, and therefore are very easy to use incorrectly. The most common beginner mistake is to look at the counters “as is” and draw conclusions. That is roughly like measuring a car’s speed without resetting the odometer and without clarifying whether you were actually driving, or just standing in a parking lot with the engine running.

The correct ritual (yes, this is that rare case where a ritual is useful) looks like this: you choose one read case or write case, clear the counters, execute the scenario, capture the values, and only then compare them with another implementation variant. If you do not call clear(), your measurement will include everything: application startup queries, migration logs, random background loads, and even what you did in the adjacent IDE tab while “just checking.”

A minimal “slice” for one scenario may look like this:

import org.hibernate.stat.Statistics;

// Important: reset counters before measuring a specific scenario
statistics.clear();
catalogQueryService.findActiveProducts(); // one specific use case

// Capture values immediately after the scenario completes
System.out.println("queries=" + statistics.getQueryExecutionCount());      // queries=1
System.out.println("statements=" + statistics.getPrepareStatementCount()); // statements=1

Notice the subtle point: we are not trying to measure “the whole application.” We are measuring “one use case.” Ideally, at this moment you should name the scenario clearly, like a human: “the active products catalog page,” “the order details load with items,” “the orders table with status NEW.” If you cannot name the scenario, your numbers will also be hard to interpret.

And one more important detail: even a read case can suddenly trigger flush (we already discussed this in the flush and dirty checking topic). So today we are not just looking at queries, but also keeping in mind whether there was a write where we did not expect one.

5. Five key counters

Statistics can do a lot, but precisely because of that, beginners quickly drown in numbers and either start ignoring the tool or, on the contrary, turn it into a religion (“the metric said 7, so it is bad”). We will do it properly: choose the minimal set that gives maximum diagnostic value for the persistence layer within the course.

Today we are interested in five counters: queryExecutionCount, prepareStatementCount, entityLoadCount, collectionFetchCount, flushCount. They cover the three main diagnostic questions well: how much query-based activity went through the ORM, how much SQL work actually reached the JDBC level, how much data was materialized into managed objects, and whether hidden writing (flush) happened in a scenario that is supposed to be “read-only.”

For clarity, here is a compact table that is useful to keep in mind:

Metric (Statistics) What it counts in everyday terms Which symptoms it catches fastest
getQueryExecutionCount() How many queries passed through Hibernate’s query mechanism; not a universal total SQL counter Quick signal of chatty query activity and N+1; read together with the SQL log, getPrepareStatementCount() and fetch counters
getPrepareStatementCount() How many JDBC PreparedStatements were prepared (approximately: how many SQL statements actually went out) Actual volume of SQL work, including unexpected UPDATE
getEntityLoadCount() How many entity instances were loaded Overfetching and “too many entities were pulled in”
getCollectionFetchCount() How many times Hibernate loaded collections (to-many) Lazy collections, secondary SELECTs, N+1 on collections
getFlushCount() How many times flush happened “There is a write in a read case,” flush-before-query, hidden changes

Next we will look at each metric separately, but with an important rule: we do not read them one by one. One number rarely gives the right diagnosis. Usually the diagnosis is born from a combination of two or three indicators plus observations in the SQL log.

queryExecutionCount: a quick signal of a chatty scenario

When a developer enables the SQL log for the first time, they often feel a strange mix of emotions: it is useful, but after a minute their eyes already want to close and go on vacation. queryExecutionCount is a way to quickly establish whether a scenario is turning into chatty query-based behavior. But it is important not to overestimate it: it is not a universal counter for all SQL activity in Hibernate.

Imagine a simple catalog scenario: we want to get a list of active products. We make one JPQL query — and expect one database query. Then queryExecutionCount in an ideal baseline case will be equal to 1 (or close to it if the scenario includes some mandatory service queries, but in the lab we try to remove noise).

Mini measurement example:

// Capture the metric strictly for one scenario
statistics.clear();
catalogQueryService.findActiveProducts();

// It should be close to 1 in a "healthy" scenario (one root query)
System.out.println(statistics.getQueryExecutionCount()); // 1

But if you return List<Product> and then iterate over it in code and touch product.getDetails() or product.getAssignments(), the scenario becomes chatty. This will almost certainly show up in the SQL log, and quantitatively it is often better caught by the growth of prepareStatementCount, collectionFetchCount, and other fetch-specific counters. queryExecutionCount remains a useful quick signal, but this single number is no longer enough.

prepareStatementCount: how much SQL actually went out

After the first acquaintance with queryExecutionCount, a beginner often wants to live happily: “Oh, I get it, just reduce the number of queries and everything will be fast.” And here prepareStatementCount gently brings us back to earth. Because queries, statement preparation, and actual SQL work are related, but not the same thing.

prepareStatementCount is closer to JDBC reality: how many times Hibernate had to prepare SQL statements for execution. If, for example, in a “read” scenario you accidentally changed a managed entity (in the style of setName(trim())), Hibernate may send an UPDATE during flush, and prepareStatementCount will grow. At the same time, queryExecutionCount may remain small, because UPDATE is not a “query” in the JPQL sense, but it is real database work.

A mini example where prepareStatementCount catches hidden work:

// Reset counters before checking the "cleanliness" of a read case
statistics.clear();
catalogQueryService.findAndAccidentallyMutateProductName(productId);

// Compare "queries" and "real statements": UPDATE may only appear in the second one
System.out.println("queries=" + statistics.getQueryExecutionCount());      // queries=1
System.out.println("statements=" + statistics.getPrepareStatementCount()); // statements=2

The number “2” itself is not important as an absolute. What matters is that it became larger than you expected for a clean read. This is a signal: “look at the SQL log — there is clearly something besides selects.” And that is exactly what we need in an audit: to quickly spot unexpected writes.

entityLoadCount: how many entities were loaded

There is a very common trap, especially after getting acquainted with JOIN FETCH. The developer makes “one query” and is happy: queryExecutionCount = 1. And then suddenly the application still feels heavy, and memory footprint seems large. Why? Because “one query” can bring in a lot of data, which Hibernate will turn into managed entity objects.

entityLoadCount shows how many entity instances were loaded. This is not “result set size in rows” and not “number of columns.” It is specifically “how many entities appeared in the persistence context as managed objects.” For diagnostics, this is extremely convenient: you can see that a read case is pulling in “an entire universe of entities,” even though business-wise you only needed a list of 20 rows with four columns.

A mini example comparing two approaches in one scenario (conceptually):

// In this scenario we care about the volume of materialized entities
statistics.clear();
catalogQueryService.findCatalogPageWithEntities();

// If the number is large, this is a direct signal of overfetching, even if the query is formally one
System.out.println("entities=" + statistics.getEntityLoadCount()); // entities=240

If you see that 240 entities were loaded for a “simple catalog page,” that is a reason to think: did you really want to materialize that many objects? Or should this scenario have been projection-based and “thin”? We are not re-arguing “entity vs projection” here (that happened earlier), but statistics help you see the cost of the decision in numbers.

collectionFetchCount: collection loads

Lazy loading for to-many relationships has a tricky effect: you can honestly make one root query, get a list of orders, and then somewhere “along the way” call order.getItems().size() or iterate over items in a loop. For Java code this looks harmless, but for Hibernate it is a signal to “initialize the collection,” meaning execute additional queries.

collectionFetchCount shows how many times such collections were loaded. In practice, this is a gold metric: if you read a list of orders and see collectionFetchCount > 0, then somewhere you touched a to-many collection lazily, and this almost certainly leads either to N+1 or to a series of secondary selects.

Imagine an orders list scenario:

// Check whether lazy to-many collections "fire" during result traversal
statistics.clear();
orderQueryService.findRecentOrdersAndTouchItems();

// Any value > 0 is a reason to look for secondary SELECTs in the SQL log
System.out.println("collections=" + statistics.getCollectionFetchCount()); // collections=30

The number “30” in the comment is just an illustration: it is usually roughly equal to the number of orders in the list, because each order loads its collection separately. And then you go to the SQL log and see a repeating select ... from order_item where order_id=? many times. Statistics here work like a smoke detector: they do not explain the fire in detail, but they say “there is smoke, go look where it comes from.”

flushCount: a warning sign for a read case

Flush is a topic that has already managed to spoil the mood for many people, because it can happen “earlier than you expected.” But in diagnostics, flushCount is a very grateful indicator. Especially when we measure a read case.

If you analyze a read scenario, your ideal flushCount is usually 0. It can become greater than zero if you changed something inside the transaction (even accidentally), or if Hibernate was forced to synchronize the context to ensure query correctness. In any case, it is a signal: “careful, the scenario is not purely read-only.”

Mini example:

// Read case: by meaning, there should be no synchronization (flush) with the database here
statistics.clear();
orderQueryService.findOrdersReadCase();

// Ideally: 0. If > 0, look for accidental update / flush-before-query
System.out.println("flushes=" + statistics.getFlushCount()); // flushes=0

If flushes=1 appears here, do not panic and “fix Hibernate.” You need to calmly open the SQL log and understand what caused the flush. Often it is a banal accidental update because “we tweaked a string,” “set a default value,” “touched an embeddable,” or “a listener fired.” We are not going deeper into callbacks today, but a flush signal in a read case is always a reason to check whether the boundary between reading and writing got broken.

6. Metric combinations: diagnostic patterns

After analyzing the five counters, a logical question arises: “Okay, so what is considered ‘good’ and what is ‘bad’?” The answer is not universal, but there are simple patterns that help real-world development teams understand in a minute which direction to investigate next. It is similar to medicine at the “temperature and blood pressure” level: they do not give a diagnosis, but they strongly help choose the next step.

Here are a few typical combinations in table form (as a hint, not as a law of nature):

What you see in the metrics What it often means What you do next (for diagnostics)
queryExecutionCount and/or prepareStatementCount noticeably higher than expected, collectionFetchCount significant N+1 or secondary selects because of to-many You open the SQL log and look for a repeating query by order_id / product_id
queryExecutionCount is small, but entityLoadCount is huge One query pulls too many entities (overfetching) You look at why the read case reads an entity graph instead of a thin read model
flushCount > 0 in a read case There is a write/change in the read scenario or flush-before-query You look for accidental update, unexpected UPDATE in SQL, side effects
prepareStatementCount is higher than expected by the business meaning There is “extra SQL” in the scenario (not only selects), or additional service operations You compare it with the SQL log: what statements were added

Notice that this table does not automatically push us into EXPLAIN yet. If the counters show chatty ORM behavior — many queries, collection loads, unnecessary entity loads — we first fix the shape of the read. It makes sense to move to generated SQL, EXPLAIN and the PostgreSQL query plan when there are already few queries, and the heaviness is sitting in one specific SQL statement.

Important: we are deliberately not turning this into a “performance optimization checklist.” These are diagnostic patterns. The point of the day is not to speed everything up at any cost right now, but to be able to say: “the problem looks like N+1” or “the problem looks like overfetching,” and then open the SQL and confirm the hypothesis in a focused way.

To reinforce the idea of “comparing two variants,” you can mentally imagine an experiment like this: one catalog implementation returns entities, the other returns a projection. We are not writing huge code here, but we capture two slices in a row:

// Compare two implementation variants with "clean counters"
statistics.clear();
catalogQueryService.findCatalogPageWithEntities();
long entitiesQueries = statistics.getQueryExecutionCount();

statistics.clear();
catalogQueryService.findCatalogPageWithProjection();
long projectionQueries = statistics.getQueryExecutionCount();

// Print only one indicator to quickly see the difference in ORM behavior shape
System.out.println("entitiesQueries=" + entitiesQueries);     // entitiesQueries=18
System.out.println("projectionQueries=" + projectionQueries); // projectionQueries=2

This is not proof of performance. It is proof of the ORM behavior shape: in the first variant, there are significantly more queries. After that, you open the SQL trace and look at why there are so many of them (often it will be the repeating secondary select).

7. Mini infrastructure for a snapshot

When you do the tenth measurement of the day, printing five numbers separately becomes tiresome. For this place, a small local snapshot of the counters is enough. Not a new “project framework,” just a compact wrapper for one measurement after a specific scenario.

For example, right next to the experiment you can keep such a record:

// Local snapshot of counters for one specific measurement
public record StatisticsSlice(
        long queryCount,
        long prepareStatementCount,
        long entityLoadCount,
        long collectionFetchCount,
        long flushCount
) {
}

That is already enough to capture the values after the scenario and print them in one line:

statistics.clear();
catalogQueryService.findActiveProducts();

StatisticsSlice slice = new StatisticsSlice(
        statistics.getQueryExecutionCount(),
        statistics.getPrepareStatementCount(),
        statistics.getEntityLoadCount(),
        statistics.getCollectionFetchCount(),
        statistics.getFlushCount()
);

System.out.println(slice); // StatisticsSlice[queryCount=1, prepareStatementCount=1, entityLoadCount=20, collectionFetchCount=0, flushCount=0]

The only safety rule worth saying out loud: statistics.clear() clears the counters globally for the whole SessionFactory. So in a real multi-threaded application, you cannot do this “just like that” — you would erase the numbers for other scenarios. But in our lab we measure locally, in isolation, and that is exactly why this trick is appropriate.

8. Typical mistakes when working with Hibernate Statistics

Mistake No. 1: statistics are disabled, and you are measuring “nothing.”
The most annoying scenario looks like this: you wrote beautiful measurement code, called getQueryExecutionCount(), and it stubbornly returns zero. Usually the reason is simple — hibernate.generate_statistics is not enabled (or the stats profile is not active). At this point you want to blame Hibernate for being heartless, but it has nothing to do with it: the counter is off — the counter does not count.

Mistake No. 2: you forgot statistics.clear() and got “lifetime numbers.”
If you do not clear the counters before every measurement, you get the statistics not for the scenario, but for everything that happened since the application started. That will include migrations, seed data, your random queries, and even what the neighboring test did. Such numbers cannot be honestly compared with each other, because you are comparing two different “life contexts.”

Mistake No. 3: you measured not one use case, but “something roughly.”
Statistics are useful only when you can say: “this specific method / scenario is what I am measuring now.” If during the measurement you call several services, then make one more query “just in case,” then log entities and accidentally initialize collections, the snapshot turns into mush. Try to measure one scenario in the cleanest possible form.

Mistake No. 4: trust one number and ignore the others.
Sometimes a developer sees queryExecutionCount=1 and celebrates victory, even though entityLoadCount has gone into space. Or the other way around: they are proud that they reduced entityLoadCount, but accidentally increased collectionFetchCount. ORM diagnostics are almost always multidimensional: you need to look at a combination of metrics, not just one.

Mistake No. 5: compare two runs under different conditions and with different data.
If in the first measurement you have 20 products, and in the second you have 2000 products (because you enabled big-dataset), then the differences in counters will not be only because of your code. These are simply different scenarios. For comparison, fix the input parameters: page size, filter, status, and, if possible, the same dataset.

1
Zadanie
Hibernate deep-dive, poziom 26, lekcja 1
Niedostępne
Minimalny snapshot Hibernate Statistics
Minimalny snapshot Hibernate Statistics
1
Zadanie
Hibernate deep-dive, poziom 26, lekcja 1
Niedostępne
Porównanie wczytywania encji i projekcji konstruktorowej według liczników
Porównanie wczytywania encji i projekcji konstruktorowej według liczników
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION