Character Streams

Character streams read and write text as Unicode characters instead of raw bytes, handling encoding/decoding so your code never has to think in bytes for text.

Java's I/O library is split into two parallel hierarchies: byte streams (InputStream/OutputStream) for raw binary data, and character streams (Reader/Writer) for text. Character streams exist because text is not just bytes — a byte sequence must be interpreted through a character encoding (UTF-8, UTF-16, ISO-8859-1, etc.) to become meaningful characters, and Reader/Writer handle that conversion for you so application code can work in terms of char and String rather than raw byte values.

Why Character Streams Exist Separately From Byte Streams

A file on disk is just a sequence of bytes. Whether those bytes represent text and, if so, which characters they represent depends entirely on an agreed-upon encoding. Reading text with a byte stream and manually decoding it is error-prone and repetitive, so Java provides a dedicated Reader/Writer hierarchy that performs byte-to-character (and character-to-byte) conversion internally, using a specified or platform-default charset.

Rule of thumb: use InputStream/OutputStream for binary data (images, serialized objects, network protocols); use Reader/Writer for text (source files, config files, human-readable logs).

The Reader and Writer Base Classes

Reader is the abstract base class for reading streams of characters; Writer is the abstract base class for writing them. Both are abstract and define the core operations that concrete subclasses implement.

read() returns an int (not char) specifically so -1, which is not a valid char value, can unambiguously signal end-of-stream.

Both classes implement Closeable (Reader also implements AutoCloseable transitively), so they are ideal candidates for try-with-resources, which guarantees close() runs even if an exception occurs mid-read.

// Core Reader methods (abstract class java.io.Reader)
int read() throws IOException;                 // reads one char, or -1 at end of stream
int read(char[] cbuf) throws IOException;       // fills array, returns count read (or -1)
int read(char[] cbuf, int off, int len) throws IOException;
void close() throws IOException;

// Core Writer methods (abstract class java.io.Writer)
void write(int c) throws IOException;           // writes one char
void write(char[] cbuf) throws IOException;
void write(String str) throws IOException;
void flush() throws IOException;
void close() throws IOException;

FileReader and FileWriter

FileReader and FileWriter are the character-stream counterparts to FileInputStream/FileOutputStream, reading from and writing to files as text. Historically, their no-charset constructors used the JVM's platform default charset, which made programs behave differently across operating systems — a common source of bugs. Since Java 11, FileReader and FileWriter gained constructors that accept an explicit Charset, which should always be preferred.

Reading and writing one character at a time is simple but slow; in practice FileReader/FileWriter are almost always wrapped in a BufferedReader/BufferedWriter (see the Buffered I/O topic) for efficiency.

Prefer java.nio.file.Files.newBufferedReader(path, charset) / newBufferedWriter(path, charset) in new code — they require an explicit charset and return an already-buffered stream in one call.

import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FileCopyText {
    public static void main(String[] args) throws IOException {
        try (FileReader reader = new FileReader("input.txt", StandardCharsets.UTF_8);
             FileWriter writer = new FileWriter("output.txt", StandardCharsets.UTF_8)) {
            int ch;
            while ((ch = reader.read()) != -1) {
                writer.write(ch);
            }
        }
    }
}

Character Encoding Fundamentals

A character encoding maps characters to byte sequences and back. UTF-8 is the dominant encoding for interchange: it represents ASCII characters as a single byte (backward-compatible with 7-bit ASCII) and other Unicode code points as 2-4 bytes. Java's internal char/String representation uses UTF-16 code units, which is an implementation detail of how Java stores characters in memory — it is unrelated to what encoding you choose when reading or writing external text.

Mismatched encodings — writing a file as UTF-8 but reading it back as ISO-8859-1, for example — silently produce garbled text ("mojibake") rather than an exception, because most encodings can decode any byte sequence into something, just not the right something. This makes encoding bugs easy to introduce and hard to notice until non-ASCII characters appear.

InputStreamReader and OutputStreamWriter are the general-purpose adapters that convert any byte stream into a character stream (or vice versa) given an explicit Charset — FileReader/FileWriter are effectively convenience wrappers around exactly this pattern for files.

import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.io.FileInputStream;
import java.io.FileOutputStream;

// InputStreamReader/OutputStreamWriter are the bridge between byte streams and char streams
try (InputStreamReader in = new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8);
     OutputStreamWriter out = new OutputStreamWriter(new FileOutputStream("copy.txt"), StandardCharsets.UTF_8)) {
    // in and out now expose character-oriented read()/write() over byte streams
}

Reading and Writing Strings and Arrays

Beyond single characters, Reader and Writer support bulk operations on char[] arrays, and Writer additionally overloads write() and provides append() for String and CharSequence, matching the ergonomics developers expect from text APIs.

StringReader/StringWriter are in-memory character streams useful for testing code written against Reader/Writer without touching the filesystem.

import java.io.StringReader;
import java.io.StringWriter;
import java.io.IOException;

public class StringStreamsDemo {
    public static void main(String[] args) throws IOException {
        StringReader reader = new StringReader("Hello, World!");
        char[] buffer = new char[5];
        int n = reader.read(buffer, 0, buffer.length);
        System.out.println(n + " chars: " + new String(buffer, 0, n)); // 5 chars: Hello

        StringWriter writer = new StringWriter();
        writer.write("Result: ");
        writer.append("done").append('!');
        System.out.println(writer.toString()); // Result: done!
    }
}

Buffered Text: Why Wrapping Matters

A raw FileReader performs one native read call (or close to it) per character, which is extremely slow for anything beyond trivial files. Wrapping it in a BufferedReader reads a large chunk into an internal buffer and serves subsequent read() calls from memory, dramatically reducing I/O overhead, and adds convenience methods like readLine(). This pattern — wrap a low-level stream in a buffering decorator — is the same idea covered in depth in the Buffered I/O topic.

readLine() returns the line without the terminating newline, and returns null (not an empty string) at end of stream — a frequent source of off-by-one loop bugs if confused with read() returning -1.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

try (BufferedReader br = new BufferedReader(new FileReader("log.txt", StandardCharsets.UTF_8))) {
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
}

PrintWriter for Formatted Text Output

PrintWriter wraps a Writer (or an OutputStream) and adds print/println/printf-style methods, similar to System.out. Unlike most Writer subclasses, PrintWriter never throws IOException from its write methods — it swallows I/O errors internally, and callers must check checkError() if they need to detect a failure. This trade-off favors ergonomic, exception-free printing over strict error propagation.

import java.io.PrintWriter;
import java.io.FileWriter;
import java.io.IOException;

try (PrintWriter pw = new PrintWriter(new FileWriter("report.txt"))) {
    pw.println("Report generated.");
    pw.printf("Total: %.2f%n", 123.456);
}

Practical Usage Patterns

In modern Java, the recommended day-to-day approach for whole-file text I/O is often java.nio.file.Files: Files.readString(path) and Files.writeString(path, text) for small files read/written in one call (UTF-8 by default), or Files.newBufferedReader/newBufferedWriter when you need streaming line-by-line access. Character streams (Reader/Writer) remain the underlying abstraction these are built on, and are still the right tool when composing with other I/O APIs (e.g. parsing an HTTP response body, or classes that only accept a Reader).

import java.nio.file.Files;
import java.nio.file.Path;
import java.io.IOException;

public class ModernTextIO {
    public static void main(String[] args) throws IOException {
        Path path = Path.of("notes.txt");
        Files.writeString(path, "First line\nSecond line\n");
        String content = Files.readString(path);
        System.out.println(content);
    }
}

Edge Cases and Resource Management

read() on a Reader returns an int; comparing it directly to a char literal like 'a' without care can hide the -1 end-of-stream sentinel because -1 auto-widens fine in an int comparison, but forgetting the loop must check for -1 (not for some char value) is a classic bug. Always close Readers and Writers — they typically wrap OS file handles or sockets that are a limited resource the garbage collector does not promptly reclaim; try-with-resources is the idiomatic guarantee.

Writer output is often buffered internally by the OS or by a BufferedWriter layer; call flush() (or rely on close(), which flushes) before assuming data has actually reached the underlying file or socket.

Mental Model

Think of a character stream as a byte stream plus a translator sitting in front of it: bytes go in or out at the bottom, and characters (decoded or encoded via a specific Charset) are what your code sees at the top. FileReader/FileWriter are just InputStreamReader/OutputStreamWriter pre-wired to a FileInputStream/FileOutputStream for convenience.

Quick Reference

Reader (abstract)          Writer (abstract)
  FileReader                 FileWriter
  InputStreamReader          OutputStreamWriter
  StringReader                StringWriter
  BufferedReader (decorator)  BufferedWriter (decorator)
  CharArrayReader                          PrintWriter (decorator)

Key methods: read()/read(char[]) -> -1 at EOF | write(...) | flush() | close()

Key Takeaways

Character streams (Reader/Writer) handle text and perform encoding/decoding; byte streams (InputStream/OutputStream) handle raw binary data.

Always specify an explicit Charset (e.g. StandardCharsets.UTF_8) rather than relying on the platform default, which varies across machines.

InputStreamReader/OutputStreamWriter are the general adapters between byte streams and character streams; FileReader/FileWriter are file-specific convenience wrappers around that pattern.

Raw character streams should almost always be wrapped in Buffered* decorators for performance in real usage.

read() returns int so -1 can unambiguously represent end-of-stream, distinct from any valid char value.

Always close Reader/Writer resources, ideally via try-with-resources, since they hold OS-level handles.

FileReader r = new FileReader("data.txt"); // platform default charset

FileReader r = new FileReader("data.txt", StandardCharsets.UTF_8);

Relying on the platform default charset makes text I/O behave differently across operating systems; always specify the charset explicitly.

int c = reader.read(); if (c == -1) { /*... */ } char ch = (char) c; // used even when c == -1 int c = reader.read(); while (c != -1) { char ch = (char) c; c = reader.read(); }

Casting the -1 EOF sentinel to char before checking for it produces a garbage character (0xFFFF) instead of correctly terminating the loop.

FileWriter w = new FileWriter("out.txt"); w.write("data"); // forgot close()/flush() try (FileWriter w = new FileWriter("out.txt", StandardCharsets.UTF_8)) { w.write("data"); }

Unflushed/unclosed writers can leave buffered data never written to the file; try-with-resources guarantees flush-and-close even on exceptions.

new BufferedReader(new FileReader(path)).readLine(); // reads one line then leaks the stream try (BufferedReader br = new BufferedReader(new FileReader(path, StandardCharsets.UTF_8))) { String line = br.readLine(); }

Creating a stream without keeping a reference to close it (or without try-with-resources) leaks the underlying file handle.

Why does Reader.read() return an int instead of a char?

So it can return -1 to signal end-of-stream — a value no valid char could represent, since char is unsigned 16-bit.

What is the difference between a byte stream and a character stream in Java?

Byte streams (InputStream/OutputStream) transfer raw bytes with no notion of text; character streams (Reader/Writer) transfer decoded/encoded text, translating bytes through a Charset.

What do InputStreamReader and OutputStreamWriter do?

They are the bridge adapters that convert a byte stream into a character stream (or vice versa) using a specified Charset; FileReader/FileWriter are convenience wrappers built on this same bridge for files.

Why should you avoid FileReader's no-charset constructor?

It uses the JVM's platform default charset, which differs across operating systems and configurations, causing the same code to decode text differently on different machines.

What does BufferedReader.readLine() return at end of stream, and why does that matter?

It returns null, not an empty string or -1; loops must check while ((line = br.readLine()) != null) or they will misbehave.

Why should Readers and Writers be closed even though Java has garbage collection?

They typically wrap OS-level resources (file descriptors, sockets) that the GC does not promptly or deterministically reclaim; leaving them open can exhaust OS resource limits.

What happens if you write UTF-8 text but read it back with ISO-8859-1?

Multi-byte UTF-8 sequences are misinterpreted as separate single-byte characters, producing garbled text (mojibake) rather than a thrown exception.

Write a program that reads a text file line by line using BufferedReader with an explicit UTF-8 charset and prints each line prefixed with its line number.

import java.io.BufferedReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.charset.StandardCharsets; public class NumberLines { public static void main(String[] args) throws IOException { Path path = Path.of("poem.txt"); try (BufferedReader br = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; int num = 1; while ((line = br.readLine()) != null) { System.out.println(num++ + ": " + line); } } } }

Files.newBufferedReader gives an already-buffered Reader with an explicit charset; readLine() is looped until it returns null, incrementing a counter for each line printed.

What does this print?

65, 66, -1 — read() returns the int code point of each character ('A' is 65, 'B' is 66), then -1 once the stream is exhausted.

Write a program using try-with-resources that copies a text file to another file, converting from ISO-8859-1 to UTF-8.

import java.io.*; import java.nio.charset.StandardCharsets; public class ConvertEncoding { public static void main(String[] args) throws IOException { try (Reader in = new InputStreamReader(new FileInputStream("legacy.txt"), StandardCharsets.ISO_8859_1); Writer out = new OutputStreamWriter(new FileOutputStream("modern.txt"), StandardCharsets.UTF_8)) { char[] buf = new char[4096]; int n; while ((n = in.read(buf)) != -1) { out.write(buf, 0, n); } } } }

InputStreamReader decodes the source bytes as ISO-8859-1 into chars, and OutputStreamWriter encodes those chars back out as UTF-8 — the conversion happens because the two streams use different charsets on read vs write.

StringReader r = new StringReader("AB");
System.out.println(r.read());
System.out.println(r.read());
System.out.println(r.read());