Skip to content

Traditional Java File Handling: Streams, Buffers, Readers, and the Decorator Pattern

Java gives you two broad approaches to working with files. The first is the traditional stream based API that has been part of Java since the beginning. The second is NIO, which is the newer, more modern approach. Before you can appreciate what NIO improves upon, you need to understand the traditional approach deeply. And honestly, the traditional API is still very much alive in production systems today. Kafka, for example, writes its logs to disk and makes heavy use of NIO, but that does not mean the traditional approach has been retired. You will encounter it regularly, and interviewers love asking about it.

This article walks you through the entire traditional file handling landscape from the ground up. You will understand not just how to use each class, but why the design is the way it is and what problem each piece is solving.

Everything Is Bytes at the Bottom

Before touching a single class, you need to internalize one fundamental truth: at the lowest level, everything is bytes. It does not matter whether you are reading a text file, writing integers, storing images, or serializing objects. The operating system only understands bytes. Every interaction with the file system goes through system calls that deal in raw bytes.

This is not a Java limitation. It is how operating systems work. Java's file handling API is built on top of this reality, and once you understand it, the entire class hierarchy clicks into place.

The Two Root Abstract Classes

At the foundation of all traditional Java file I/O sit two abstract classes:

  • OutputStream for writing bytes
  • InputStream for reading bytes

These are abstract, meaning you never instantiate them directly. They define the contract. They are the ones that ultimately talk to the operating system through system calls. Every read call causes a system call. Every write call causes a system call. A system call means switching from user space to kernel space, which has overhead.

This detail about system calls matters enormously for performance, and it is the central motivation for everything else we are going to cover.

The Core: FileOutputStream and FileInputStream

The concrete implementations of those two abstract classes that actually interact with real files are:

  • FileOutputStream to write bytes to a file
  • FileInputStream to read bytes from a file

Here is the most basic way to write bytes to a file:

java
// Writing bytes directly to a file
try (FileOutputStream fos = new FileOutputStream("data.bin")) {
    fos.write(72); // writes the byte 72 (which is 'H' in ASCII)
    fos.write(101); // 'e'
    fos.write(108); // 'l'
    fos.write(108); // 'l'
    fos.write(111); // 'o'
}

And reading those bytes back:

java
// Reading bytes one at a time
try (FileInputStream fis = new FileInputStream("data.bin")) {
    int byteValue;
    while ((byteValue = fis.read()) != -1) {
        // read() returns -1 at end of file
        System.out.print((char) byteValue);
    }
}

The read() method returns an int rather than a byte. The value is between 0 and 255 for actual data, and -1 signals end of file. You cast to char when you want to print as text.

Notice the try with resources syntax. The parentheses after try declare resources that implement AutoCloseable. Java guarantees those resources get closed when the block exits, whether normally or by exception. This is how you handle file I/O in modern Java. Never rely on manually calling close() in a finally block. That approach is error prone and the try with resources pattern replaces it entirely.

You can also use a File object as a helper for validation before you open the stream:

java
File file = new File("data.bin");
if (!file.exists()) {
    System.out.println("File not found");
    return;
}
try (FileInputStream fis = new FileInputStream(file)) {
    // safe to read
}

The File class is just a helper that represents a path. It does not open any stream. It gives you methods like exists(), length(), getName(), and isDirectory() for checking things about a file before you decide to open it.

The Performance Problem: System Calls Are Expensive

Here is the problem. If you have a file with one million bytes in it and you call fis.read() in a loop, you make one million system calls. Each system call requires the CPU to switch from your program (user space) into the operating system kernel and back. That switching is not free. It takes time.

Think of it like this: imagine you need to move a thousand books from one room to another. You could carry one book at a time, walking back and forth a thousand times. Or you could stack twenty books on a cart, make fifty trips, and finish in a fraction of the time. The individual trip is the system call. The books per trip are the bytes. Buffering is the cart.

Now imagine you have one million bytes. Without buffering you make one million system calls. With buffering you might make only around 123 system calls (one million divided by 8192 bytes per buffer load). The difference in performance is dramatic. This is why buffering exists.

The Decorator Pattern: Adding Capabilities in Layers

Before we look at buffering classes, you need to understand the design pattern behind the entire traditional I/O class hierarchy: the Decorator Pattern.

The decorator pattern lets you wrap an object with another object that adds new behavior, without changing the original class. In Java I/O, FileOutputStream and FileInputStream are the core objects at the center. Everything else is a wrapper that adds capabilities on top.

Think of it like a coffee shop order. Your base is plain coffee. You can add milk on top. Then add sugar on top of that. Then add foam on top. Each addition wraps the previous one and adds a new capability, but the base coffee is still inside. In Java I/O, FileOutputStream is your plain coffee. BufferedOutputStream wraps it to add buffering. DataOutputStream wraps that to add primitive type awareness. ObjectOutputStream wraps that to add object serialization.

Here is the key: each wrapper takes the thing it wraps as a constructor argument. That is the decoration. And because they all share a common base type (OutputStream or InputStream), you can chain them together however you like.

BufferedOutputStream and BufferedInputStream

Adding buffering is simple. You wrap the core stream:

java
// Writing 1000 lines with buffering
try (
    FileOutputStream fos = new FileOutputStream("output.txt");
    BufferedOutputStream bos = new BufferedOutputStream(fos)
) {
    for (int i = 0; i < 1000; i++) {
        String line = "Line " + i + "\n";
        bos.write(line.getBytes()); // goes to internal buffer, not disk yet
    }
    // bos.flush() happens automatically on close
}

When you call bos.write(...), the bytes do not go to disk immediately. They go into an internal buffer (8192 bytes by default). Only when that buffer fills up, or when you flush, or when you close the stream, does the actual write to disk happen via a single system call.

Reading with a buffer works the same way in reverse:

java
// Reading with buffering
try (
    FileInputStream fis = new FileInputStream("output.txt");
    BufferedInputStream bis = new BufferedInputStream(fis)
) {
    int byteValue;
    while ((byteValue = bis.read()) != -1) {
        // Each bis.read() call hits the internal buffer first
        // Only when buffer is empty does it make a system call for 8192 more bytes
        System.out.print((char) byteValue);
    }
}

The first call to bis.read() causes a system call that loads up to 8192 bytes from the file into the buffer. All subsequent calls to bis.read() serve bytes from that buffer with no system call at all. Only when the buffer is exhausted does another system call happen to refill it.

The speed difference is measurable. When writing 50,000 bytes to a file, using FileOutputStream directly (one system call per write) takes significantly longer than using BufferedOutputStream (far fewer system calls). You can prove this yourself by benchmarking with System.nanoTime().

An important point: if you use BufferedOutputStream and forget to call flush() or close(), bytes sitting in the buffer that have not yet reached the disk will be lost. Always close your streams. The try with resources pattern ensures this happens.

DataOutputStream and DataInputStream: Primitive Type Awareness

FileOutputStream knows only bytes. BufferedOutputStream adds buffering to byte writing. But what if you want to write an int? An int in Java is four bytes. If you try to write it with plain FileOutputStream.write(int), you will notice that write only writes the lowest one byte of whatever int you pass. To write a full integer you would have to manually break it into four bytes, write each one, and then when reading you would have to read four bytes and reassemble them. That is tedious and error prone.

DataOutputStream solves this. It adds knowledge of Java's primitive types:

java
// Writing primitive types
try (
    DataOutputStream dos = new DataOutputStream(
        new BufferedOutputStream(
            new FileOutputStream("game.bin")
        )
    )
) {
    dos.writeInt(9500);       // player ID, exactly 4 bytes
    dos.writeDouble(87.5);    // player score, exactly 8 bytes
    dos.writeBoolean(true);   // active flag, 1 byte
    dos.writeLong(1234567L);  // timestamp, 8 bytes
    dos.writeChar('A');       // level, 2 bytes
}

And reading them back in exactly the same order:

java
// Reading primitive types back
try (
    DataInputStream dis = new DataInputStream(
        new BufferedInputStream(
            new FileInputStream("game.bin")
        )
    )
) {
    int playerId     = dis.readInt();
    double score     = dis.readDouble();
    boolean isActive = dis.readBoolean();
    long timestamp   = dis.readLong();
    char level       = dis.readChar();
    
    System.out.println("Player " + playerId + " score: " + score);
}

You must read in the same order you wrote. DataInputStream and DataOutputStream do not store any type metadata in the file. They just know how many bytes each type occupies and handle the conversion for you. Reading in a different order will give you garbage values.

ObjectOutputStream and ObjectInputStream: Full Object Serialization

DataOutputStream handles primitive types field by field. But what if you have an object with ten fields? Writing each field individually is workable, but what if you have a hundred objects, each with ten fields? That becomes impractical.

Java's answer is serialization: converting a complete object graph into a byte sequence and back. To make a class serializable, it must implement the Serializable marker interface:

java
import java.io.Serializable;

public class Player implements Serializable {
    private static final long serialVersionUID = 1L;
    
    private int id;
    private String name;
    private double score;
    private transient String sessionToken; // will NOT be serialized
    // static fields are also NOT serialized (they belong to the class, not the instance)
    
    // constructors, getters, setters...
}

Two important points about what does and does not get serialized:

  1. Fields marked transient are skipped during serialization. You commonly see this with sensitive data like passwords, tokens, or encryption keys. Even if the object gets written to disk or transmitted over a network, those fields will not be included. When the object is deserialized, transient fields get their default value (null for objects, 0 for numbers, false for booleans).

  2. static fields are not serialized either. Static fields belong to the class, not to any particular instance, so they have no place in a serialized object.

Writing and reading objects:

java
Player player = new Player(1, "Alice", 9500.0, "token123");

// Writing an object
try (
    ObjectOutputStream oos = new ObjectOutputStream(
        new BufferedOutputStream(
            new FileOutputStream("player.dat")
        )
    )
) {
    oos.writeObject(player); // entire object tree converted to bytes
}

// Reading it back
try (
    ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(
            new FileInputStream("player.dat")
        )
    )
) {
    Player loaded = (Player) ois.readObject(); // cast is required
    System.out.println(loaded.getName());
}

Notice the full decorator chain: FileOutputStream at the core, wrapped by BufferedOutputStream for performance, wrapped by ObjectOutputStream for object awareness. Each layer adds exactly one capability, and you can mix and match layers as your needs require.

The Character Problem: Why Byte Streams Are Not Enough for Text

You might be wondering: we already showed reading text with FileInputStream earlier. The bytes got cast to char and it worked. What is the problem?

It works for ASCII characters. Every ASCII character fits in one byte. But many characters do not. The euro sign, Hindi script, Chinese characters, emoji, and countless other characters from around the world require multiple bytes to represent. The UTF-8 encoding, for example, uses one byte for ASCII characters but up to four bytes for characters outside the ASCII range. The dollar sign in UTF-8 is one byte. Many characters outside the English alphabet take two or three bytes. Some emoji take four bytes.

If you read a UTF-8 file byte by byte with FileInputStream and cast each byte to char, you get garbage for any character that requires more than one byte. You are reading one byte and treating it as a complete character when it is only a fragment.

What you need is a reader that knows about encoding: one that can look at a group of bytes, understand that they together form one character, decode them correctly, and return that single character. That is what InputStreamReader and OutputStreamWriter provide.

OutputStreamWriter and InputStreamReader: Encoding Awareness

These two classes bridge the gap between the byte world and the text world:

java
// Writing text with explicit encoding
try (
    OutputStreamWriter writer = new OutputStreamWriter(
        new FileOutputStream("text.txt"),
        "UTF-8"   // specify the character encoding
    )
) {
    writer.write("Hello");
    writer.write("€"); // euro sign, requires 3 bytes in UTF-8
    writer.write("नमस्ते"); // Hindi text, multiple bytes per character
}
java
// Reading text with the same encoding
try (
    InputStreamReader reader = new InputStreamReader(
        new FileInputStream("text.txt"),
        "UTF-8"
    )
) {
    int ch;
    while ((ch = reader.read()) != -1) {
        System.out.print((char) ch); // correct characters, not garbage
    }
}

InputStreamReader knows the encoding. When you call read(), it internally reads however many bytes the encoding requires for the next character, decodes them together, and returns the correctly decoded character. If the encoding is UTF-8 and the next character is a three byte sequence, it reads all three bytes, decodes them, and returns the single char.

An important design point: once you wrap a stream with InputStreamReader or OutputStreamWriter, the result is no longer an InputStream or OutputStream. It is a Reader or Writer. This matters for the next layer. You cannot wrap a Reader with BufferedOutputStream. You must wrap it with BufferedReader. The hierarchy splits at this point into a separate reader/writer branch.

Reader and Writer: The Abstract Base Classes for Text

Just as InputStream and OutputStream are the abstract bases for byte I/O, Reader and Writer are the abstract bases for character I/O. InputStreamReader extends Reader. OutputStreamWriter extends Writer. And the buffered versions for the character world are BufferedReader and BufferedWriter.

BufferedReader and BufferedWriter: Buffered Character I/O

BufferedReader adds two things on top of InputStreamReader:

  1. Buffering of decoded characters. Without BufferedReader, every call to reader.read() reads the bytes for one character, decodes them, and returns. With BufferedReader, it reads and decodes many characters at once, stores them in an internal buffer, and serves them one by one from that buffer. You get the decode work done in batches instead of on every single read.

  2. The readLine() method. This is the killer feature. Instead of reading character by character, you can read an entire line of text in one call.

java
// The full chain for reading text files efficiently
try (
    BufferedReader br = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("text.txt"),
            "UTF-8"
        )
    )
) {
    String line;
    while ((line = br.readLine()) != null) {
        // readLine() returns null at end of file (not -1 like streams)
        System.out.println(line);
    }
}

Writing with BufferedWriter:

java
// The full chain for writing text files efficiently
try (
    BufferedWriter bw = new BufferedWriter(
        new OutputStreamWriter(
            new FileOutputStream("output.txt"),
            "UTF-8"
        )
    )
) {
    bw.write("Hello, world!");
    bw.newLine(); // writes the correct line separator for the current OS
    bw.write("Second line here");
    bw.newLine();
}

To read a character versus reading until end of stream: read() on a Reader returns an int that is -1 at end of stream. But readLine() on a BufferedReader returns null at end of stream. Do not confuse the two. Using "".equals(readLine()) as your end condition will fail because readLine() returns an empty string for blank lines, not null.

FileReader and FileWriter: The Convenient Shortcut

Java provides FileReader and FileWriter as convenient shortcuts that combine the file opening and decoding steps. FileReader extends InputStreamReader and internally creates a FileInputStream. FileWriter extends OutputStreamWriter and internally creates a FileOutputStream.

The problem with FileReader and FileWriter is that they use the platform default encoding. On different operating systems, the default encoding can be different. Files written on a Windows machine might not read correctly on a Linux machine if the file contains characters outside the basic ASCII range. For anything serious, prefer the explicit chain with InputStreamReader/OutputStreamWriter and a specified encoding like UTF-8.

That said, FileReader is commonly used in examples because it is concise:

java
// FileReader is shorthand for InputStreamReader(new FileInputStream(file))
// but uses platform default encoding
try (BufferedReader br = new BufferedReader(new FileReader("notes.txt"))) {
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
}

PrintWriter: Formatted Text Output

PrintWriter is a Writer subclass that adds formatted printing methods similar to System.out:

java
try (PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter("log.txt")))) {
    pw.println("Application started");
    pw.printf("Score: %d at %.2f seconds%n", 9500, 3.14);
    pw.println("Application ended");
}

PrintWriter has a constructor that accepts a file path directly as a string, which is a convenient shortcut for simple cases:

java
try (PrintWriter pw = new PrintWriter("log.txt")) {
    pw.println("Hello from PrintWriter");
}

PrintWriter never throws IOException from its writing methods. Instead, it sets an error flag that you can check with checkError(). This is by design for logging scenarios where you do not want checked exceptions interrupting your normal flow. Just be aware that writes can silently fail if you do not check that flag.

Try With Resources: The Right Way to Handle Streams

Always use try with resources for streams. This was introduced in Java 7 and it is not optional for quality code. The syntax is:

java
try (Resource1 r1 = new Resource1(); Resource2 r2 = new Resource2()) {
    // use r1 and r2
} catch (IOException e) {
    // handle exceptions from both the try block AND the close calls
}

Resources are closed in reverse order of declaration. If you declare FileOutputStream first and BufferedOutputStream second, then BufferedOutputStream closes first and FileOutputStream closes second. This is correct because the outer wrapper should be flushed and closed before the underlying resource closes.

The alternative, manual resource management, looks like this and is substantially more fragile:

java
// The old way - do NOT write code like this
BufferedOutputStream bos = null;
FileOutputStream fos = null;
try {
    fos = new FileOutputStream("data.bin");
    bos = new BufferedOutputStream(fos);
    bos.write(42);
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (bos != null) {
        try { bos.close(); } catch (IOException e) { /* swallowed */ }
    }
}

The try with resources version is cleaner, less code, and correctly handles the case where both the operation and the close() throw exceptions (using suppressed exceptions).

The Complete Decorator Pattern Picture

Here is a visual summary of how the layers stack:

OBJECT LAYER (full object serialization)
    ObjectOutputStream / ObjectInputStream
            |
PRIMITIVE LAYER (int, double, long, boolean, char)
    DataOutputStream / DataInputStream
            |
BUFFER LAYER (performance via memory buffer)
    BufferedOutputStream / BufferedInputStream
            |
CORE LAYER (actual OS interaction via system calls)
    FileOutputStream / FileInputStream
            |
        FILE ON DISK

For the character/text branch:

BUFFER + READLINE LAYER
    BufferedWriter / BufferedReader
            |
ENCODING LAYER (UTF-8, UTF-16, etc.)
    OutputStreamWriter / InputStreamReader
            |
BUFFER LAYER (optional but recommended)
    BufferedOutputStream / BufferedInputStream
            |
CORE LAYER
    FileOutputStream / FileInputStream
            |
        FILE ON DISK

Every layer wraps the layer below it. You include exactly the layers you need for your use case. Working with raw bytes and need speed? Add buffering. Working with text? Add encoding awareness. Working with objects? Add serialization. The pattern is consistent throughout.

Common Pitfalls

Forgetting to flush. When you write through a BufferedOutputStream or BufferedWriter, bytes sit in the buffer until the buffer is full or you explicitly flush. If your program crashes or exits without closing the stream, that buffered data is lost. Always use try with resources to ensure the stream is closed and therefore flushed.

Reading and writing in different order with DataStreams. If you write an int, then a double, then a boolean, you must read them back in exactly that order. The file contains no type metadata. Reading in wrong order gives meaningless values with no error thrown.

Using FileReader without specifying encoding. For files that might contain characters outside the ASCII range, always specify the encoding explicitly. Using the platform default encoding makes your code behave differently across operating systems.

Casting -1 to char. When fis.read() returns -1 for end of file, if you cast that to char without checking first, you get the character with value 65535 printed as garbage. Always check for -1 before casting.

Checking for empty string instead of null with readLine(). readLine() returns null at end of file, not -1 and not an empty string. An empty string means the file contained an empty line. Check for null.

Appending versus overwriting. new FileOutputStream("file.txt") overwrites existing content. new FileOutputStream("file.txt", true) appends to existing content. The second boolean argument controls append mode.

Interview Questions You Will Face

What is the difference between a byte stream and a character stream? Byte streams work with raw bytes. Character streams work with Unicode characters and handle encoding and decoding automatically. Use byte streams for binary data (images, audio, serialized objects). Use character streams for text.

What is the Decorator Pattern and how is it used in Java I/O? The decorator pattern wraps an object with another object to add behavior without changing the original class. In Java I/O, FileOutputStream is the core. BufferedOutputStream wraps it to add buffering. DataOutputStream wraps that to add primitive type support. You compose the exact behavior you need by stacking wrappers.

Why is buffering important in file I/O? Every call to FileInputStream.read() or FileOutputStream.write() triggers a system call, which requires switching from user space to kernel space and back. That switching is expensive. Buffering accumulates many bytes in memory and makes one system call for the whole batch, dramatically reducing overhead. The default buffer size is 8192 bytes.

What is the difference between FileReader and InputStreamReader? Both are Reader implementations for reading text. FileReader is a convenience class that uses the platform default encoding. InputStreamReader accepts an explicit encoding parameter. In practice you should prefer InputStreamReader with an explicit "UTF-8" argument for portability.

What is Serializable and what does transient mean?Serializable is a marker interface (no methods) that signals Java can convert instances of that class to bytes and back. Fields marked transient are excluded from serialization. Use transient for sensitive data (passwords, tokens) or data that can be recomputed (cached values) that should not be stored.

What does try with resources do differently from a try finally block? Try with resources guarantees that close() is called on all declared resources in reverse order when the block exits, whether normally or by exception. If both the body and close() throw exceptions, the body exception is the primary one and the close exception is added as a suppressed exception. With manual try finally, the close exception can swallow the original exception, making debugging very difficult.

Why does read() return an int instead of a byte? Because byte in Java is signed and ranges from -128 to 127. But byte data from files should be treated as unsigned values from 0 to 255. Returning int lets the method use -1 as an unambiguous end of file signal that cannot be confused with valid data.

What is the difference between BufferedOutputStream and DataOutputStream?BufferedOutputStream adds a memory buffer to reduce the number of system calls. It knows only about bytes. DataOutputStream adds knowledge of Java primitive types so you can write integers, doubles, longs, and booleans correctly. They solve different problems and can be used together by stacking DataOutputStream on top of BufferedOutputStream.

What happens to static fields during serialization? Static fields are not serialized. Serialization captures the state of a specific object instance. Static fields belong to the class itself, not to any instance, so they are not part of the serialized byte stream. When the object is deserialized, static fields retain whatever value they have in the running JVM.

Putting It All Together

Here is a complete example combining multiple concepts: writing a player object to a file with full decorator chain, and reading it back:

java
import java.io.*;

public class Player implements Serializable {
    private static final long serialVersionUID = 1L;
    private int id;
    private String name;
    private double score;
    private transient String sessionToken; // not saved to disk
    
    public Player(int id, String name, double score, String token) {
        this.id = id;
        this.name = name;
        this.score = score;
        this.sessionToken = token;
    }
    
    @Override
    public String toString() {
        return "Player{id=" + id + ", name=" + name + 
               ", score=" + score + ", token=" + sessionToken + "}";
    }
}

public class FileDemo {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Player original = new Player(1, "Alice", 9500.5, "secrettoken");
        System.out.println("Before: " + original);
        
        // Save to file using the full decorator chain
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new BufferedOutputStream(
                    new FileOutputStream("player.dat")))) {
            oos.writeObject(original);
        }
        
        // Load back from file
        Player loaded;
        try (ObjectInputStream ois = new ObjectInputStream(
                new BufferedInputStream(
                    new FileInputStream("player.dat")))) {
            loaded = (Player) ois.readObject();
        }
        
        // sessionToken is null because it was transient
        System.out.println("After: " + loaded);
        
        // Write a text file with encoding
        try (BufferedWriter bw = new BufferedWriter(
                new OutputStreamWriter(
                    new FileOutputStream("notes.txt"), "UTF-8"))) {
            bw.write("Player name: " + loaded.toString());
            bw.newLine();
        }
        
        // Read it back line by line
        try (BufferedReader br = new BufferedReader(
                new InputStreamReader(
                    new FileInputStream("notes.txt"), "UTF-8"))) {
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}

Why This Knowledge Still Matters

The traditional I/O API is not obsolete. You will see it in older codebases, in libraries that have not migrated to NIO, and in situations where its straightforward blocking model is exactly what you need. More importantly, understanding this API deeply gives you the foundation to understand NIO properly.

NIO was designed to solve the specific problems of the traditional approach: blocking reads and writes, no support for asynchronous I/O, and performance limits when handling many concurrent file or network operations. When you study NIO and see concepts like channels and byte buffers, they will make more sense because you will understand what problem they are solving over the traditional approach.

Master this decorator pattern. Understand which layer does what. Know your byte streams from your character streams. That knowledge carries forward into every I/O topic you encounter in Java.