Key Takeaways
- Java serialization writes an object graph to a Java-specific stream; deserialization reconstructs it using compatible Java classes.
- Default serialization includes non-static, non-transient instance fields, including private fields.
- An explicit serialVersionUID controls a compatibility check; it does not make incompatible class changes safe.
- Do not deserialize untrusted native streams. Class allowlists and resource limits are defense in depth, not a safety guarantee.
- JSON and Protocol Buffers provide language-neutral data contracts, with different schema, tooling, and operational trade-offs.
Java serialization converts a serializable object graph—an object and the objects reachable through its serialized references—into bytes. Deserialization reads those bytes to reconstruct the graph. Java's built-in format is intended for compatible Java code, not arbitrary browsers, databases, or other programming languages.
The Java Object Serialization specification defines this mechanism. Understand it when maintaining native serialized data, but prefer an explicit data contract when designing a new service boundary.
Why use serialization in Java?
You need a stored or transmitted representation of your data, but you don't necessarily need Java native serialization. For example:
- Database persistence: map a user's name and email to typed columns through APIs such as JDBC's PreparedStatement, rather than storing a native object stream.
- Browser communication: send a JSON representation over HTTP.
- Java-only storage: native serialization can preserve object relationships in a file when you control the classes, data provenance, and compatibility policy.
As an explicit contrast, JavaScript's JSON.parse() reads JSON text, not a Java serialization stream:
const user = JSON.parse('{"name":"Sam","email":"sam@example.com"}');
console.log(user.name); // SamFor the Java side of that approach, see how Jackson ObjectMapper maps Java objects to JSON.
How Java serialization works
The Java Serializable interface
java.io.Serializable is a marker interface: it declares no methods. Implementing it opts a class into the native serialization mechanism; subclasses of a serializable class are also serializable.
Under default serialization, objects reached through non-static, non-transient reference fields must support serialization too. For example, a User containing an Address can fail with NotSerializableException if the referenced Address is not serializable. Custom serialization or object replacement can change what is actually written.
Object graphs, shared references, and cycles
ObjectOutputStream traverses the graph, writes class descriptions and object state, and assigns reference handles. Later references to an already-written object use its handle rather than duplicating its state. For ordinary objects, this preserves shared references and cycles:
root.first ----> User A
root.second ----> User A (same instance)
User A.manager -> User B
User B.manager -> User A (cycle)
Stream: object definitions + handles for repeated referencesAfter reading this graph, root.first and root.second refer to the same reconstructed instance. That is reference identity, not merely value equality; see Java equals() and hashCode() for that distinction. The receiver still needs compatible local classes; a normal serialization stream is not a bundle of application class definitions.
Which fields are saved and restored?
- Instance fields: default serialization saves non-static, non-transient fields regardless of Java access modifiers.
- Static fields: belong to the class and are not saved as per-object state.
- Transient fields: are omitted from the default serialized form.
For an ordinary Serializable class, its constructors and instance field initializers do not run during deserialization. The first non-serializable superclass's accessible no-argument constructor does run. Transient fields declared in the serializable class therefore start at their Java defaults—null for references, zero for numeric primitives, and false for boolean—unless custom code restores them. These rules are detailed in the object input specification.
Java serialization example with ObjectOutputStream and ObjectInputStream
ObjectOutputStream.writeObject() writes the graph; ObjectInputStream.readObject() reads it. Save the following as SerializationDemo.java and run it with JDK 25. The filter is explained in the security section below.
import java.io.IOException;
import java.io.InputStream;
import java.io.InvalidObjectException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
public final class SerializationDemo {
public static final class User implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String name;
private final String email;
private transient String password;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() { return name; }
public String getEmail() { return email; }
public String getPassword() { return password; }
public void setPassword(String password) {
this.password = password;
}
}
public static void main(String[] args)
throws IOException, ClassNotFoundException {
Path file = Files.createTempFile("user-", ".ser");
try {
User original = new User("Sam", "sam@example.com");
original.setPassword(System.getenv("DEMO_PASSWORD"));
try (OutputStream bytes = Files.newOutputStream(file);
ObjectOutputStream out = new ObjectOutputStream(bytes)) {
out.writeObject(original);
}
try (InputStream bytes = Files.newInputStream(file);
ObjectInputStream in = new ObjectInputStream(bytes)) {
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
"maxdepth=5;maxrefs=20;maxarray=16;maxbytes=4096;"
+ User.class.getName() + ";!*"
));
Object value = in.readObject();
if (!(value instanceof User restored)) {
throw new InvalidObjectException("Expected User");
}
System.out.println(restored.getName());
System.out.println(restored.getEmail());
System.out.println(restored.getPassword());
}
} finally {
Files.deleteIfExists(file);
}
}
}Compile and run in a POSIX shell, using an illustrative, non-secret password value:
javac SerializationDemo.java
DEMO_PASSWORD=example-only java SerializationDemoThis prints Sam, sam@example.com, and null, in that order:
Sam
sam@example.com
nullThe name and email survive default serialization; password does not. The @Serial annotation helps compilers check serialization-related declarations.
The example uses try-with-resources to close streams and propagates exceptions so failures remain visible. In application code, distinguish IOException for I/O and serialization failures, ClassNotFoundException for unavailable classes, and InvalidClassException for class compatibility or filter rejection failures. Never replace these failures with an empty catch block.
What serialVersionUID actually controls
For ordinary serializable classes, serialVersionUID is a version identifier checked between the stream's class description and the local class. A mismatch causes InvalidClassException. Without an explicit declaration, the runtime computes a value from class details; seemingly small changes can alter it.
Keep an explicit UID stable only while maintaining the serialized contract. Changing it intentionally rejects older streams; retaining it does not perform a migration or guarantee semantic compatibility. The specification's class evolution rules distinguish changes such as these:
| Change | Default serialization behavior |
|---|---|
| Add an instance field | A newly added field receives its type's default when absent from old data. |
| Remove or rename a field | Removed stream fields can be skipped; renaming does not map the old value to the new name. Older readers can receive defaults from newer streams, breaking their assumptions. |
| Change a primitive field's type | Incompatible; default serialization does not convert the stored primitive type. |
| Switch between Serializable and Externalizable | Incompatible serialized contracts. |
For example, suppose you add this field to User while keeping serialVersionUID equal to 1L:
// Fragment: add inside the User class.
private int loginCount = 7;Reading a stream written before loginCount existed gives that field 0, not 7: the initializer does not run. Changing the UID to 2L instead rejects that older stream. Preserve old serialized fixtures and test both upgrade and rollback readers rather than relying on a same-build round trip.
Serialization lifecycle hooks and Externalizable
Native serialization has executable extension points, not just field copying:
- writeObject and readObject: private class-specific methods customize state. Calling defaultWriteObject or defaultReadObject preserves the default field representation within those hooks.
- writeReplace and readResolve: substitute the object written or the object returned after reading. They support patterns such as serialization proxies and canonical instances.
- Externalizable: gives a class explicit control through writeExternal and readExternal, including responsibility for superclass state and format evolution. Reconstruction uses a public no-argument constructor.
If name and email are required, you could add this validation hook to User using the imports from the complete example:
// Fragment: add inside the User class.
@Serial
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
if (name == null || email == null) {
throw new InvalidObjectException("name and email are required");
}
}Treat deserialization as another construction path and recheck invariants—conditions that must always hold for a valid object. For sensitive mutable state, review defensive copying and validation before assignment as well. Externalizable changes control over representation; it is not an escape from native deserialization risk.
Java deserialization security and ObjectInputFilter
Do not deserialize untrusted data. A crafted stream can cause available classes to execute dangerous behavior during reconstruction. A gadget chain combines such behaviors across classes, potentially enabling remote code execution or denial of service. Checking the result's type after readObject returns is too late to prevent behavior that already ran; see Oracle's deserialization vulnerability guidance.
A file or database BLOB is not inherently trusted: consider who can modify it. Private fields provide no protection against their values appearing in a default serialized stream.
Apply an allowlist before reading objects
The example installs a filter before readObject. Its pattern sets thresholds of graph depth 5, 20 references, array length 16, and 4,096 consumed bytes. These are illustrative budgets, not JDK defaults.
In ObjectInputFilter.Config.createFilter() patterns, semicolons separate rules. User.class.getName() allows the intended class, and the final !* rejects other class-name matches. Array patterns match element types, so this is not an exact root-type check.
Filters do not inspect ordinary field values, and concretely encoded strings and primitives do not trigger filter callbacks. Bound message bytes separately before deserialization; maxbytes is not a hard transport-size limit. Validate the expected root and business data too, and review the behavior of every allowed class. The ObjectInputFilter API documents the filtering model.
Choose filters for each deserialization context
JEP 290 introduced serialization filtering in JDK 9; JEP 415 added context-specific filter selection in JDK 17. A JVM-wide filter factory can select or compose policies for individual streams, including streams created by libraries. Oracle's Secure Coding Guidelines cover both mechanisms.
Do not assume a global filter and a stream-specific filter are automatically combined restrictively. Verify the effective policy, configure all native input paths, and test rejected classes and oversized graphs. Filtering remains defense in depth, not permission to accept arbitrary untrusted streams.
Java serialization versus JSON and Protocol Buffers
JSON defines a language-neutral data format. Jackson data binding maps between that representation and Java objects. Protocol Buffers uses explicit message schemas and a compact binary representation.
| Concern | Native Java serialization | JSON with Jackson | Protocol Buffers |
|---|---|---|---|
| Interoperability | Java-specific class contract. | Language-neutral, human-readable text. | Language-neutral messages with schema-aware tooling. |
| Schema evolution | UID checks plus Java class evolution rules. | Define field names, defaults, and unknown-field policy explicitly. | Follow field-number and wire-compatibility rules. |
| Object identity | Preserves shared references and cycles for ordinary default-serialized graphs. | Represent relationships explicitly, for example with IDs. | Represent relationships explicitly, for example with IDs. |
| Payload characteristics | Includes class metadata and reference information. | Text includes property names; assess compression separately. | Compact typed binary messages; requires schema tooling. |
| Performance evaluation | Measure graph traversal and allocation costs. | Measure parsing, mapping, and compression costs. | Measure generated-code performance on your messages. |
| Security approach | Avoid untrusted input; restrict classes and resources. | Bind to concrete transfer types; avoid arbitrary class-name-based polymorphism. | Parse known message types; validate values and bound resources. |
Choose JSON when browser compatibility and inspectability matter. Choose Protocol Buffers when explicit schemas and compact service messages justify code generation. Neither choice removes input validation or dependency maintenance; benchmark your own messages rather than assuming a universal size or speed ranking.
For Protocol Buffers evolution, never reuse an existing field number; reserve deleted numbers. The Protocol Buffers language guide explains the compatibility rules. For a messaging example using an explicit JSON contract, see the Spring Boot Kafka consumer JSON example.
When replacing native storage, use a restricted migration process with compatible legacy classes, validate the restored data, map it to transfer objects, and write the new format. Retire the native reader after migration instead of keeping it as an undocumented fallback.
Common Mistakes
- Expecting another snapshot from the same stream: writing a mutated instance again normally writes a back-reference, not fresh state. ObjectOutputStream.reset() clears remembered object state so subsequent writes can serialize it again; choose reset boundaries deliberately.
- Retrofitting transient and assuming old data disappeared: existing serialized copies still contain whatever was previously written.
- Testing only successful round trips: also test old fixtures, invalid values, rejected types, resource limits, and dependency changes against your supported readers.
FAQ
What does Serializable do in Java?
Serializable is a marker interface that opts a class into Java's native object serialization mechanism. It does not define a language-neutral schema or guarantee that every referenced object can be serialized.
What is serialVersionUID and when should I change it?
For ordinary serializable classes, serialVersionUID controls a version check during deserialization. Keep it stable for deliberately compatible changes; change it when you intend to reject older streams, and provide a migration strategy where needed.
Is Java deserialization safe for untrusted input?
No. Avoid native deserialization at trust boundaries because object reconstruction can execute dangerous behavior. ObjectInputFilter allowlists and resource limits reduce risk but do not make arbitrary untrusted streams safe.
Does transient prevent a field from being serialized?
Transient omits a field from the default serialized form. Custom code can still write its value, and adding transient does not erase previously stored copies or encrypt data.
When should I use JSON or Protocol Buffers instead of Java serialization?
Use JSON for language-neutral, inspectable representations and Protocol Buffers for explicit schemas and compact binary messages. Native serialization can fit controlled Java-only graphs, but requires deliberate compatibility and security policies.
Further Reading
- Java Object Serialization architecture — object graphs, reference handles, fields, and special cases.
- Versioning serializable objects — compatible and incompatible class evolution.
- ObjectInputStream API — reading, exceptions, and filter callbacks.
- ObjectInputFilter configuration — pattern syntax, budgets, and filter factories.
- Secure Coding Guidelines for Java SE — deserialization validation and sensitive-state handling.
- Jackson databind documentation — Java-to-JSON data binding and project guidance.
- Protocol Buffers overview — schema-based serialization and its trade-offs.
- Protocol Buffers language guide — field numbering and message evolution.
