We override toString() in Java so that printing or logging an object shows its field values instead of the class name and a hash code, and the ToStringBuilder class from Apache Commons Lang builds that text in one consistent format. The method comes from Object, so every class has it, but the inherited version prints something like Book@1b6d3586.
We use a custom toString() for log messages, exception messages, debugger views and test failure output. Java records generate one for us, IDEs write one in two clicks, and ToStringBuilder helps when many classes in a codebase should print the same way or when a class hierarchy shares one implementation.
The following example overrides toString() in a Book class with ToStringBuilder and compares it with the inherited version.
class Book {
private final String title;
private final String author;
private final int pages;
Book(String title, String author, int pages) {
this.title = title;
this.author = author;
this.pages = pages;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("title", title)
.append("author", author)
.append("pages", pages)
.toString();
}
}
class PlainBook {
String title = "Dune";
}
String plain = new PlainBook().toString(); // "PlainBook@7a81197d" (hash varies)
String custom = new Book("Dune", "Herbert", 412).toString(); // "Book[title=Dune,author=Herbert,pages=412]"
Notice that the custom version names each field, and that string concatenation, println() and logging frameworks call toString() for us. We start with the hand-written and record versions and go through ToStringBuilder in detail, including reflection, output styles, custom formats and class hierarchies, and finish with IDE, Lombok and Guava options.
1. Why Override toString() in Java
The Object.toString() method returns getClass().getName() + “@” + Integer.toHexString(hashCode()). That text tells us the type and an identity hash, but nothing about the state of the object, so a log line such as Loan failed for Book@7a81197d does not help anyone debugging a production issue.

We override toString() in classes whose instances show up in logs, error messages or assertions, such as entities, DTOs, value objects and configuration classes. The result should be concise and informative for a person to read, so the method returns a description for humans and never a format that other code parses.
Several options exist, and the choice depends on the kind of class.
- A Java record already has a generated toString(), so we write nothing.
- A regular class with a few fields gets a hand-written or IDE-generated method.
- A codebase that wants one format everywhere, or a hierarchy that shares one method, uses ToStringBuilder.
- A class that uses Lombok adds @ToString, and a Guava project can use MoreObjects.toStringHelper().
2. Writing toString() by Hand
A hand-written toString() needs no library and runs fast. The method returns a String, must be public (we cannot reduce the visibility of an inherited method) and gets the @Override annotation, so the compiler reports a typo such as tostring().
class Magazine {
private final String title;
private final String editor; // may be null
private final String[] topics;
Magazine(String title, String editor, String... topics) {
this.title = title;
this.editor = editor;
this.topics = topics;
}
@Override
public String toString() {
return "Magazine{title='" + title + "', editor="
+ Objects.toString(editor, "unknown")
+ ", topics=" + Arrays.toString(topics) + "}";
}
}
String full = new Magazine("Wired", "Lokesh", "AI", "Java").toString(); // "Magazine{title='Wired', editor=Lokesh, topics=[AI, Java]}"
String noEditor = new Magazine("Byte", null).toString(); // "Magazine{title='Byte', editor=unknown, topics=[]}"
Two details make the method safe. The call Objects.toString(editor, “unknown”) returns the default text for a null field, and Arrays.toString() prints the array elements, because an array field would otherwise print as [Ljava.lang.String;@1b6d3586. Collections and maps print their elements without help.
For a fixed layout, String.format() or the formatted() method reads better than a long concatenation. A StringBuilder helps when the text depends on conditions or loops.
String formatted = "%s by %s, %d pages".formatted("Emma", "Austen", 474); // "Emma by Austen, 474 pages"
StringBuilder sb = new StringBuilder("Shelf[");
for (String title : List.of("Dune", "Emma")) {
sb.append(title).append(';');
}
String shelf = sb.append(']').toString(); // "Shelf[Dune;Emma;]"
3. Records Generate toString() for Us
A record gets a toString() that the compiler generates from its components. The Record.toString() Javadoc only promises the record name and the component names and values, but the JDK prints them as Name[component=value, …].
record Loan(String title, String member, LocalDate due) { }
Loan loan = new Loan("Dune", "Lokesh", LocalDate.of(2026, 11, 2));
String loanText = loan.toString(); // "Loan[title=Dune, member=Lokesh, due=2026-11-02]"
We can still override toString() in a record, for example to hide a component that holds a password or a token. For most DTOs and value objects, the generated method is the reason we do not need any library at all.
4. ToStringBuilder in Apache Commons Lang 3
The ToStringBuilder class is part of Apache Commons Lang. It appends fields one by one with append(name, value) and formats the result with a ToStringStyle, so all classes that use the same style print the same way. Arrays, null values and nested objects are handled for us.
Add the commons-lang3 dependency. The current release is 3.21.0, and the package is org.apache.commons.lang3.builder. The old commons-lang 2.x artifact with the package org.apache.commons.lang is no longer developed, so new code uses version 3.
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.21.0</version>
</dependency>
4.1. Appending Selected Fields
Appending fields explicitly gives us full control over what the text contains. The Book class from the intro uses this form, and the order of the append() calls is the order in the output. Fields we leave out, such as an internal cache or a password, never show up.
String[] tags = {"classic", "sci-fi"};
String tagged = new ToStringBuilder("Dune", ToStringStyle.NO_CLASS_NAME_STYLE)
.append("tags", tags)
.append("isbn", (Object) null)
.toString(); // "[tags={classic,sci-fi},isbn=<null>]"
We can see that the builder prints array contents in braces and writes <null> for a missing value, so a null field never causes a NullPointerException in toString().
4.2. Choosing an Output Style
The style decides the brackets, separators and whether the class name and identity hash appear. Commons Lang ships seven predefined styles as constants of ToStringStyle, and each row of the table shows the output for the same three fields of a Book object in the package com.howtodoinjava. The hash after the @ differs on every run.
Book dune = new Book("Dune", "Herbert", 412);
String json = new ToStringBuilder(dune, ToStringStyle.JSON_STYLE)
.append("title", "Dune")
.append("pages", 412)
.toString(); // "{"title":"Dune","pages":412}"
| Style | Output for title, author and pages |
|---|---|
| DEFAULT_STYLE | com.howtodoinjava.Book@279f2327[title=Dune,author=Herbert,pages=412] |
| SHORT_PREFIX_STYLE | Book[title=Dune,author=Herbert,pages=412] |
| NO_CLASS_NAME_STYLE | [title=Dune,author=Herbert,pages=412] |
| SIMPLE_STYLE | Dune,Herbert,412 |
| NO_FIELD_NAMES_STYLE | com.howtodoinjava.Book@279f2327[Dune,Herbert,412] |
| JSON_STYLE | {“title”:”Dune”,”author”:”Herbert”,”pages”:412} |
| MULTI_LINE_STYLE | Class name and hash on the first line, followed by one field per line |
The SHORT_PREFIX_STYLE is a good default for logs because it keeps the class name but drops the package and the hash. We can change the default for all builders with ToStringBuilder.setDefaultStyle(), but a static setting affects the whole JVM, so passing the style in each class is easier to follow. JSON_STYLE output looks like JSON, yet it is meant for reading, so we still use Jackson or Gson when other code parses the text.
4.3. One reflectionToString() in the Superclass
The reflectionToString() method reads all fields with reflection, including inherited ones, so a single method in the top class of a hierarchy prints every subclass correctly. The following example defines three member types of a library app, and only AbstractMember overrides toString().
abstract class AbstractMember {
private final int id;
private final String firstName;
private final int age;
AbstractMember(int id, String firstName, int age) {
this.id = id;
this.firstName = firstName;
this.age = age;
}
@Override
public String toString() {
return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE);
}
}
class Member extends AbstractMember {
private final LocalDate lastVisit;
Member(int id, String firstName, int age, LocalDate lastVisit) {
super(id, firstName, age);
this.lastVisit = lastVisit;
}
}
class Guest extends Member {
private final String branch;
Guest(int id, String firstName, int age, LocalDate lastVisit, String branch) {
super(id, firstName, age, lastVisit);
this.branch = branch;
}
}
Guest guest = new Guest(100, "Lokesh", 37, LocalDate.of(2026, 10, 1), "Delhi");
String guestText = guest.toString(); // "Guest[branch=Delhi,lastVisit=2026-10-01,age=37,firstName=Lokesh,id=100]"
Notice that the fields of the runtime class come first, followed by each superclass, and that Commons Lang sorts the fields of each class by name. New fields appear in the output without any code change, which is the main benefit of reflection, and also its main risk.
Reflection prints every field, including passwords, tokens and lazy-loaded JPA associations, so we exclude sensitive fields explicitly. The @ToStringExclude annotation hides a field from ReflectionToStringBuilder, and toStringExclude() takes field names when we cannot change the class.
class Account {
private final String user = "lokesh";
@ToStringExclude
private final String password = "s3cret";
@Override
public String toString() {
return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE);
}
}
String account = new Account().toString(); // "Account[user=lokesh]"
Guest guest = new Guest(100, "Lokesh", 37, LocalDate.of(2026, 10, 1), "Delhi");
String byName = ReflectionToStringBuilder.toStringExclude(guest, "age", "id"); // contains branch, lastVisit and firstName only
Reflection also has a cost. It is slower than appended fields, and since Java 17 the JDK blocks deep reflection into JDK internals (JEP 403), so reflectionToString() works for our own classes but throws InaccessibleObjectException when we pass it a JDK object such as a LocalDate. Fields of JDK types inside our classes are fine, because the builder prints them with their own toString().
4.4. Custom Formatting with a ToStringStyle Subclass
A custom style changes how values of a certain type are printed in every class that uses it. The following style keeps the short class name and prints dates as dd-MM-yyyy, the format a support team in India reads every day.
class DateStyle extends ToStringStyle {
private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
DateStyle() {
setUseShortClassName(true);
setUseIdentityHashCode(false);
}
@Override
protected void appendDetail(StringBuffer buffer, String fieldName, Object value) {
if (value instanceof LocalDate date) {
value = date.format(FORMAT);
}
super.appendDetail(buffer, fieldName, value);
}
}
Guest guest = new Guest(100, "Lokesh", 37, LocalDate.of(2026, 10, 1), "Delhi");
String dated = ToStringBuilder.reflectionToString(guest, new DateStyle()); // "Guest[branch=Delhi,lastVisit=01-10-2026,age=37,firstName=Lokesh,id=100]"
The method appendDetail() receives each non-null field value, so we only convert the types we care about and pass everything else to the parent implementation. The constructor settings replace the class name and hash prefix that the default style would print.
4.5. Adding Fields to the Parent Output with appendSuper()
When a subclass overrides toString(), it can reuse the parent’s text with appendSuper(super.toString()) and append its own fields. The builder copies the content between the brackets of the parent’s text, so the result stays one object description instead of two nested ones.
class Staff extends AbstractMember {
private final String role;
Staff(int id, String firstName, int age, String role) {
super(id, firstName, age);
this.role = role;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.appendSuper(super.toString())
.append("onDuty", true)
.toString();
}
}
String staff = new Staff(7, "Anita", 41, "librarian").toString(); // "Staff[role=librarian,age=41,firstName=Anita,id=7,onDuty=true]"
The parent’s reflectionToString() already includes the role field of Staff, because reflection runs on the runtime class. With field-by-field append() calls in the parent, appendSuper() would add only the parent’s fields, which is the cleaner design when each class lists its own fields.
4.6. Stopping Reflection at a Superclass
The method ReflectionToStringBuilder.toString(object, style, outputTransients, outputStatics, reflectUpToClass) stops at a given superclass. It helps when a base class holds technical fields such as audit timestamps that should not appear in every log line.
Guest guest = new Guest(100, "Lokesh", 37, LocalDate.of(2026, 10, 1), "Delhi");
String upTo = ReflectionToStringBuilder.toString(guest, ToStringStyle.SHORT_PREFIX_STYLE,
false, false, Member.class); // "Guest[branch=Delhi,lastVisit=2026-10-01]"
We can see that the fields of Guest and Member are printed, and the fields declared in AbstractMember are skipped. The reflection walk includes the class passed as the limit and stops before its parent.
5. IDE Generation, Lombok and Guava
An IDE writes a plain toString() from the fields we select. In IntelliJ IDEA we press Alt+Insert (Cmd+N on macOS) and pick toString(), and in Eclipse we use Source > Generate toString(). Both offer templates, including one that uses ToStringBuilder, and the generated code has no runtime dependency, but we must regenerate it when fields change.
With Lombok, the @ToString annotation generates the method at compile time, and @ToString.Exclude hides a field. On JPA entities we exclude associations, because printing a lazy collection triggers a database query or a LazyInitializationException.
Guava offers MoreObjects.toStringHelper(), a small builder without reflection.
String guava = MoreObjects.toStringHelper("Book")
.add("title", "Dune")
.add("isbn", null)
.omitNullValues()
.toString(); // "Book{title=Dune}"
6. Logging a Failed Checkout Without Leaking Data
Say a library app logs every failed checkout with the member and the book, and a support engineer reads these logs to answer complaints. With the inherited toString(), the log says Checkout failed for Member@5e9f23b4, which is useless. With a reflectionToString() that has no exclusions, the log contains the member’s password hash and email address, which turns the log files into a data leak.
The fix combines the techniques from section 4. The member class appends only the fields a support engineer needs, and the log statement prints both objects.
class LibraryCard {
private final int cardNo;
private final String name;
private final String email;
private final String passwordHash;
LibraryCard(int cardNo, String name, String email, String passwordHash) {
this.cardNo = cardNo;
this.name = name;
this.email = email;
this.passwordHash = passwordHash;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("cardNo", cardNo)
.append("name", name)
.toString(); // email and passwordHash stay out of logs
}
}
Book dune = new Book("Dune", "Herbert", 412);
LibraryCard card = new LibraryCard(42, "Lokesh", "lokesh@example.com", "x9f2");
String logLine = "Checkout failed for " + card + ", book " + dune; // "Checkout failed for LibraryCard[cardNo=42,name=Lokesh], book Book[title=Dune,author=Herbert,pages=412]"
The explicit field list also protects us in the future, because a new sensitive field added next year does not appear in the logs by accident.
7. Comparing the Options
All options produce readable text, and they differ in what they cost and how they react to new fields.
| Option | Dependency | New fields appear | Sensitive fields | Best for |
|---|---|---|---|---|
| Hand-written | None | Only if we edit the method | Safe by default | Small classes, hot code paths |
| Record | None (Java 16+) | Yes | Override to hide | DTOs and value objects |
| IDE generated | None | After regeneration | Safe by default | Regular classes without Lombok |
| ToStringBuilder.append() | commons-lang3 | Only if we edit the method | Safe by default | One format across a codebase |
| reflectionToString() | commons-lang3 | Yes | Need @ToStringExclude | Hierarchies, quick debugging |
| Lombok @ToString | Lombok | Yes | Need @ToString.Exclude | Projects that use Lombok |
| Guava toStringHelper() | Guava | Only if we edit the method | Safe by default | Projects that use Guava |
8. Java toString() FAQs
After the first override compiles, the next questions concern records and class hierarchies.
8.1. When does Java call toString() without an explicit call?
Java calls toString() when an object takes part in string concatenation, when we pass it to System.out.println() or String.valueOf(), and when a logging framework formats a placeholder such as {}. For a null reference, concatenation and String.valueOf() print null without calling any method.
8.2. Do Java records need a toString() override?
No. The compiler generates one that prints all components, as shown in section 3. We override it only to hide or reformat a component.
8.3. Should toString() call super.toString()?
Only when the parent prints useful fields. Object.toString() returns the class name and hash, so calling it from a direct subclass of Object adds noise. In a hierarchy, appendSuper(super.toString()) joins the parent’s fields with the subclass fields.
8.4. Can toString() throw an exception?
Yes, it can, and it should not. A NullPointerException from a field access inside toString() hides the original error in a log or a debugger. Using Objects.toString(), ToStringBuilder or a record keeps the method safe for null fields.
8.5. Is reflectionToString() slow?
Yes, it is slower than appending fields, because it reads every field with reflection on each call. For log lines at INFO level the difference rarely matters, but in a loop that runs millions of times we prefer a hand-written method or append() calls.
9. Conclusion
Overriding toString() turns Book@7a81197d into text that a developer can act on. Records and IDE generation cover most classes without extra code, and a hand-written method with Objects.toString() and Arrays.toString() stays safe for null fields and arrays.
ToStringBuilder from commons-lang3 3.21.0 gives a codebase one format and handles null values and arrays. Its reflectionToString() method covers whole hierarchies with one method, but it prints every field, so we exclude passwords and associations or append the fields explicitly.
10. References
- Object.toString() Javadoc (Java 25)
- Record.toString() Javadoc (Java 25)
- Objects.toString() Javadoc (Java 25)
- Apache Commons Lang 3 builder package Javadoc
- Lombok @ToString
Happy Learning !!
Very very interesting and useful concept.
Thanks to post such kind of think.
How do you get ToStringBuilder to print values of object and collection properties?
Thanks for the article . . .