> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Java InvalidClassException: local class incompatible
- URL: https://www.ggorantala.dev/java-invalidclassexception-local-class-incompatible-serialversionuid/
- Published: 2026-09-12T21:36:26.000Z
- Updated: 2026-09-12T21:36:32.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: Java, java-errors, InvalidClassException, serialization, serialVersionUID, jvm, java-21, java-25

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:

```log
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:

```log
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 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:

```java
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:

```java
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`:

```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`:

```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:

```bash
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:

```log
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

```bash
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:

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

```java
$ 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](https://www.ggorantala.dev/java-unsupportedclassversionerror-class-file-version) \- 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.

```diff
 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:

```java
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 `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:

```log
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.

```java
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:

```bash
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:

```log
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:

```bash
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:

```bash
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:

```java
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](https://www.ggorantala.dev/java-classcastexception-no-visible-cast-generics-erasure): what javac emits is not always what you wrote.
- Build-target mismatches that surface at runtime are covered in [UnsupportedClassVersionError](https://www.ggorantala.dev/java-unsupportedclassversionerror-class-file-version).
- If the class in the stream is missing entirely rather than mismatched, start at [NoClassDefFoundError vs ClassNotFoundException](https://www.ggorantala.dev/java-noclassdeffounderror-vs-classnotfoundexception).
- Two copies of the same class name from different jars produce [NoSuchMethodError and friends](https://www.ggorantala.dev/java-nosuchmethoderror-abstractmethoderror-dependency-conflict) rather than this error.

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