Skip to content

Java InvalidClassException: local class incompatible

InvalidClassException: local class incompatible means the stream's serialVersionUID no longer matches your class. Here is why it drifted, and how to fix it without losing data.

Java java-errors InvalidClassException serialization serialVersionUID jvm java-21 java-25
Gopi Gorantala
Reading Progress

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

JDKBehaviour
8Message 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.
11Message unchanged. Nestmates (JEP 181) remove that accessor, so the same source hashes to a different UID than on 8. java.io.Serial still absent.
17Message unchanged. java.io.Serial available (added in JDK 14). -Xlint:serial still single-check on 17.0.20, as measured.
21Message unchanged. -Xlint:serial also flags non-private readObject/writeObject and a wrong-typed serialVersionUID (augmented in JDK 18, JDK-8202056).
25Message 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 is eclipse-temurin:21.0.10_7-jdk). Any 8/11/17/21/25 build reproduces it identically.
  • No build tool, no dependencies, no network: javac and java only, 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.ser

d. 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.ser

e. 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 = 608380690021792991

The 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 = 7871049535220833956

Same 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.ser

4. 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:

  1. The class name.
  2. The class modifiers as a 32-bit integer.
  3. The name of each interface, sorted by name.
  4. For each field sorted by field name, except private static and private transient fields: name, modifiers, descriptor.
  5. If a class initializer exists: the name <clinit>, the modifier STATIC, the descriptor ()V.
  6. For each non-private constructor, sorted: <init>, modifiers, descriptor.
  7. 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:

ChangeUID moves?
Add a private instance fieldYes
Add a private static field with an initializerYes (because it creates <clinit>)
Add a private static field with no initializerNo
Add a private transient fieldNo
Add a non-private method or constructorYes
Add a private method or private constructorNo
Widen a field from private to publicYes
Add an interface to implementsYes
Make the class finalYes
Reorder fields in the sourceNo (they are sorted by name)
Rename a constructor parameterNo (only descriptors count)
Change a method bodyNo
Use a lambda inside an existing methodNo (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

SituationDo thisCost
Old data must stay readable (cache, session store, queue, files)Pin the stream's UID and accept defaulted new fieldsYou now own compatibility forever; every future edit must stay data-compatible
No persisted data, only two live services disagreeingPin any UID and deploy both sides togetherNeeds a coordinated release
You can discard the old dataBump the UID deliberately, flush the cache, redeployA visible outage window instead of a silent one
Both sides are yours and you can change the formatStop 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 InvalidClassException and 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 serialVersionUID in a superclass and expecting subclasses to inherit it. They do not. I measured a parent with static final long serialVersionUID = 99L and a subclass with nothing: the parent reports 99, the child reports 430373551017189413. The lookup is per-class and reads the declared field only. That is why the convention is private 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 serialVersionUID on every serializable class on the day you create it. Once data exists, the number is no longer yours to choose.
  • Pin --release in 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/**/*.java

On 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 private

A 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.actual

An 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.App

jdk.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

9. Key Takeaways

  • local class incompatible is 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>. A private static final String X = "EUR"; is enough to change it.
  • The same source can hash differently on different --release targets. javac 21 --release 8 and --release 11 gave two different UIDs for one file, because JDK 11 nestmates removed a synthetic access$000 method.
  • Fix it by pinning the stream's UID from the error message, not a fresh 1L, and design for new fields arriving as null or 0.
  • 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.
Javajava-errorsInvalidClassExceptionserializationserialVersionUIDjvmjava-21java-25

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments