On This Page
You deployed a new build. It starts fine. Then the first request touches the Redis session store, or an RMI call arrives, or a cache warms up from disk, and every read of an object written by the previous version fails. Nothing is corrupt and nothing is missing. The JVM is telling you that the class in the stream and the class on your classpath are not the same class, and it decided that by comparing one number.
1. The Error
This is what lands in the log on JDK 21:
Exception in thread "main" java.io.InvalidClassException: Order; local class incompatible: stream classdesc serialVersionUID = 2386988060474805082, local class serialVersionUID = 608380690021792991
at java.base/java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:598)
at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2078)
at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1927)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2252)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1762)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:540)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:498)
at Read.main(Read.java:6)On JDK 8 the message text is byte-for-byte the same, but the frames carry no module prefix:
Exception in thread "main" java.io.InvalidClassException: Order; local class incompatible: stream classdesc serialVersionUID = 2386988060474805082, local class serialVersionUID = 608380690021792991
at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:699)
at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2005)I measured this on OpenJDK 8u502, 11.0.32, 17.0.20, 21.0.10 and 25.0.4, Linux x64, no libraries involved. The message is identical on all five; only the line numbers move.
Two numbers matter. stream classdesc serialVersionUID was baked into the bytes when the object was written. local class serialVersionUID is what your current class has. They disagree, so ObjectInputStream throws before reading a single field.
The confusing part is that Order compiles, sits on the classpath exactly once, and has no serialVersionUID field in it. That last fact is the whole problem.
2. Version Behaviour Matrix
| JDK | Behaviour |
|---|---|
| 8 | Message as shown, no java.base/ prefix. java.io.Serial does not exist. -Xlint:serial only warns about a missing serialVersionUID. A class whose inner or anonymous class reads a private member gets a synthetic access$000 method, which changes the computed UID. |
| 11 | Message unchanged. Nestmates (JEP 181) remove that accessor, so the same source hashes to a different UID than on 8. java.io.Serial still absent. |
| 17 | Message unchanged. java.io.Serial available (added in JDK 14). -Xlint:serial still single-check on 17.0.20, as measured. |
| 21 | Message unchanged. -Xlint:serial also flags non-private readObject/writeObject and a wrong-typed serialVersionUID (augmented in JDK 18, JDK-8202056). |
| 25 | Message unchanged. Adds the jdk.SerializationMisdeclaration JFR event (JDK-8275338, delivered in JDK 23). jdk.Deserialization loses its duration field and becomes an instant event. |
Nothing about the failure changed between Java 8 and Java 25. The toolchain around it did, and one of those changes silently moves the number the JVM compares.
3. Reproduce It Yourself
a. What you need
- OpenJDK 21.0.10 (I used
21.0.10+7-Ubuntu-124.04; the container equivalent iseclipse-temurin:21.0.10_7-jdk). Any 8/11/17/21/25 build reproduces it identically. - No build tool, no dependencies, no network:
javacandjavaonly, about 1 MB of disk.
b. The setup files
Create v1 and v2 under a scratch root.
v1/Order.java, the version that writes the stream:
import java.io.Serializable;
public class Order implements Serializable {
private String iban;
private long amountMinor;
public Order(String iban, long amountMinor) {
this.iban = iban;
this.amountMinor = amountMinor;
}
public String toString() {
return "Order[" + iban + "," + amountMinor + "]";
}
}v2/Order.java, next sprint's version. The single added line is what breaks it:
import java.io.Serializable;
public class Order implements Serializable {
private String iban;
private long amountMinor;
private String currencyCode; // <== this one field moves the computed serialVersionUID
public Order(String iban, long amountMinor) {
this.iban = iban;
this.amountMinor = amountMinor;
}
public String toString() {
return "Order[" + iban + "," + amountMinor + "," + currencyCode + "]";
}
}Write.java:
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
public class Write {
public static void main(String[] args) throws Exception {
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(args[0]))) {
out.writeObject(new Order("BE71096123456769", 250000L));
}
System.out.println("wrote " + args[0]);
}
}Read.java:
import java.io.FileInputStream;
import java.io.ObjectInputStream;
public class Read {
public static void main(String[] args) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(args[0]))) {
System.out.println("read " + in.readObject());
}
}
}c. The exact command line
No flags are load-bearing. The failure is pure library behaviour:
java -cp out/v2 Read order.serd. Numbered steps
Step 1 - compile the old version
$ mkdir -p out/v1 out/v2
$ javac -d out/v1 v1/Order.java Write.java Read.java
Expected: no output.
Step 2 - compile the new version
$ javac -d out/v2 v2/Order.java Read.java
Expected: no output.
Step 3 - print both fingerprints before you run anything
$ serialver -classpath out/v1 Order
Order: private static final long serialVersionUID = 2386988060474805082L;
$ serialver -classpath out/v2 Order
Order: private static final long serialVersionUID = 608380690021792991L;
Step 4 - write a stream with the old class
$ java -cp out/v1 Write order.ser
wrote order.ser
Step 5 - read it back with the new class
$ java -cp out/v2 Read order.sere. The confirmation signal
Step 5 fails in 97 ms and exits with code 1. The first line must be exactly:
Exception in thread "main" java.io.InvalidClassException: Order; local class incompatible: stream classdesc serialVersionUID = 2386988060474805082, local class serialVersionUID = 608380690021792991The top frame must be java.io.ObjectStreamClass.initNonProxy, and both numbers must match what serialver printed in step 3.
If you see incompatible types for field ... instead, you changed a field's type while the UID still matched, which is a different failure (section 6). ClassNotFoundException means Order is not on the classpath at all.
f. Environment-specific triggers
- The UID values depend on the exact source. Change a character of the class name, a field name, or a method signature and both numbers move. The mismatch still reproduces; the digits will not match this page.
- The toolchain variant is the interesting one. Take a class with an inner or anonymous class that reads a private field of its enclosing class, and compile it twice with the same JDK 21 compiler:
$ javac --release 8 -d rel8 Order.java # UID = 2656209544088287101
$ javac --release 11 -d rel11 Order.java # UID = 7871049535220833956Same compiler, same source, two different UIDs. --release 17 and --release 21 agree with 11. Nothing in source control changed.
- Records, enums and dynamic proxies always have UID
0L, and for records the matching requirement is waived entirely, so this error cannot occur for them. See section 7.
g. Teardown
rm -rf out rel8 rel11 order.ser4. Why It Happens - Surface Level
When you write a serializable object, the JVM puts a class descriptor into the stream ahead of the field data: class name, flags, field names and types, and a 64-bit serialVersionUID. On read, ObjectStreamClass.initNonProxy finds the same-named class on your classpath, computes or reads its serialVersionUID, and compares. Mismatch means refuse.
If you never declared the field, the JVM computes one from the shape of the class. Adding private String currencyCode changes the shape, changes the number, and makes every stream written by the previous build unreadable. You did not break the data. You changed the fingerprint the data is checked against.
5. Why It Happens - Under the Hood
serialVersionUID is a checksum over the class's declared silhouette, not over its data layout. That distinction causes nearly every surprise here.
The Java Object Serialization Specification defines the computation exactly. These items go, in order, into a DataOutputStream:
- The class name.
- The class modifiers as a 32-bit integer.
- The name of each interface, sorted by name.
- For each field sorted by field name, except
private staticandprivate transientfields: name, modifiers, descriptor. - If a class initializer exists: the name
<clinit>, the modifierSTATIC, the descriptor()V. - For each non-private constructor, sorted:
<init>, modifiers, descriptor. - For each non-private method, sorted: name, modifiers, descriptor.
SHA-1 runs over those bytes and the first eight bytes of the digest become the long, little-endian. The spec's warning is blunt: the computation "is highly sensitive to class details that may vary depending on compiler implementations."
I measured every clause against one base class on javac 8, 11, 17, 21 and 25. The results line up with the spec:
| Change | UID moves? |
|---|---|
| Add a private instance field | Yes |
| Add a private static field with an initializer | Yes (because it creates <clinit>) |
| Add a private static field with no initializer | No |
| Add a private transient field | No |
| Add a non-private method or constructor | Yes |
| Add a private method or private constructor | No |
Widen a field from private to public | Yes |
Add an interface to implements | Yes |
Make the class final | Yes |
| Reorder fields in the source | No (they are sorted by name) |
| Rename a constructor parameter | No (only descriptors count) |
| Change a method body | No |
| Use a lambda inside an existing method | No (the synthetic lambda$... method is private) |
The row people get wrong is <clinit>. Adding private static final String CURRENCY = "EUR"; looks like the safest edit imaginable: it is not a serialized field, and the spec excludes private static fields. But the assignment forces javac to emit a static initializer, and <clinit> is in the hash. That variant and a bare static { ... } block produced the identical UID 1103939587928534099, which is how you know the field contributed nothing and the class initializer contributed everything.
Now the compiler-version trap. Before JDK 11, when an inner or anonymous class touched a private member of its enclosing class, javac generated a package-private bridge:
$ javap -p -cp out8 Order
public class Order implements java.io.Serializable {
private java.lang.String a;
public Order(java.lang.String, long);
public java.lang.String getA();
static java.lang.String access$000(Order); <== javac 8 only
}access$000 is not private, so clause 7 puts it in the hash. JDK 11 introduced nestmates (JEP 181), which let nested classes access each other's private members directly, and javac stopped emitting the accessor. The method vanished from the class file and the UID changed with it. Compiling the same file with javac 21 --release 8 brings both back.
This is why "it worked before we upgraded the build" is a real report and not a misdiagnosis. Bumping --release from 8 to 11 can change the default serialVersionUID of classes nobody touched, and persisted objects, RMI peers and shared session stores break on deploy. Same class of surprise as UnsupportedClassVersionError - a build setting with a runtime consequence - except the build here produces a perfectly valid class file that no longer matches your data.
One asymmetry is worth internalising. The spec lists "changing the access to a field" as a compatible change, because it does not affect the serialized data layout. It does change the computed UID. The compatibility rules govern what the stream format can absorb; the default UID governs whether you are allowed to try. With no declared UID, the second gate slams shut long before the first is consulted.
6. The Fix
The mismatch is a comparison, so make the comparison pass: declare the stream's UID on your new class. The error message already gave you the number.
import java.io.Serializable;
public class Order implements Serializable {
+
+ private static final long serialVersionUID = 2386988060474805082L;
+
private String iban;
private long amountMinor;
private String currencyCode;On JDK 14 and later, annotate it so the compiler checks the declaration for you:
import java.io.Serial;
import java.io.Serializable;
public class Order implements Serializable {
@Serial
private static final long serialVersionUID = 2386988060474805082L;
private String iban;
private long amountMinor;
private String currencyCode;
public Order(String iban, long amountMinor) {
this.iban = iban;
this.amountMinor = amountMinor;
}
public String toString() {
return "Order[" + iban + "," + amountMinor + "," + currencyCode + "]";
}
}java.io.Serial does not exist on 8 or 11, so drop the annotation and its import there. Both forms, run against the old stream on all five LTS builds:
JDK 8 reads the OLD stream: read Order[BE71096123456769,250000,null]
JDK 11 reads the OLD stream: read Order[BE71096123456769,250000,null]
JDK 17 reads the OLD stream: read Order[BE71096123456769,250000,null]
JDK 21 reads the OLD stream: read Order[BE71096123456769,250000,null]
JDK 25 reads the OLD stream: read Order[BE71096123456769,250000,null]currencyCode comes back null. That is correct, and it is the part you must design for: the stream holds no value for a field that did not exist, so the JVM leaves it at the type's default - 0 for a long, false for a boolean. If null is not a legal state, add a private readObject that fills it in, or make the absence meaningful in your domain logic.
Which fix, when
| Situation | Do this | Cost |
|---|---|---|
| Old data must stay readable (cache, session store, queue, files) | Pin the stream's UID and accept defaulted new fields | You now own compatibility forever; every future edit must stay data-compatible |
| No persisted data, only two live services disagreeing | Pin any UID and deploy both sides together | Needs a coordinated release |
| You can discard the old data | Bump the UID deliberately, flush the cache, redeploy | A visible outage window instead of a silent one |
| Both sides are yours and you can change the format | Stop using Java serialization for this hop (section 7) | Real migration work; the only fix that removes the class of bug |
What is not a fix
- Catching
InvalidClassExceptionand returning empty. You have converted a loud failure into silent data loss. Session stores that do this log out every user on deploy and nobody notices for a week. - Copying the UID from whichever side is in front of you. Pin the number from the stream. If the old artifact was built with a different
--release, the old source and the stream disagree, and only the stream is authoritative. - Declaring
serialVersionUIDin a superclass and expecting subclasses to inherit it. They do not. I measured a parent withstatic final long serialVersionUID = 99Land a subclass with nothing: the parent reports99, the child reports430373551017189413. The lookup is per-class and reads the declared field only. That is why the convention isprivate static final long. - Adding the field with a fresh value like
1L. That is a new fingerprint. It fixes future writes and breaks every existing stream.
The sibling messages
If the UID matches but the shapes still disagree, you get a different exception, and the difference tells you what to look at:
java.io.InvalidClassException: S; incompatible types for field b
at java.base/java.io.ObjectStreamClass.matchFields(ObjectStreamClass.java:2207)That is a declared type change (long to int) with a pinned UID. Adding or removing a field with a pinned UID does not throw; it reads and defaults, verified both directions. Two more you will meet:
java.io.InvalidClassException: NV; no valid constructor- the first non-serializable superclass has no accessible no-arg constructor.java.io.InvalidClassException: filter status: REJECTED- a serial filter (JEP 290) blocked the class, not a UID problem at all.
7. Best Practices & The Better Design
Records make this failure impossible. The spec gives record classes a default serialVersionUID of 0L and explicitly waives the matching requirement for them. Records deserialize through their canonical constructor rather than by writing into fields, so the stream is matched by component name. Measured on 17, 21 and 25 (records are JDK 16+): write a two-component record, add a third, read the old stream back.
import java.io.Serializable;
public record Order(String iban, long amountMinor, String currencyCode)
implements Serializable { }uid=0
local uid=0
read Order[iban=BE71096123456769, amountMinor=250000, currencyCode=null]Same defaulting semantics, no fingerprint to manage, and the canonical constructor runs, so your invariants are enforced on the way in. Plain Java deserialization never does that.
Better still: keep Java serialization off the wire. It couples your persisted bytes to your class declarations, and this article is a catalogue of how that coupling bites. For anything crossing a process boundary or outliving a deploy - a Redis session, a Kafka payload, a cache file, an RMI call - use a schema you control: JSON with an explicit DTO, Avro, or Protobuf. Adding a field then becomes a reviewed schema decision, not a side effect of a private keyword.
If you must keep Java serialization, treat every serializable class as published API:
- Declare
@Serial private static final long serialVersionUIDon every serializable class on the day you create it. Once data exists, the number is no longer yours to choose. - Pin
--releasein the build, and never change it for a module with persisted objects without an explicit migration. - Keep serializable types small, flat, and free of inner classes. Every nested class that touches a private member is a compiler-version dependency.
- Configure an
ObjectInputFilter(JEP 290, plus context-specific filters from JEP 415 in JDK 17). Deserializing untrusted bytes is a remote code execution primitive, and the filter is the supported control.
8. How to Prevent It Long-Term
Fail the build for any module that serializes:
javac -Xlint:serial -Werror -d out src/main/java/com/acme/**/*.javaOn JDK 8, 11 and 17 this catches the missing serialVersionUID. On 21 and 25 the augmented checks (JDK 18, JDK-8202056) also catch declarations that look right and do nothing:
L.java:2: warning: [serial] serializable class L has no definition of serialVersionUID
L.java:5: warning: [serial] serialization-related method readObject not declared private
L.java:6: warning: [serial] serialization-related method writeObject not declared privateA public void readObject(...) is never called by the deserializer. It compiles, it passes review, and it does nothing. That check alone is worth the upgrade.
Baseline the fingerprints in CI. serialver reads compiled classes, so diff it against a committed file:
find out -name '*.class' \
| sed 's|^out/||; s|\.class$||; s|/|.|g' | sort > classes.txt
serialver -classpath out $(cat classes.txt | tr '\n' ' ') > serialuids.actual
diff serialuids.expected serialuids.actualAn unreviewed UID change becomes a failing build instead of a 3am page. Run it with the --release value you actually ship.
Watch it in production. There is no -Xlog channel for serialization; I checked -Xlog:help on 21 and there is none. JFR is the only runtime view, and both relevant events are off by default, including under settings=profile. Enable them explicitly:
java -XX:StartFlightRecording=filename=/tmp/ser.jfr,settings=default,\
+jdk.SerializationMisdeclaration#enabled=true,\
+jdk.Deserialization#enabled=true \
-cp app.jar com.acme.Appjdk.SerializationMisdeclaration (JDK 23 and later; I confirmed it is absent from the JDK 21 event set and present on 25) names the exact broken declaration:
jdk.SerializationMisdeclaration {
misdeclaredClass = JT (classLoader = app)
message = "method public void JT.readObject(java.io.ObjectInputStream) must be private"
}jdk.Deserialization exists back to JDK 11 and carries type, bytesRead, depth, objectReferences, filterConfigured, filterStatus and exceptionType; read it with jfr print --events jdk.Deserialization /tmp/ser.jfr.
Two alerts are worth wiring up. A non-zero rate of exceptionType = java.io.InvalidClassException for more than 5 minutes after a deploy means a live version skew. Any jdk.SerializationMisdeclaration event is a standing defect - the check runs roughly once per class, so one event is enough.
Canary the next LTS. Run the test suite on the next LTS in CI, including a test that reads a checked-in golden stream file produced by the shipped build. That one test catches a --release change before production does.
Where this connects
- The compiler-version mechanism here is the same one behind ClassCastException at a line with no visible cast: what javac emits is not always what you wrote.
- Build-target mismatches that surface at runtime are covered in UnsupportedClassVersionError.
- If the class in the stream is missing entirely rather than mismatched, start at NoClassDefFoundError vs ClassNotFoundException.
- Two copies of the same class name from different jars produce NoSuchMethodError and friends rather than this error.
9. Key Takeaways
local class incompatibleis a fingerprint mismatch, not data corruption. The stream's bytes are fine; the number they are checked against moved.- If you never declared
serialVersionUID, the JVM hashes your class's declared shape: non-private methods and constructors, all non-private-static, non-private-transient fields, interfaces, class modifiers and<clinit>. Aprivate static final String X = "EUR";is enough to change it. - The same source can hash differently on different
--releasetargets.javac 21 --release 8and--release 11gave two different UIDs for one file, because JDK 11 nestmates removed a syntheticaccess$000method. - Fix it by pinning the stream's UID from the error message, not a fresh
1L, and design for new fields arriving asnullor0. - Records sidestep the whole problem: default UID
0L, matching waived, deserialization through the canonical constructor. For anything crossing a process boundary, a schema you control beats Java serialization outright.