CodeGym /Courses /Hibernate deep-dive /Safe toString ( ) and logging

Safe toString ( ) and logging

Hibernate deep-dive
Level 13, Lesson 4
Available

1. toString() as an ORM trigger

If you are used to thinking that toString() is just a way to “pretty-print an object,” then in ordinary Java that is almost true. But in the Hibernate world, an entity is not just an object with fields: it can have lazy references (proxies) and lazy collections (persistent collections). And any attempt to “pretty-print everything” can easily turn into loading relations from the database. The most unpleasant part is that this happens not in the repository and not in the query, but in a place where you were not thinking about SQL at all — for example, in logs.

After equality, proxies, and Set, there is one more deceptive everyday zone — the entity’s own methods. toString() and logging break not on business comparison, but on the same lazy/proxy mechanism: you go a little deeper into the object — and get SQL or LazyInitializationException.

Let us establish the core idea: toString() and logging must be side-effect free. They must not initiate reads from the database and must not depend on whether you are in a transaction right now or not. Ideally, it is just a safe string built from a few simple fields of the current object.

How “innocent printing” turns into SQL

Hibernate gives us lazy loading so we do not have to pull the entire graph at once. But lazy loading has a price: as soon as you access relation data, Hibernate tries to fetch it. And “access” is not only your business code. It is also toString(), logging, and sometimes the IDE debugger.

Here is a diagram worth keeping in mind:

flowchart TD
    A["log.info('Order = {}', order)"] --> B["Logger calls order.toString()"]
    B --> C["toString() touches a lazy relation/collection"]
    C --> D{"Is there an active transaction
and an open session?"} D -->|Yes| E["Hibernate runs SELECT
and initializes the relation"] D -->|No| F["LazyInitializationException
or strange side effects"]

This is exactly the “lazy trigger in logging”: you did not write findItemsForOrder(), but you got SQL.

2. Dangerous toString() and extra loads

In this section, we will make a very short but painfully realistic observation: a bad toString() often looks “logical” precisely to a beginner developer. Of course you want to see “everything,” right? Order number, customer, items, product categories... And then this same toString() starts living its own life: the logger calls it, the exception handler calls it, a test assertion calls it, System.out.println() calls it, the IDE debugger calls it — and your SQL log suddenly turns into a disco.

Example: items.size() in order toString()

Let us imagine (or remember) that PurchaseOrder.items is a @OneToMany and by default it is almost always LAZY. Now the naive toString():

import jakarta.persistence.Entity;
import java.util.Set;

@Entity
public class PurchaseOrder {
    private Long id;

    // Usually this is a LAZY collection: a simple access can trigger SQL
    private Set<OrderItem> items;

    @Override
    public String toString() {
        // WARNING: items.size() can initialize the persistent collection
        // and, as a result, run SELECT in the database (or throw LazyInitializationException outside a transaction).
        return "PurchaseOrder{id=" + id + ", items=" + items.size() + "}";
    }
}

Why is this dangerous? Because items.size() is not “counting the size of an ordinary HashSet.” It is an attempt to find the size of a persistent collection. If the collection is not initialized, Hibernate will be forced to initialize it (or at least query the database for information). In practice, that often means a SELECT against the order item table.

In the SQL trace, this usually looks something like this (highly simplified):

select oi.* from order_item oi where oi.order_id = ?;

If logging happens in a loop over a list of orders, you get the classic “N+1, but by your own initiative”: you created it yourself.

Example: recursion because of bidirectional relations

Another “classic” is when the parent’s toString() prints children, and the child’s toString() prints the parent. Then your log becomes an endless snake until the stack says, “nope, nope, nope.”

public class OrderItem {
    private Long id;

    // Bidirectional reference: if both toString() methods print each other, recursion will happen
    private PurchaseOrder order;

    @Override
    public String toString() {
        // WARNING: if order.toString() also prints items,
        // you may get infinite recursion/StackOverflowError.
        return "OrderItem{id=" + id + ", order=" + order + "}";
    }
}

If PurchaseOrder.toString() contains items, and OrderItem.toString() contains order, then this is almost a guaranteed path to recursion. Even if you do not get a StackOverflowError, you will still get either huge logs or unnecessary graph loading.

Example: “customer in order” and proxy

Suppose you decide to print customer in the order’s toString():

public class PurchaseOrder {
    private Long id;

    // Often LAZY to-one: here there may be a proxy that fetches data on access
    private Customer customer;

    @Override
    public String toString() {
        // WARNING: customer.toString() can:
        // 1) initiate SQL (if it touches fields/relations),
        // 2) fail with LazyInitializationException (if the session is already closed).
        return "PurchaseOrder{id=" + id + ", customer=" + customer + "}";
    }
}

If customer is a proxy, then customer.toString() can also be dangerous if it prints related fields internally or tries to fetch, for example, a list of addresses. The danger is twofold: you may get SQL where you did not expect it, or you may get a LazyInitializationException if the transaction is already closed.

3. Safe toString() for entities

A good toString() for an entity is like a good fire extinguisher: you rarely use it on purpose, but when you suddenly need it (log, exception, debug), it must work predictably and not set the house on fire. That is why we do not build “rich” string representations of the graph. We build short, boring, but safe strings from a couple of fields. Yes, it sounds unromantic. But it lasts longer in production.

Here, the word “safe” should be understood narrowly: we are not trying to build a complete portrait of the entity in any runtime state. We are simply not letting toString() traverse the lazy graph and unexpectedly hit the database. If you are dealing with a raw proxy, it is usually safer to log id, the real class of the entity, or a separate log view in the service layer than to demand the full truth about the object’s life from toString().

Below are exactly those local templates: for an already initialized instance or for code where you consciously limited yourself to the entity’s simple fields.

What you can and cannot include in toString()

Instead of long rule lists, it is more convenient to look at a table once and remember the principle:

What you want to add to toString() Usually safe? Why
id Yes This is a simple field. It may be null for new objects — and that is normal.
business key (sku, email, orderNumber) Usually yes It should not be LAZY and should be stable. But remember PII (for example, email).
status, createdAt, simple enum/time fields Yes They do not pull the graph and do not trigger loading.
@ManyToOne customer as a whole No Almost certainly a proxy and another entity’s toString().
@OneToMany items No Almost certainly a lazy collection and SQL on traversal/size().
“pretty-print everything with Objects.toString(this)” No This will just call your own toString() (and may recurse).
large fields (description, technicalSpecs) Probably not Logs will balloon; plus such fields are often made LAZY (and then there will be a surprise).

The main principle is simple: toString() must be local. It describes this object, not the whole world around it.

Safe toString() for Product

In our project, Product is the central catalog entity. For logs, id and sku are usually enough, sometimes status. Name can also be included if it is short and not secret.

import jakarta.persistence.Entity;

@Entity
public class Product {
    private Long id;
    private String sku;
    private String name;

    @Override
    public String toString() {
        // Print only the simple fields of the current entity — no relations or collections
        return "Product{id=" + id + ", sku='" + sku + "', name='" + name + "'}";
    }
}

Yes, it is boring. But boredom is an underrated sign of reliability.

Safe toString() for PurchaseOrder

An order often appears in logging of scenarios: creation, status changes, stock reservation. Here, id, orderNumber, and status are usually placed in toString().

import jakarta.persistence.Entity;

@Entity
public class PurchaseOrder {
    private Long id;
    private String orderNumber;
    private OrderStatus status;

    @Override
    public String toString() {
        // Important: there is no customer/items or other relations here,
        // so toString() does not trigger lazy loading and does not fail outside a transaction.
        return "PurchaseOrder{id=" + id + ", number='" + orderNumber + "', status=" + status + "}";
    }
}

Notice what is not here: customer, items, deliveryAddress (the address will later become a value object, but not today). Everything that can potentially pull the graph is excluded.

Proxy classes and strange names

Sometimes you log not an entity, but a proxy, and the log shows something like Customer$HibernateProxy$.... This is not an error, but it is annoying. If you want, you can print a “human-friendly” class name through a Hibernate utility without initializing the object.

import org.hibernate.Hibernate;

public class DebugUtil {
    public static String entityClassName(Object entityOrProxy) {
        // Hibernate.getClass(...) helps get the real entity class even for a proxy.
        // The point here is to correctly name the object type, not to promise "no side effects ever."
        return Hibernate.getClass(entityOrProxy).getSimpleName();
    }
}

This is useful specifically for diagnosing the object type, not as a replacement for a normal log view: by itself, this helper does not turn a raw proxy into a full entity snapshot.

And remember: the less Hibernate-specific code you have in the domain model, the calmer the codebase is. That is why I would not drag this into every entity, but keep it as a separate debug tool.

4. Service logs without printing entities

Logs in the backend are like a surveillance camera recording: it is better if they show important events, not an endless series about every entity on screen. A very common mistake is to log an entity “as a whole” and hope that toString() will “sort itself out.” We have already found that “sort itself out” in Hibernate usually means “with SQL.” So in services it is better to log explicit fields that you have chosen as safe.

Parameterized logging versus concatenation

One of the easiest ways to accidentally call toString() (and sometimes SQL) even when the log will not be emitted at all is string concatenation.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderLoggingDemo {
    private static final Logger log = LoggerFactory.getLogger(OrderLoggingDemo.class);

    public void demo(PurchaseOrder order) {
        // Good: toString() is called only if debug is really enabled
        log.debug("Order = {}", order);

        // Bad: concatenation builds the string in advance, and toString() is always called
        log.debug("Order = " + order);
    }
}

The parameterized log.debug("...", obj) variant is good because the logger decides for itself whether to build the string. But "..." + order is built before the logger method is called, and no debug level will save you there.

“But I want to log the number of items!”

That is a very human desire. The problem is that order.getItems().size() is computed before log.debug(...) is called. Even if debug is disabled, you have already touched the lazy collection.

So the simple but honest version looks like this:

// We make loading items conscious: only if debug logging is really enabled
if (log.isDebugEnabled()) {
    // Here we explicitly agree to possible SQL for the sake of a diagnostic log
    log.debug("Order {} items={}", order.getId(), order.getItems().size());
}

Yes, that is a little more code. But it clearly shows: “I consciously accept triggering collection loading, but only if debug logging is really enabled.”

And an even more mature option (especially if you just want the count) is not to touch the collection at all, but to get the number with a separate query instead (through a projection or a count query). But that is already a read-design topic, and we are not expanding it today; just remember that “compute a count without loading the collection” is usually possible.

Log events, not graphs

In Commerce Persistence Lab, the service layer is the natural place where we record business events. For this, it is better to write a log not as “here is your object,” but as “this happened.”

// Log the event and key identifiers, not the entire entity
log.info("Order created: id={}, number={}", order.getId(), order.getOrderNumber());
log.info("Order status changed: id={}, from={}, to={}", order.getId(), oldStatus, newStatus);

Such logs read like a story of the system’s life. And they do not pull lazy relations simply because you tried to “take a quick look.”

Mini idea: a separate “log view” instead of an entity

Sometimes you want to log a compact set of fields in a single object, but without the risk that relations will accidentally be included. A very simple approach is to create a small record type.

// A special type only for logs: contains only safe, already selected fields
public record OrderLogView(Long id, String number, OrderStatus status) { }

And use it only for logs:

// Build a "snapshot" from simple fields — without passing the entity into the logger as-is
OrderLogView view = new OrderLogView(order.getId(), order.getOrderNumber(), order.getStatus());
log.info("Order snapshot: {}", view);

A record gives you a safe field-based toString() automatically. And most importantly, you do not risk someone “later adding items to PurchaseOrder.toString() because it is convenient.”

5. toString() and LazyInitializationException with open-in-view=false

In this section, it is important to feel the connection between architectural discipline and the exact way the application breaks. We deliberately keep spring.jpa.open-in-view=false so that the persistence context is not smeared all the way into the web layer. That is correct, but it requires care: any attempt to touch lazy data outside a transaction will fail. And toString()/logging is one of the most unexpected ways to “touch” it.

What it looks like in real life

Imagine this chain: a service method finishes, the transaction closes, the entity becomes detached. Then somewhere “at the boundary” (for example, in an error handler, in an audit log, in a simple log.info("{}", entity)) toString() is called, and it reaches into a lazy relation.

If toString() tries to do items.size() or customer.getEmail(), Hibernate says: “Sorry, but the session is already gone, I cannot go to the database.” And you get a LazyInitializationException in a place that does not look like “database work” at all.

And this is very tricky: a beginner developer often tries to “fix” it by enabling OSIV. But we already agreed that this must not be done: OSIV treats the symptom and masks the architectural problem. The right fix is to make toString() safe and log only what does not require lazy loading.

“But I was just printing the object in an error...”

Yes, and that is a classic trap. Consider this code:

catch (Exception ex) {
    // WARNING: if order.toString() is unsafe, you can get a second exception
    // right during logging (and it may "hide" the root cause).
    log.error("Failed to process order {}", order, ex);
    throw ex;
}

If order.toString() is dangerous, then during exception handling you can suddenly get a second exception that may “cover” the first one, making diagnosis harder. The error turns into a matryoshka: “a crash during a crash.”

So a safe toString() is not only about SQL. It is also about diagnostic reliability.

Serialization is “toString() on steroids,” but we will not go deeper

There is another relative of toString(): entity serialization (for example, to JSON). I will not go into Jackson details and annotations now (we agreed that this is not today’s topic). But it is important to understand the principle: a serializer usually reads fields and getters, and therefore can initiate lazy loading exactly like toString().

If you need to send data outward, it is better to return DTOs/projections, not entities. And even if you do not expose entities externally, serialization can appear “inside” — for example, in logs, where someone decided, “let us just log the object as JSON.”

Short thought: if you are not ready for “just print the object” to reach the database, then you have not fully tamed the ORM yet. This lecture is precisely about making that output safe.

6. Typical mistakes with toString() and entity logging

Mistake #1: “Let us print everything in toString() so debugging is easier.”
This is a temptation everyone faces. But with ORM it is especially expensive: “print everything” almost always means “walk the graph,” and therefore initiate lazy loading. As a result, you get extra SELECTs, N+1, or a failure outside the transaction. toString() should be short and local.

Mistake #2: calling items.size() (or any iteration over a collection) inside toString().
Even if it looks harmless, for a lazy collection it usually means initialization. And initialization is SQL. In a transaction this creates extra queries; outside a transaction it may fail with LazyInitializationException. And the worst part: the call happens “by accident,” just from logging.

Mistake #3: recursive toString() on bidirectional relations.
PurchaseOrder prints items, OrderItem prints order, and you get infinite recursion or a monstrously bloated log. Such problems often surface late because “nobody used to print it before.” In an ORM project, this must be prevented from the start.

Mistake #4: logging with string concatenation ("..." + entity).
Even if the logging level is off, concatenation has already called toString(). That means you can accidentally initiate lazy loading even when the log is not physically written. Parameterized messages ({}) save you because the string is built only if the level is enabled.

Mistake #5: logging an entity “as a whole” in high-frequency spots.
For example, in a loop processing hundreds of orders or products. Even a safe toString() can bloat logs and make the system noisy. And if toString() is unsafe, you also get a flood of SQL. Logs should capture events and key identifiers, not replace the debugger.

1
Task
Hibernate deep-dive, level 13, lesson 4
Locked
Safe `toString()` without lazy loading
Safe `toString()` without lazy loading
1
Task
Hibernate deep-dive, level 13, lesson 4
Locked
Logging an entity through explicit fields
Logging an entity through explicit fields
1
Survey/quiz
Entity Identity, level 13, lesson 4
Unavailable
Entity Identity
Object Comparison and Equality
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION