Project Lombok in Java: What It Generates, How It Hooks In, and Where It Bites
A Java class with three fields needs a constructor, accessors, equals, hashCode and toString before it does anything useful. Project Lombok writes them for you at compile time. A ten-line class in this article becomes 80 lines of plain Java, and nobody has to read or maintain those 80 lines. That is why so many Java codebases use it.
It is also the reason this tutorial covers more than the annotations. Lombok is not an ordinary library. It reaches inside the compiler, and the code it writes is code you never see. So the bugs it causes are also ones you never see: a cart item that can no longer be found in its set, a log line that kills a request, a failed refund that commits to the database. This article starts from zero and then reproduces each of those traps.
Everything runs on Java 25 and Lombok 1.18.48, and it lives in the companion repository. Every block of source code below is a real file there, and its first line says which one; the only exception is a generated method, and the text points it out. Claims about behaviour are backed by its 66 tests or by the compatibility script. Where I rely on documentation alone, the text says so.
The most important result concerns the future rather than any annotation. Lombok 1.18.48 needs sun.misc.Unsafe, the JDK plans to take that away, and when I switched it off today, the compiler crashed and produced nothing. That comes right after the vocabulary.
The vocabulary
Come back to this table when a later section uses one of these words.
| term | what it means |
|---|---|
| annotation processor | A plugin that javac runs during compilation. The standard API lets it read code and create new files. |
| processor path | Where javac looks for annotation processors, set with --processor-path. |
| syntax tree (AST) | The compiler's in-memory model of your source code. Lombok adds methods to it. |
| value object | An object defined by its contents, not its identity. Two 10 PLN amounts are the same amount. |
| record | Java's own language feature for immutable data carriers, final since Java 16. |
| delombok | A Lombok tool that writes the generated code out as ordinary Java source. |
| entity | A class mapped to a database table by JPA. It has an identity and a lifecycle, and Hibernate may subclass it. |
| proxy | A generated subclass that stands in for the real object, used by Hibernate for lazy loading and by Spring for transactions. |
| checked exception | An exception the Java compiler forces you to declare or catch. IOException is one. |
How Lombok works
You add an annotation, and methods appear. There is no reflection and no code generation at run time. It all happens once, inside javac.

Ordinary annotation processors can only add new files. They cannot change the class you are compiling, which is why MapStruct generates a separate OrderMapperImpl. Lombok does change that class, and the standard API offers no way to do so. It reaches into javac's internal classes instead. Three consequences follow, and I measured each one.
The class file is plain Java. @Data has source retention, so it is gone from the compiled class, and a test confirms that Customer.class does not mention it. Another test compiles a class using @Data, @Builder, @NonNull and @SneakyThrows, then runs it with no Lombok on the class path. It works, which is why the dependency scope is provided. One caveat: this is a property Lombok has to keep getting right. The 1.18.48 changelog fixes a bug where @SneakyThrows on JDK 26 produced class files that needed lombok.jar at run time.
A new JDK can break it. Internal classes carry no compatibility promise, so Lombok needs a new release when the compiler's internals change. Here is what happened with a small class on real javac, as recorded by compat.sh in the repository:
| JDK | Lombok | result |
|---|---|---|
| 25.0.4.1 | 1.18.38 (older than JDK 25 support) | exit 1, Error during the transformation of 'Plain' |
| 25.0.4.1 | 1.18.40 (added JDK 25 support) | compiles |
| 27 | 1.18.46 (older than JDK 27 support) | exit 3, ExceptionInInitializerError |
| 27 | 1.18.48 (added JDK 27 support) | compiles |
So your JDK upgrade depends on a Lombok release. The project has mostly been quick: according to its changelog, 1.18.40 shipped on September 4, 2025, ahead of JDK 25, and 1.18.48 on September 1, 2026, ahead of JDK 27 (the build I tested is dated September 15). JDK 26 reached general availability on March 17, 2026 and its support arrived with 1.18.46 on April 22, about five weeks later.
The next break is already announced. Every successful compile on JDK 25 and 27 prints this:
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by lombok.permit.Permit
WARNING: Please consider reporting this to the maintainers of class lombok.permit.Permit
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future releaselombok.permit.Permit is how Lombok gets past the module system to reach the compiler's internals. JEP 498 sets out the plan for these methods: a warning from JDK 24, and then "JDK 26 or later will throw an exception whenever a memory-access method is used". JDK 27 still only warned in my runs, so that step has not arrived. You can try it today with a flag, and I did:
| JDK and Lombok 1.18.48, with | result |
|---|---|
| the defaults | compiles, prints the warning |
-J--sun-misc-unsafe-memory-access=deny on JDK 25 and 27 | exit 3, ExceptionInInitializerError, caused by InaccessibleObjectException |
the same flag plus ten -J--add-opens=jdk.compiler/... flags on JDK 27 | compiles |
So there is an escape hatch, and the module system's own option is what opens it. I tried it on one small class, so treat it as evidence that a way out exists, not as a tested migration path; the list of packages is in compat.sh, and I did not minimise it. A real project can reproduce the failure with MAVEN_OPTS=--sun-misc-unsafe-memory-access=deny mvn compile, which also exits 1, because Maven compiles in its own process by default.
The practical advice is short. A job with deny would fail every day, so it tells you nothing new. Run a scheduled build on the JDK's newest early-access release instead: it goes red when the default changes or the internals move, while there is still time to plan. It will sometimes be red only because Lombok has not caught up yet, which is also what you want to know before upgrading. And read the Lombok changelog before each JDK upgrade.
Setting it up
This is the first place a tutorial written a few years ago will fail you. Adding the dependency used to be enough. On a current JDK it is not.
<!-- compat/no-processor-path/pom.xml -->
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.48</version>
<scope>provided</scope>
</dependency>
</dependencies>With only that on JDK 25, Maven stops on the first generated member it needs:
[ERROR] /w/src/main/java/demo/Probe.java:[10,28] constructor Probe in class demo.Probe cannot be applied to given types;Nothing in that message mentions Lombok. The reason is a change in javac's defaults. Its JDK 25 manual says annotation processing happens "if at least one other option is used to explicitly configure annotation processing", and having a processor on the class path does not count. JDK 21 still ran it, and printed a note warning that a future release might not.

The fix is what Lombok's Maven page calls "mandatory starting with JDK23": name it as a processor.
<!-- compat/with-processor-path/pom.xml -->
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.48</version>
</path>
</annotationProcessorPaths>
</configuration>Gradle needs the same two roles. Lombok's Gradle page uses compileOnly and annotationProcessor with the same coordinates, plus their test variants if your tests use Lombok. I took that from the documentation and did not run it. Your IDE also has to understand Lombok, or it will show errors on code that compiles. Check the setup page for your IDE.
Finally, put a lombok.config file in the project root. It holds settings that apply at compile time, and config.stopBubbling keeps them independent of whatever sits in the parent directories. The Jackson line matters only if you use Jackson 3, and it comes back later.
# lombok.config
# Lombok reads this file at compile time. Commit it to the root of the project.
config.stopBubbling = true
# Jackson 3 builders for @Jacksonized (without this line Lombok warns and generates Jackson 2 annotations).
lombok.jacksonized.jacksonVersion += 3Your first class: @Data
// src/main/java/com/wolinski/shop/tour/Customer.java
package com.wolinski.shop.tour;
import lombok.Data;
@Data
public class Customer {
private final long id;
private String name;
private String email;
}That is the whole class. The tests call new Customer(1), setName, getEmail, compare two instances with equals, and read Customer(id=1, name=Ann, email=ann@example.com) from toString. There is no setId, because the field is final, and the constructor takes only id, because it is the only final field.
@Data is shorthand for five annotations. Knowing the five matters, because every serious problem below comes from one of them doing its job on a class where you did not want it done.

@Data is five annotations. Each one is a choice, and @Data makes all five for you.To see exactly what Lombok wrote, run delombok on the file. This is its equals for Customer, shortened to two of the fields. It compares every field, and it reads them through getters, which will matter when we get to Hibernate.
public boolean equals(final java.lang.Object o) {
if (o == this) return true;
if (!(o instanceof Customer)) return false;
final Customer other = (Customer) o;
if (!other.canEqual((java.lang.Object) this)) return false;
if (this.getId() != other.getId()) return false;
final java.lang.Object this$name = this.getName();
final java.lang.Object other$name = other.getName();
if (this$name == null ? other$name != null : !this$name.equals(other$name)) return false;
return true;
}This block is the one exception to the rule about real files, since delombok generates it at build time. BuildTest runs that tool on Customer.java.
The annotations you will use
@Value and @With: immutable objects
@Value is the immutable sibling of @Data. The class becomes final, its fields become private and final, and there are no setters. @With adds a method that returns a changed copy.
// src/main/java/com/wolinski/shop/tour/Money.java
package com.wolinski.shop.tour;
import java.math.BigDecimal;
import lombok.Value;
import lombok.With;
@Value
public class Money {
BigDecimal amount;
@With String currency;
}zloty.withCurrency("EUR") returns a new object and leaves zloty alone. zloty.withCurrency("PLN"), the value it already has, returns the same instance. That is a sensible optimisation, and worth knowing if you ever compare with ==.
@Builder: construction by name
// src/main/java/com/wolinski/shop/tour/Order.java
package com.wolinski.shop.tour;
import java.util.List;
import lombok.Builder;
import lombok.NonNull;
import lombok.Singular;
import lombok.Value;
@Value
@Builder(toBuilder = true)
public class Order {
@NonNull String number;
@Singular List<OrderLine> lines;
@Builder.Default String status = "NEW";
}// src/test/java/com/wolinski/shop/TourTest.java
Order order = Order.builder()
.number("A-1")
.line(new OrderLine("BOOK", 1))
.line(new OrderLine("PEN", 2))
.build();Three things in that class are worth learning together. @Singular generates line(...) for one element as well as lines(...) for many, and the built list rejects add with UnsupportedOperationException. @Builder.Default gives status a real default of NEW. toBuilder = true adds order.toBuilder().status("SHIPPED").build(), which copies and leaves the original intact.
@NonNull: a null check at the door
On a generated setter or constructor, or a method parameter, @NonNull inserts a check at the top. In the builder above it fires in build(), not when you forget the call, with the message number is marked non-null but is null. It is a run-time check that the code generator adds, and nothing more. It is not a static guarantee, and a later section shows where it does not reach.
Constructors and logging for services
This is the use I would defend the hardest, because it removes pure noise and hides no decision.
// src/main/java/com/wolinski/shop/tour/CheckoutService.java
package com.wolinski.shop.tour;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class CheckoutService {
private final Mailer mailer;
private final Duration timeout = Duration.ofSeconds(5);
public void place(Order order, Customer customer) {
log.info("placing {}", order.getNumber());
mailer.send(customer.getEmail(), "Order " + order.getNumber() + " placed within " + timeout);
}
}@RequiredArgsConstructor takes the final fields that have no value yet, so the constructor has one parameter, mailer, and timeout stays out. Spring uses a single constructor for injection automatically. @Slf4j adds a private static final field called log. There is also @NoArgsConstructor, and @AllArgsConstructor, which gets a warning from me below.
@SneakyThrows: the one to be suspicious of
It lets a method throw a checked exception without declaring it, by fooling the compiler. Lombok's page limits the use cases to strict interfaces such as Runnable and "impossible" exceptions. Keep it to those. A later section shows it committing a transaction that should have rolled back.
The rest of the box
You will meet these in other people's code. I did not test them for this article, so this table is my opinion plus Lombok's documentation.
| annotation | what it does | my advice |
|---|---|---|
val, var | Local type inference. | Use Java's own var. |
@Cleanup | Closes a resource at the end of the scope. | Use try-with-resources. |
@SuperBuilder | Builders across inheritance. Moved out of experimental in 1.18.48. | Fine when you truly have the hierarchy. |
@Accessors(fluent = true) | Getters named name() instead of getName(). | If you want that, consider a record. |
@UtilityClass, @Delegate, @ExtensionMethod | Experimental features. | Avoid in shared code: the experimental package is where features may change. |
@Synchronized, @Locked | Locking on private objects. | Prefer an explicit lock, so readers see it. |
Lombok or a record?
Before Java 16, the language had no answer to @Value. It has one now, and for a new data carrier a record is my default, since it needs no compiler hack and no dependency. The difference is in the details:
@Value class | record | |
|---|---|---|
| comes from | a compiler plugin | the language |
| accessor | getNumber() | number() |
toString | Money(amount=10.00, currency=PLN) | Product[sku=BOOK, stock=3] |
| hide a field from the constructor | yes, with an initializer | no: the components are the state |
| can extend another class | yes | no |
| can be extended | no, unless you add @NonFinal | no |
| pattern matching deconstruction | no | yes |
| JavaBean frameworks | works by default | depends on the framework |
| deep immutability | no, shallow | no, shallow |
They are not rivals in every respect, because Lombok works on records too. A record has no builder and no with methods, and Lombok can add them:
// src/main/java/com/wolinski/shop/tour/Product.java
package com.wolinski.shop.tour;
import lombok.Builder;
import lombok.With;
@Builder
public record Product(String sku, @With int stock) {}This is my favourite combination for a data carrier with many fields. The language owns the data, and Lombok writes only the convenience.
Typical problems
Each of these is a passing test in the repository. The column to read first is the middle one: most of them give you no signal at all.
| problem | what tells you | what to do |
|---|---|---|
| Mutable key in a hash set | nothing | Immutable keys, or explicit equality fields |
callSuper = false | nothing, once the warning is silenced | callSuper = true, if the parent has equals |
| Builder ignores an initializer | a warning | @Builder.Default, and -Werror |
| Hand-written method replaces a generated one | a compile error or a warning | Rename it, or write both equals and hashCode |
toString leaks a secret | nothing | onlyExplicitlyIncluded |
@NonNull gaps | nothing | Do not treat it as a guarantee |
BigDecimal scale | nothing | Normalise the scale |
Shallow @Value | nothing | Copy collections on the way in |
| Sneaky exception in a transaction | nothing | throws and a rollback rule |
Lost @Qualifier | a startup error, or nothing | lombok.copyableAnnotations |
| Jackson names | nothing | Plain field names, and a test on the JSON |
| MapStruct order | nothing | The binding |
A mutable key in a hash set
// src/main/java/com/wolinski/shop/traps/CartItem.java
package com.wolinski.shop.traps;
import lombok.Data;
@Data
public class CartItem {
private final String sku;
private int quantity;
}@Data includes quantity in hashCode and generates its setter, a combination that does not belong together. Change the quantity of an item in a HashSet, and the set can no longer find it:
// src/test/java/com/wolinski/shop/TrapsTest.java
CartItem book = new CartItem("BOOK");
Set<CartItem> cart = new HashSet<>();
cart.add(book);
book.setQuantity(2);
assertThat(cart.contains(book)).isFalse();
assertThat(cart.remove(book)).isFalse();
assertThat(cart).hasSize(1);
The element is still in the set, it counts towards its size, and remove returns false. In a long-lived set this is also a memory leak. Use immutable objects as keys, or restrict equality to fields that never change, with @EqualsAndHashCode(onlyExplicitlyIncluded = true).
Inheritance and callSuper
Put @Data on a subclass and Lombok generates equals from the subclass's own fields. The compiler warns: Generating equals/hashCode implementation but without a call to superclass. The easy response is to silence it the way the message suggests:
// src/main/java/com/wolinski/shop/traps/Coupon.java
package com.wolinski.shop.traps;
import lombok.Data;
import lombok.EqualsAndHashCode;
// callSuper = false silences Lombok's warning and keeps exactly what the warning was about.
@Data
@EqualsAndHashCode(callSuper = false)
public class Coupon extends Discount {
private final String code;
public Coupon(int percent, String code) {
super(percent);
this.code = code;
}
}Now new Coupon(10, "WELCOME") equals new Coupon(50, "WELCOME"), because percent lives in the parent. A 10% coupon and a 50% coupon are "the same". The warning was correct, and callSuper = false only removed the evidence. Use callSuper = true, which FixedCoupon in the repository does, and which makes those two unequal.
That advice has a condition, though: the parent needs a value-based equals of its own. If it only inherits Object.equals, callSuper = true compares identity, and two identical objects are never equal. A sample with such a parent compiles without a warning and prints equal=false. If the parent has no equals, either give it one, or keep callSuper = false deliberately and accept that its fields do not count.
A builder ignores your initializer
// src/main/java/com/wolinski/shop/traps/RetryPolicy.java
package com.wolinski.shop.traps;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class RetryPolicy {
private final String name;
private int maxAttempts = 3;
@Builder.Default private int backoffMillis = 200;
}RetryPolicy.builder().name("mail").build() gives backoffMillis == 200 and maxAttempts == 0, not 3. Without @Builder.Default, the initializer is dropped. Lombok warns once at compile time (@Builder will ignore the initializing expression entirely), and a policy that makes zero attempts does not fail until a retry is needed. Treat warnings as errors in CI: a test confirms -Werror fails a class like this and leaves clean Lombok code alone, and the JVM's Unsafe message does not trip it, even with -Xlint:all.
Adding @Builder.Default has a side effect of its own. Lombok moves the initializer out of the field, so a constructor you wrote by hand no longer gets it, as its docs warn. In Invoice, the builder gives currency == "PLN", and new Invoice("F-1") gives null, from the same line of source that reads = "PLN".
Hand-written methods switch off generated ones
Lombok decides by name and number of parameters, not by type. The @Data page says of that match that "no warning or error is emitted". So a setQuantity(String) you added for a form removes the generated setQuantity(int), and setQuantity(5) then stops compiling. That one announces itself.
Equality is worse. Add a harmless-looking overload equals(HandWrittenEquals other), and Lombok generates neither equals(Object) nor hashCode. Here you do get a warning, Not generating hashCode: One of equals or hashCode exists, but the build passes, and two items with the same sku are then distinct in a HashSet: the test prints distinct=2.
toString prints your secrets
// src/main/java/com/wolinski/shop/traps/Account.java
package com.wolinski.shop.traps;
import lombok.Data;
import lombok.ToString;
@Data
public class Account {
private final String login;
@ToString.Exclude private final String password;
private final String apiKey;
}Somebody remembered password, and nobody remembered apiKey when it was added months later. toString() contains apiKey=sk-123, on its way to your logs. Exclusion lists fail open: a new field is printed by default. For a class that holds anything sensitive, use @ToString(onlyExplicitlyIncluded = true), so that a new field stays hidden until someone decides otherwise. A record's default toString prints every component too.
@NonNull has gaps
// src/main/java/com/wolinski/shop/traps/Shipment.java
package com.wolinski.shop.traps;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Shipment {
@NonNull private String address;
public static Shipment byHand(String address) {
Shipment shipment = new Shipment();
shipment.address = address;
return shipment;
}
}new Shipment(null) and setAddress(null) both throw. new Shipment() does not, and leaves the "non-null" field null. Nor does byHand(null), because a direct field assignment contains no generated check. The annotation guards the code Lombok writes, not the field.
BigDecimal and the equality you did not choose
The vocabulary table says that two 10 PLN amounts are the same amount. Money from earlier disagrees: new Money(new BigDecimal("10.0"), "PLN") does not equal new Money(new BigDecimal("10.00"), "PLN"). Lombok defers to BigDecimal.equals, which counts the scale, whereas compareTo calls them equal. A record behaves the same way, since it also uses equals on its components. For money, normalise the scale in a factory, or write equals and hashCode by hand, both of them, for the reason given above. Generated equality is only as correct as the equals of each field.
@Value is only shallow
Basket is a @Value with a List<String> field. Add to the list you passed in, and the "immutable" basket changes, along with its hashCode. basket.getSkus().clear() empties it too. Copy collections on the way in, with List.copyOf in a hand-written constructor or through @Singular, which does it for you: a test clears the source list after build() and the order keeps its line. Records have exactly the same limit.
Sneaky exceptions commit transactions
I find this the most dangerous item on the list, since the code looks tidier than the correct version.
// src/main/java/com/wolinski/shop/tx/RefundService.java
package com.wolinski.shop.tx;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class RefundService {
private final JdbcTemplate jdbc;
private final PaymentGateway gateway;
@Transactional
@SneakyThrows
public void refund(String order) {
jdbc.update("insert into refund(order_number) values (?)", order);
gateway.send(order);
}
@Transactional(rollbackFor = Exception.class)
public void refundDeclared(String order) throws IOException {
jdbc.update("insert into refund(order_number) values (?)", order);
gateway.send(order);
}
public interface PaymentGateway {
void send(String order) throws IOException;
}
}Spring's documented default is that "Checked exceptions that are thrown from a transactional method do not result in a rollback in the default configuration". The rule is applied at run time, to the real exception type, so hiding it from the compiler changes nothing. In a test against a real database with a gateway that throws IOException, the row is committed: a refund recorded for a payment that never went out.

The test found a second surprise I had not planned for. My first assertion expected the caller to get an IOException, and it failed. The class-based proxy cannot rethrow a checked exception the method does not declare, so it wraps it in java.lang.reflect.UndeclaredThrowableException. A handler for IOException further up will not run. refundDeclared, with throws IOException and rollbackFor = Exception.class, gets both things right: no row, and the original exception. It needs both parts. The throws clause gives the caller the real exception, and the rollback rule is what undoes the row, since by the rule quoted above a declared IOException still commits.
If you want this safe across a whole application, Spring has a switch for it: @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS). A test confirms it rolls the sneaky case back, and that the caller still gets the wrapper. One caution about the example itself: it calls a payment gateway inside a database transaction to keep the demonstration short. In a real system that is a dual write, since a rollback cannot take back a payment that did go out. That is what the outbox pattern is for.
A qualifier that disappears
// src/main/java/com/wolinski/shop/spring/PriceAlerts.java
package com.wolinski.shop.spring;
import com.wolinski.shop.tour.Mailer;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
@Getter
@RequiredArgsConstructor
public class PriceAlerts {
@Qualifier("fast") private final Mailer mailer;
}The qualifier stays on the field, and the generated constructor parameter, which is what Spring reads, does not get it. What happens next depends on your bean names, and one of the two outcomes is much worse than the other:
beans of type Mailer | result |
|---|---|
fast, slow | Startup fails with NoUniqueBeanDefinitionException. Annoying, and safe. |
fast, mailer | Startup succeeds, and the class gets mailer, not the fast it asked for. |
In the second row Spring falls back to matching the parameter name, which is available because the build compiles with -parameters. This repository turns that on in its POM, and Spring Boot's parent POM does it for you. So in a typical Boot project, a field that names one bean and qualifies another silently gets the wrong one. Adding one line to a lombok.config fixes both rows. It belongs in your root config; the repository has it in one package so that, since configuration applies per directory, it can show both behaviours side by side:
# src/main/java/com/wolinski/shop/spring/copying/lombok.config
# A lombok.config applies to its directory and everything below it, and adds to the one at the project root.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.QualifierJackson sees different names
// src/main/java/com/wolinski/shop/jackson/Contact.java
package com.wolinski.shop.jackson;
import lombok.Data;
@Data
public class Contact {
private String eMail;
private boolean isActive;
private Boolean isVip;
}Lombok has rules for accessor names, and Jackson has its own for turning them back into properties. Here is the same object serialized:
| field | Lombok generates | JSON, Jackson 3.2.4 | JSON, Jackson 2.22.4 |
|---|---|---|---|
String eMail | getEMail() | eMail | email |
boolean isActive | isActive(), setActive() | active | active |
Boolean isVip | getIsVip(), setIsVip() | isVip | isVip |
Each row is a trap. A field named isActive loses its prefix in the JSON, while the same name on a Boolean keeps it. And eMail changes its public name when you upgrade Jackson, without a line of your code changing.
The obvious fix is to put @JsonProperty on the field, and that makes it worse. Since 1.18.40 Lombok no longer copies that annotation to the accessors, so Jackson sees the field and the getter as two different properties and writes both. Here is the same class with @JsonProperty("eMail") and @JsonProperty("isActive") on its fields:
| variant | JSON, Jackson 3.2.4 | JSON, Jackson 2.22.4 |
|---|---|---|
@JsonProperty on the fields | EMail, eMail, active, isActive | email, eMail, active, isActive |
the same, with lombok.copyJacksonAnnotationsToAccessors = true | eMail, isActive | eMail, isActive |
no annotations, fields renamed email and active | email, active | email, active |
The last row is the one to prefer: avoid names like eMail, avoid is on a boolean, and both Jackson versions agree. If the wire name is already fixed and has to differ, use the config key from the middle row. Either way, pin the JSON with a test if it is a contract.
The other Jackson trap is @Data @Builder together. @Builder adds an all-arguments constructor, so @Data stops generating its own and no no-argument constructor exists. Deserializing Parcel fails with a "no Creators" error. @Jacksonized is the intended fix: it points Jackson at the builder. Since 1.18.44 it needs the jacksonVersion line from the lombok.config above if you are on Jackson 3.
// src/main/java/com/wolinski/shop/jackson/JacksonParcel.java
package com.wolinski.shop.jackson;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
@Value
@Builder
@Jacksonized
public class JacksonParcel {
String id;
int grams;
}MapStruct and processor order
MapStruct is also an annotation processor, and it has to see the getters that Lombok creates. Lombok's changelog says that since 1.18.16 "mapstruct users should now add a dependency to lombok-mapstruct-binding". I wanted to see what happens otherwise, so I compiled the same mapper with the processors in each order:

Every build exits 0. The second one has no error in it at all, and at run time it returns an object with null fields. Add the binding, and both orders I tried work:
<!-- pom.xml -->
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
</annotationProcessorPaths>JPA entities: do not use @Data
Entities are where @Data does the most damage. Here is the pattern, with a relation in both directions:
// src/main/java/com/wolinski/shop/jpa/BadCustomer.java
package com.wolinski.shop.jpa;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@Entity
@Data
public class BadCustomer {
@Id @GeneratedValue private Long id;
private String name;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private List<BadOrder> orders = new ArrayList<>();
}BadOrder mirrors it, with a lazy @ManyToOne back to the customer. I ran this on Hibernate 7.4 with H2, and each of these is a test:

toString on a mapped relation, three different failures depending on the state of the objects.- Linked objects overflow the stack.
toStringandhashCodeeach go customer to orders to customer untilStackOverflowError. - A log line runs a query. With the session open,
toStringinitialises the lazy collection. Hibernate's statistics count one collection load for that single call. - A log line throws. With the session closed, the same
toStringthrowsLazyInitializationException. - Persisting moves the hash.
hashCodeincludesid, which goes fromnullto a value on persist. An entity added to aHashSetbeforehand cannot be found afterwards. - Different customers are equal. Two new entities with the same name, both with a
nullid, are equal, so a set treats them as one.
@Value is no way out. A @Value entity persists, and then fails on the first load with No default constructor for entity: Hibernate's guide says the class needs a no-argument constructor and should not be final. The failure arrives after you have written the data.
What works is to choose each annotation, with equality and toString restricted to fields you name:
// src/main/java/com/wolinski/shop/jpa/GoodCustomer.java
package com.wolinski.shop.jpa;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.NaturalId;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class GoodCustomer {
@Id @GeneratedValue
@ToString.Include
private Long id;
@NaturalId
@Column(nullable = false, updatable = false)
@EqualsAndHashCode.Include
@ToString.Include
private String number;
@Setter private String name;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private List<GoodOrder> orders = new ArrayList<>();
public GoodCustomer(String number, String name) {
this.number = number;
this.name = name;
}
public List<GoodOrder> getOrders() {
return Collections.unmodifiableList(orders);
}
public void addOrder(GoodOrder order) {
orders.add(order);
order.assignTo(this);
}
}getOrders is written by hand because @Getter would hand out the mutable list and let callers skip addOrder; Lombok leaves a method alone when it already exists. Equality rests on a natural key, the customer number, which is assigned before persist and never changes. Hibernate's user guide agrees that "using a natural-id is best for equals and hashCode". If you have no such key, the same section describes comparing by identifier with a constant hashCode. I did not test that variant.
The tests show this entity staying in a HashSet through persist, producing a short toString after the session closes, and comparing equal to a Hibernate proxy of itself in both directions. The last one works because Lombok's generated equals uses instanceof and reads through getters, as we saw earlier, so a proxy subclass passes and initialises itself.
That has a price, and it is the limit of this design. Hashing an uninitialised proxy runs one SQL statement to fetch the number. Hashing one after its session has closed throws LazyInitializationException, the same exception as before, now from a much smaller surface. So load what you intend to put in a set, and do so inside the session.
A puzzle: the diff with no logic in it
A colleague tidies up this class before a release. The field order now matches the order on the bank's form, nothing else changes, and the caller is not touched.
// src/test/resources/samples/reorder/before/Transfer.java
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class Transfer {
private final String payer;
private final String payee;
private final long cents;
}// src/test/resources/samples/reorder/after/Transfer.java
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class Transfer {
private final String payee;
private final String payer;
private final long cents;
}// src/test/resources/samples/reorder/Caller.java
public class Caller {
public static void main(String[] args) {
Transfer transfer = new Transfer("alice", "bob", 100);
System.out.println("payer=" + transfer.getPayer() + " payee=" + transfer.getPayee());
}
}The whole project compiles, with no warnings. Reflection is not involved, and you have all the code. What does the program print, compared with before?
The answer
Before the tidy-up it printed payer=alice payee=bob. After it, payer=bob payee=alice. The money goes the other way.
@AllArgsConstructor creates its parameters in field order (its documentation says so in a single sentence), so moving a field changes the constructor. Both are String, so the old call still compiles, now with its arguments meaning the opposite. In a hand-written class the constructor is separate code and the reorder would not touch it. With Lombok, field order is part of your public API, and nothing in the diff says so.
Four things to take away, and the tests cover the first two:
- A builder survives it.
Transfer.builder().payer("alice").payee("bob")gives the right answer after the same reorder, because the names travel with the values. - A record does not. Reordering components of
record Transfer(String payer, String payee, long cents)swaps the values in the same way. So this is not specific to Lombok; it is a risk of any positional constructor with neighbouring parameters of one type. - Types fix it at the root. With separate
PayerandPayeetypes the swapped call would not compile. - You can ban the annotation.
lombok.allArgsConstructor.flagUsage = errorstops the explicit form. By its docs that does not cover the constructor@Valuegenerates, so such a class has the same risk.
Team rules with lombok.config
Lombok has a feature that is easy to miss: it can ban itself, one annotation at a time.
# src/test/resources/samples/policy/lombok.config
config.stopBubbling = true
lombok.data.flagUsage = error
lombok.sneakyThrows.flagUsage = warningWith that file in place, a class using @Data fails to compile with Use of @Data is flagged according to lombok configuration. The warning on @SneakyThrows is a warning like any other, so with -Werror it stops the build too. Scope matters here: in the root config this bans @Data everywhere, so to stop the entity problem coming back while you keep it elsewhere, put the file in your entity package, since a config applies to its directory and below. If the choice deserves an explanation, write it down as an architecture decision record. Note that this is the right tool, and ArchUnit is not: ArchUnit reads compiled classes, and a test here confirms that @Data does not survive compilation.
One catch when you roll such a rule out: lombok.config is not an input Maven tracks. In compat.sh, I add that ban to a compiled project: mvn compile exits 0, because it sees nothing to do, and mvn clean compile exits 1. Make sure CI builds clean, and run clean yourself after changing the config.
There is one more setting worth knowing about. Since 1.18.34, Lombok marks everything it generates with @lombok.Generated, which javap confirms in the class file. The JVM does not load that annotation at run time, so it does not contradict the earlier test that ran without Lombok. Per Lombok's docs, that is useful for JaCoCo and similar tools, so generated equals does not count against your coverage. That cuts both ways: the coverage report will not point you to a generated method nobody ever tested.
Getting out: delombok
Adopting Lombok is reversible, and that should make the decision easier. java -jar lombok.jar delombok src -d out writes plain Java for every class, and a real project also passes its dependencies with --classpath. I tried it on Customer.java, which has none, and there is a catch. The default output does not compile without Lombok:
error: package lombok does not existThe generated methods carry the same @lombok.Generated annotation. Set lombok.addLombokGeneratedAnnotation = false in a config file first, run delombok again, and the result compiles with -proc:none. Customer.java goes from 10 lines to 80.
The output is mechanical, though. equals keeps names such as this$name, so plan to replace the generated equals with something readable, or to turn the class into a record, rather than keeping everything it wrote.
What to reach for

@Data does not appear in it.Should you use Lombok at all? It is a real trade, and I would put it this way:
- If you are starting today on a current JDK, records cover much of what Lombok was first adopted for. What is left is mostly builders, service constructors and logging. Consider whether that is worth a dependency on compiler internals, and an IDE plugin. If you mainly want generated value classes, Immutables and AutoValue use the standard processor API, generating new classes rather than editing yours. I did not test those here.
- If you already have it, there is no emergency. Set the rules in
lombok.config, ban@Datain your entity packages, fail the build on warnings, and move new data carriers to records. - In either case, build on the JDK's early-access releases, because that is the risk you will not see in day-to-day work.
There is also a cost that no test here shows: anything that reads your source instead of your class files sees a smaller program than the compiler does. Lombok's own delombok page says "lombok cannot plug into javadoc", which is the reason delombok exists. The same applies to other source-based tools, so if you use one, such as a Kotlin compiler in a mixed project, check how it handles Lombok first.
Scope and how this was checked
The test suite (./run.sh, 66 tests) runs on Temurin 25 in a Docker image pinned by digest, using Lombok 1.18.48, Hibernate 7.4.14, H2 2.5.252, Spring 7.0.10, Jackson 3.2.4 and 2.22.4, and MapStruct 1.6.3 with binding 0.2.0. Claims about the build are tested with real javac and java processes rather than an in-process compiler, and compat.sh repeats them across JDK 21, 25 and 27; its output is committed in results/compat.txt.
Some limits. The compatibility rows come from compiling one small class, so a green row does not prove your project will build. I did not measure compile-time cost, and I did not test Gradle or any IDE. H2 stands in for a production database; the entity behaviour tested here comes from Hibernate and the generated methods rather than from SQL. The Spring test uses a class-based proxy, and I did not run an interface-based one. And @Data is fine on a small, private, mutable class that never goes into a set or a database; this article picks on it because that is rarely where it ends up.