Custom serialization in Java means adding the private methods writeObject(ObjectOutputStream) and readObject(ObjectInputStream) to a Serializable class, so the class decides which data goes into the stream and how its fields are restored. The JVM finds these methods by their exact signatures and calls them instead of, or in addition to, the default field-by-field serialization.
We need custom serialization when the default format does not fit the class, for example to write a field in a compact or stable format, to handle a field whose type is not serializable, to restore transient fields, or to validate the object after it is read, because deserialization does not run any constructor.
The following example shows the two methods in their shortest useful form, with the default serialization followed by one extra value.
class Session implements Serializable {
private String user = "lokesh";
private transient long loginTime; // not written by default
long loginTime() { return loginTime; }
void login(long time) { loginTime = time; }
@Serial
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // writes user
out.writeLong(loginTime); // extra data
}
@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // reads user
loginTime = in.readLong(); // same order as written
}
}
Notice that both methods are private, call the default method first, and read the extra value in the order it was written. We look at the reasons for custom serialization, write a User class field by field, run a demo, add validation after reading, and cover the related hooks writeReplace(), readResolve() and readObjectNoData().
1. Custom Serialization
With plain Java serialization, ObjectOutputStream writes every non-static, non-transient field, and ObjectInputStream sets the fields back without calling a constructor. That default is fine for simple data classes. We customize it in these cases.
- The serialized form should differ from the fields, so we can rename or restructure fields later without breaking old data.
- A field holds an object that is not Serializable, such as a connection or a cache, and we mark it transient and rebuild it after reading.
- The object has rules that a constructor normally checks, such as an account number greater than zero, and we check them again after reading.
- A field is sensitive, such as a password hash, and we leave it out or write a protected form of it.
Custom serialization requires a change to the class itself, because the JVM looks for the two methods in the class being serialized. For a class we cannot modify, we serialize a wrapper or a DTO instead, or we convert the object to JSON as described in serialize object to String.
2. Implementing readObject() and writeObject()
To customize serialization and deserialization, we define writeObject() and readObject() in the class with these exact signatures. A wrong modifier, name or parameter type makes the JVM ignore the method without any error, so we mark both with @Serial and compile with javac -Xlint:serial, which warns about mistakes such as a writeObject() that is not private.
- Inside writeObject(), we call out.defaultWriteObject() first and write the class data after it with the writeXXX() methods of ObjectOutputStream.
- Inside readObject(), we call in.defaultReadObject() first and read the data after it with the matching readXXX() methods of ObjectInputStream.
- The order of the values in both methods must be the same, because the stream has no field names for data we write ourselves.

The following User class has four fields, all marked transient because the class writes them by hand, and a serialVersionUID. The @Serial annotation, added in Java 14, marks every serialization member.
class User implements Serializable {
@Serial
private static final long serialVersionUID = 7829136421241571165L;
private transient String firstName;
private transient String lastName;
private transient int accountNumber;
private transient LocalDate dateOpened;
User(String firstName, String lastName, int accountNumber, LocalDate dateOpened) {
this.firstName = firstName;
this.lastName = lastName;
this.accountNumber = accountNumber;
this.dateOpened = dateOpened;
}
String getFirstName() { return firstName; }
String getLastName() { return lastName; }
int getAccountNumber() { return accountNumber; }
LocalDate getDateOpened() { return dateOpened; }
@Serial
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeUTF(firstName);
out.writeUTF(lastName);
out.writeInt(accountNumber);
out.writeLong(dateOpened.toEpochDay());
}
@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
firstName = in.readUTF();
lastName = in.readUTF();
accountNumber = in.readInt();
dateOpened = LocalDate.ofEpochDay(in.readLong());
}
}
Without the two methods, a User would come back with null names, a zero account number and a null date, because transient fields get default values.
Call defaultWriteObject() and defaultReadObject() even when every field is transient. One of them, or writeFields() and readFields(), must be called once and only once before any extra data, and a later version of the class can add normal fields without breaking old streams. The date is written as an epoch day, a single long, so the stream does not depend on the internal fields of LocalDate.
The method writeUTF() throws NullPointerException for a null String. For fields that can be null, we use writeObject() and readObject() with a cast, which handle null, or we write a boolean flag before the value.
3. Demo
The demo writes a User to a file with ObjectOutputStream and reads it back with ObjectInputStream. Both streams are opened in try-with-resources blocks, so they are closed even when an exception occurs.
User user = new User("Lokesh", "Gupta", 102825, LocalDate.of(2017, 5, 24));
Path file = Files.createTempFile("user", ".ser");
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
out.writeObject(user);
}
User restored;
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) {
restored = (User) in.readObject();
}
String name = restored.getFirstName() + " " + restored.getLastName(); // "Lokesh Gupta"
int account = restored.getAccountNumber(); // 102825
LocalDate opened = restored.getDateOpened(); // 2017-05-24
Each value comes back from the hand-written data, since the default serialization wrote no fields at all. For a file that other people can change, we also install an ObjectInputFilter on the input stream, as shown in the object-to-String article.
4. Post-Serialization Data Validation
Sometimes we only want to validate the object or run some business rules after deserialization, without changing the default format. Deserialization creates the object without a constructor call, so the checks in the constructor never run. The readObject() method acts as the constructor for the deserialized object, and Effective Java recommends writing it like one.
A bank app shows why this matters. A stream can come from an old version of the class, from a bug in another service or from someone who edited the bytes, and nothing stops it from containing an account with a negative balance. The following Account class uses default serialization and validates the result in readObject().
class Account implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String owner;
private final List<String> tags;
private final int balance;
Account(String owner, List<String> tags, int balance) {
if (balance < 0) throw new IllegalArgumentException("negative balance");
this.owner = Objects.requireNonNull(owner);
this.tags = List.copyOf(tags);
this.balance = balance;
}
int balance() { return balance; }
@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
if (owner == null || tags == null || balance < 0) {
throw new InvalidObjectException("invalid account: " + owner + ", " + balance);
}
}
}
To test the check, we serialize a valid account and change the four bytes of the balance in the stream, as a broken or hostile sender would. The value 500 is 00 00 01 F4 in the stream and becomes FF FF FF FF, which is -1.
static byte[] toBytes(Object obj) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
out.writeObject(obj);
}
return bytes.toByteArray();
}
static Object fromBytes(byte[] data) throws IOException, ClassNotFoundException {
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data))) {
return in.readObject();
}
}
byte[] valid = toBytes(new Account("lokesh", List.of("gold"), 500));
int copyBalance = ((Account) fromBytes(valid)).balance(); // 500
byte[] edited = valid.clone();
for (int i = 0; i < edited.length - 3; i++) {
if (edited[i] == 0 && edited[i + 1] == 0 && edited[i + 2] == 1 && edited[i + 3] == (byte) 0xF4) {
Arrays.fill(edited, i, i + 4, (byte) 0xFF);
}
}
Object broken = fromBytes(edited); // InvalidObjectException: invalid account: lokesh, -1
Without the check, the second read would return an Account with a balance of -1. With the check, the caller gets an InvalidObjectException and never sees the broken object. The default read also sets final fields, so final works with this pattern.
The tags list needs one more thought. A crafted stream can make tags point to a mutable list that the attacker still holds a reference to. When a class keeps mutable fields, its readObject() makes defensive copies, which for a final field means a readResolve() that builds a new object through the constructor, or the serialization proxy pattern described in the next section.
5. Other Serialization Hooks
Besides the two main methods, the serialization runtime looks for three more methods by name. All of them can be marked with @Serial.
| Method | Called | Typical use |
|---|---|---|
| Object writeReplace() | Before writing, on the object being serialized | Write a different object, such as a serialization proxy |
| Object readResolve() | After reading, before returning the object | Return a canonical instance, such as a singleton, or rebuild through the constructor |
| void readObjectNoData() | When the stream has no data for this class | Set valid defaults after a superclass was added to the hierarchy |
A singleton is the classic case for readResolve(). Every deserialization creates a new object, and readResolve() replaces it with the one instance.
class AppSettings implements Serializable {
static final AppSettings INSTANCE = new AppSettings();
private AppSettings() {}
@Serial
private Object readResolve() {
return INSTANCE;
}
}
Object copy = fromBytes(toBytes(AppSettings.INSTANCE));
boolean sameInstance = copy == AppSettings.INSTANCE; // true
An enum gives the same guarantee without any code, which is why Effective Java recommends a single-element enum for serializable singletons. In the serialization proxy pattern, writeReplace() writes a small private nested class that holds only the logical data, and its readResolve() creates the real object through the public constructor, so every check in the constructor runs.
Two more rules apply in class hierarchies and with records.
- Each class in a hierarchy handles only its own fields in its own writeObject() and readObject(), and the runtime calls them from the top superclass down.
- A record ignores writeObject(), readObject() and readObjectNoData(), because records are always rebuilt through their canonical constructor, so validation goes into that constructor. The methods writeReplace() and readResolve() still work for records.
When a class must control the whole format, including superclass data, Externalizable is the other option, with a public no-argument constructor and the methods writeExternal() and readExternal().
6. Custom Serialization FAQs
A class with its own serialization methods raises questions about visibility, ordering and the alternatives.
6.1. Why are writeObject() and readObject() private?
The serialization runtime calls them through reflection, so they do not need to be visible. Keeping them private means subclasses cannot override them and other code cannot call them, and each class in a hierarchy keeps control of its own fields. A public or protected version is ignored by the runtime.
6.2. What happens if the read order differs from the write order?
The values get mixed up or the read fails. The stream does not label custom data, so reading an int where a long was written returns wrong numbers, and reading past the data throws EOFException or OptionalDataException.
6.3. Do we need to call defaultWriteObject() if all fields are transient?
Yes. The specification requires defaultWriteObject() or writeFields() once before any custom data, and the same for defaultReadObject() or readFields() on the reading side. Skipping it can break deserialization when a later class version adds fields.
6.4. Is readObject() called for records?
No. For a record, the runtime reads the component values and calls the canonical constructor, and it ignores readObject() and writeObject(). We put validation into the compact constructor of the record instead.
6.5. Should we use custom serialization or JSON?
For new data that leaves the JVM, JSON with Jackson or Gson is the better choice, since it is portable and safer to read from untrusted sources, as in our Gson custom serialization and Jackson custom serializer guides. Custom writeObject() and readObject() methods matter for classes that already use Java serialization, such as sessions in a servlet container.
7. Conclusion
Custom serialization is two private methods with fixed signatures. The method writeObject() calls defaultWriteObject() and writes extra data, and readObject() calls defaultReadObject() and reads the same data in the same order. Marking both with @Serial and compiling with -Xlint:serial catches some signature mistakes, such as a missing private modifier.
The most valuable use is often readObject() as a second constructor that validates the object, because deserialization skips the real constructor. For singletons and proxies, readResolve() and writeReplace() complete the set, and records skip the custom methods and rely on their canonical constructor.
8. References
- Java Object Serialization Specification, The writeObject Method
- Java Object Serialization Specification, The readObject Method
- Serializable Javadoc
- Serial Javadoc
- ObjectInputStream Javadoc
Happy Learning !!
import java.io.*; public class Solution implements Serializable { public static class A { String nameA = "A"; //static String nameA="A"; public A(){ } public A(String nameA) { this.nameA += nameA; } } public class B extends A implements Serializable { private String nameB; public B(String nameA, String nameB) { super(nameA); this.nameA += nameA; this.nameB = nameB; } // private void writeObject(ObjectOutputStream oos) throws IOException { // oos.defaultWriteObject(); // oos.writeObject(this.nameA); // oos.writeObject(this.nameB); // } // private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { // ois.defaultReadObject(); // this.nameA = (String)ois.readObject(); // this.nameB = (String)ois.readObject(); // } } public static void main(String[] args) throws IOException, ClassNotFoundException { ByteArrayOutputStream arrayOutputStream = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(arrayOutputStream); Solution solution = new Solution(); B b = solution.new B("B2", "C33"); System.out.println("nameA: " + b.nameA + ", nameB: " + b.nameB); oos.writeObject(b); ByteArrayInputStream arrayInputStream = new ByteArrayInputStream(arrayOutputStream.toByteArray()); ObjectInputStream ois = new ObjectInputStream(arrayInputStream); B b1 = (B)ois.readObject(); System.out.println("nameA: " + b1.nameA + ", nameB: " + b1.nameB); } }Hi , thanks for this article. It really helped me understanding Custom Serialization in deep .
i totally understood your article i believe but the purpose for which i opened this article in first place , is still unanswered.
your article helped me understand the things which was not known to me like defaultReadObject etc.
would you be more kind to help with above code .
when i run this code: output is:
nameA: AB2B2, nameB: C33
nameA: A, nameB: C33
and when i make change in above code like
// String nameA = “A”; –commented this
static String nameA=”A”; — uncommented this.
output is:
nameA: AB2B2, nameB: C33
nameA: AB2B2, nameB: C33
i am not able to understand why. Already invested 4-5 hours but still did not understand. Hope, you would help with this too.
thanks you so much. have a good day.
During deserialization, after the blank instance is created, JVM first set it’s static fields and then invokes the
readObject()API. Read more how the deserialization happen in Java?Hi
This is valid scenario , if we can manipulate serialization based on business logic, then what is the difference between this and externalization.
Please explain with example.
Regards
Rohit
What if the account number (or any other field) is final? Is there a way to serialize it using own write/readObject methods? The default serialization would work, but this custom one not.
no such method for write and set object at java class
Good explanation.
Consider explaining handling a case where a file was serialized with an older version of the class and then deserialized with a newer version of the class. The newer version has more fields than the older version. The readObject of the newer version will try to read additional fields which will throw IOException.
So In order to keep track of the no of fields in the class, a field counter could be used. The field counter is then serialized by the
defaultWriteObject()method. WhiledefaultReadObject()reads the counter and then limit the reads to the counter value thus avoiding to read the fields added in the newer version. This way the newer version becomes compatible with the backward versions.My own implementation of User Class
package com.amit.serialization; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.util.Date; public class User implements Serializable { private static final long serialVersionUID = 1L; private transient String firstName; private transient String lastName; private transient int accountNumber; private transient Date dateOpened; private transient String bankName; // fieldCount to keep track of the no of fields in the Object. For compatibility between versions. private int fieldCount = 0; public User(String firstName, String lastName, int accountNumber, Date dateOpened, String bankName) { super(); this.firstName = firstName; this.lastName = lastName; this.accountNumber = accountNumber; this.dateOpened = dateOpened; this.bankName = bankName; this.fieldCount = 5; } public User() { super(); } //Setters and Getters private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { aInputStream.defaultReadObject(); int currentCount = 0; if (checkCount(currentCount)) { return; } firstName = aInputStream.readUTF(); currentCount++; if (checkCount(currentCount)) { return; } lastName = aInputStream.readUTF(); currentCount++; if (checkCount(currentCount)) { return; } accountNumber = aInputStream.readInt(); currentCount++; if (checkCount(currentCount)) { return; } dateOpened = new Date(aInputStream.readLong()); currentCount++; if (checkCount(currentCount)) { return; } bankName = aInputStream.readUTF(); } private boolean checkCount(int currentCount) { if (currentCount >= this.fieldCount) { return true; } return false; } private void writeObject(ObjectOutputStream aOutputStream) throws IOException { aOutputStream.defaultWriteObject(); aOutputStream.writeUTF(firstName); aOutputStream.writeUTF(lastName); aOutputStream.writeInt(accountNumber); aOutputStream.writeLong(dateOpened.getTime()); aOutputStream.writeUTF(bankName); } public final String getFirstName() { return firstName; } public final String getLastName() { return lastName; } public final int getAccountNumber() { return accountNumber; } public final Date getDateOpened() { return new Date(dateOpened.getTime()); } public final void setFirstName(String aNewFirstName) { firstName = aNewFirstName; } public final void setLastName(String aNewLastName) { lastName = aNewLastName; } public final void setAccountNumber(int aNewAccountNumber) { accountNumber = aNewAccountNumber; } public final void setDateOpened(Date aNewDate) { Date newDate = new Date(aNewDate.getTime()); dateOpened = newDate; } public final void setBankName( String aNewBankName) { bankName = aNewBankName; } public final String getBankName() { return bankName; } }At all the times, the new fields should be appended to the class and never added in-between.
For more complex data structure like a linked list, this simple counter might not work. If may then require a more complicated tracking & reading mechanism.
Please refer to do’s and don’ts for correct serialization.
If you add any new field in class. And then you’re de-serializing your persisted object of old class in to the class with newer fields. All the newer fields will get populate by its’ default datatype value.
The serialization process is instance-independent, i.e. objects can be serialized on one platform and deserialized on another . It is important concept in java. Thanks a lot for sharing this.
I have a concern.When we have readObject and writeObject () for custom serialization then what is the use of Externalizable.
Please elaborate?
Java runtime uses reflection to determine how to marshal or unmarshal the objects of the Serializable class. During early versions of Java, reflection was very slow hence if you needed to serialize or deserialize large objects, then it used to cause a performance problem.
To handle this Externalizable was introduced, which provided an option to remove the dependency on reflection by providing custom serialization and deserialisation logic. But after Java 1.3 there was a major improvement in the speed of reflection which filled the performance gap between Serializable and Externalizable.
So to summarise, Externalizable is some thing which was useful in java 1.1 days, but there is no need for it anymore.
In User class accountNumber fields is `int`. Is it possible to change the datatype of accountNumber to `double` while serializing object.
With the serialized stream can I able to get this class object,
public class User implements Serializable {
private static final long serialVersionUID = 7829136421241571165L;
private String firstName;
private String lastName;
private Double accountNumber;
private Date dateOpened;
}