Java BufferedWriter Data Loss — Flush and Close Pitfalls
An unflushed 8KB BufferedWriter buffer vanishes on JVM crash, losing critical logs.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- BufferedReader and BufferedWriter wrap Reader/Writer to buffer data in memory, reducing system calls from O(n) to O(1) per buffer fill.
- Default buffer size is 8,192 characters, configurable via constructor or Files.newBufferedReader() for UTF-8 safe handling.
- readLine() returns null at EOF, not empty string — a common source of infinite loops in production.
- For log processing, BufferedWriter with periodic flush() ensures monitoring tools see data without closing the stream.
- Performance gain: reading a 10,000-line file is often 10–20x faster than unbuffered reads.
- Biggest mistake: forgetting newLine() after write() — output becomes one continuous line cross-platform.
BufferedWriter is a Java I/O decorator that wraps a Writer to buffer output in memory before flushing to disk or network. It exists to solve a fundamental performance problem: unbuffered writes force a system call for every single character or small array, which can be 100-1000x slower than writing in large chunks.
By default, BufferedWriter uses an 8192-byte internal buffer (configurable via constructor), accumulating writes until the buffer is full or you explicitly call flush(). The tradeoff is that data sitting in that buffer is vulnerable — if your application crashes, or you forget to close the writer, that buffered data is silently lost.
This is not a bug; it's the contract of buffering. The flush() method forces the buffer contents to the underlying stream, and close() calls flush() internally before releasing resources. The pitfall arises when developers assume data is written immediately after calling write(), or when exceptions in a try block prevent close() from executing.
The classic fix is the try-with-resources pattern (Java 7+), which guarantees close() is called even on exceptions. For production systems, also consider that BufferedWriter is thread-safe only if the underlying writer is; for concurrent writes, use synchronization or higher-level abstractions like java.util.logging or Logback.
Alternatives include FileWriter directly (no buffering, terrible performance for small writes), PrintWriter (auto-flush on println, but swallows exceptions), or NIO Files.write() for one-shot writes. Use BufferedWriter when you need to write large text files line-by-line with reasonable performance; avoid it for real-time logging where data must be visible immediately — use a logger with immediate flush or an unbuffered writer.
Imagine you're moving books from one room to another. You could carry one book per trip — that works, but it's exhausting and slow. Or you could grab a box, fill it with 20 books, and make one efficient trip. BufferedReader and BufferedWriter are that box. Instead of reading or writing one character at a time to disk (slow, expensive), they collect a bunch of characters in memory first and do the work in bigger, faster chunks. That's literally it.
Every Java application that reads a config file, processes a CSV, writes a log, or handles any text-based I/O is touching the file system — and the file system is brutally slow compared to RAM. If your code reads characters one at a time from disk, you're making thousands of tiny expensive system calls instead of a few efficient ones. At small scale it doesn't matter. At production scale, it absolutely does. This is the gap between code that works and code that performs.
Why BufferedWriter Can Silently Drop Your Data
BufferedReader and BufferedWriter are I/O wrappers that reduce disk or network system calls by batching data into an internal buffer — typically 8 KB. Instead of writing each character individually (which triggers an expensive OS write), BufferedWriter accumulates data and flushes it in bulk. This turns O(n) system calls into O(n / bufferSize) calls, dramatically improving throughput for sequential reads and writes.
Critically, BufferedWriter does not guarantee data is written to disk when you call write(). Data sits in the buffer until it fills, or until flush() or close() is called. If your application crashes before flush(), buffered data is lost. Similarly, BufferedReader reads ahead into its buffer; if you close it prematurely, unread buffered data is discarded — but more dangerously, if you don't close the underlying stream, resources leak.
Use these wrappers whenever you perform bulk text I/O — reading large files line-by-line, writing logs, or processing streams. The performance gain is substantial: a 100 MB file written without buffering can take 10x longer. But never assume data is persisted until flush() or close() completes. In production, always pair BufferedWriter with explicit flush() in a finally block or use try-with-resources.
close() flushes the buffer, but if an exception occurs before close(), buffered data is silently lost. Always flush() in a finally block or use try-with-resources.flush() before a long-running batch job. A JVM crash during the batch lost the last 8 KB of transactions — about 200 records — with no error logged.flush() or close().Why Buffering Exists — The Cost of Unbuffered I/O
Java's base I/O classes like FileReader and FileWriter are perfectly functional — but they're unbuffered. Every call to read() or write() goes straight to the operating system, which means a context switch: your program pauses, the OS takes over, fetches the data, and hands control back. That round-trip costs time even when reading a single byte.
BufferedReader wraps around any Reader (like FileReader) and maintains an internal character array — a buffer — defaulting to 8,192 characters. It reads a big chunk from the underlying source all at once, stores it in that array, and then serves your read() calls from memory. Same principle applies to BufferedWriter: characters accumulate in the buffer and only flush to disk in large batches.
The real-world difference is dramatic. Reading a 10,000-line file with an unbuffered FileReader makes 10,000+ system calls. Wrapping it in a BufferedReader reduces that to a handful. For write-heavy operations like logging or generating reports, BufferedWriter can be the difference between a process that finishes in milliseconds versus seconds.
This is also why you'll see BufferedReader and BufferedWriter in virtually every production Java codebase that touches text files. It's not optional best practice — it's standard practice.
import java.io.BufferedReader; import java.io.FileReader; import java.io.FileWriter; import java.io.BufferedWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; public class BufferedVsUnbufferedDemo { // Write a temp file we can use for both read experiments private static Path createSampleFile() throws IOException { Path tempFile = Files.createTempFile("forge_demo", ".txt"); try (BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toFile()))) { for (int lineNumber = 1; lineNumber <= 5000; lineNumber++) { writer.write("Line " + lineNumber + ": The quick brown fox jumps over the lazy dog."); writer.newLine(); // OS-appropriate line separator — not hardcoded \n } } // BufferedWriter flushes and closes automatically here (try-with-resources) return tempFile; } public static void main(String[] args) throws IOException { Path sampleFile = createSampleFile(); // --- UNBUFFERED READ --- long startUnbuffered = System.currentTimeMillis(); int totalCharsUnbuffered = 0; try (FileReader rawReader = new FileReader(sampleFile.toFile())) { int character; while ((character = rawReader.read()) != -1) { // Each call hits the OS totalCharsUnbuffered++; } } long unbufferedTime = System.currentTimeMillis() - startUnbuffered; // --- BUFFERED READ --- long startBuffered = System.currentTimeMillis(); int totalCharsBuffered = 0; try (BufferedReader bufferedReader = new BufferedReader(new FileReader(sampleFile.toFile()))) { int character; while ((character = bufferedReader.read()) != -1) { // Served from in-memory buffer totalCharsBuffered++; } } long bufferedTime = System.currentTimeMillis() - startBuffered; System.out.println("=== I/O Performance Comparison ==="); System.out.println("Characters read (unbuffered): " + totalCharsUnbuffered); System.out.println("Unbuffered time: " + unbufferedTime + " ms"); System.out.println("Characters read (buffered): " + totalCharsBuffered); System.out.println("Buffered time: " + bufferedTime + " ms"); System.out.println("Speedup factor: ~" + (unbufferedTime > 0 ? unbufferedTime / Math.max(bufferedTime, 1) : "N/A") + "x"); Files.deleteIfExists(sampleFile); // Clean up after ourselves } }
Scanner vs BufferedReader: When to Use Which
Java provides two primary tools for reading text input: Scanner and BufferedReader. Both read characters from a source, but they serve different purposes and have distinct strengths. Choosing the wrong one can lead to performance problems or unnecessarily verbose code.
Scanner is designed for parsing — it can split input into tokens, match patterns with regular expressions, and convert tokens to primitive types (nextInt(), nextDouble(), nextBoolean()). It's ideal for interactive input (System.in), configuration files, or any scenario where you need to extract structured data from a stream. However, Scanner is not buffered by default in terms of large file reads — it uses a 1KB internal buffer — and it has a significant performance overhead due to parsing logic and the use of regular expressions. For line-by-line reading of large files, Scanner can be 2-5x slower than BufferedReader.
BufferedReader is built for pure reading — it provides readLine() and read(char[], int, int) methods that are highly efficient because they bypass parsing entirely. When you only need to read lines and process them yourself, BufferedReader is the faster choice. It also allows you to wrap any Reader, making it compatible with character-stream sources. Its buffer is much larger by default (8KB), leading to fewer system calls.
Here's a comparison table to help decide:
| Feature | Scanner | BufferedReader |
|---|---|---|
| Primary use | Parsing tokens and primitive types | Reading text efficiently (line-by-line) |
| Performance | Slower for large files due to parsing overhead | Faster; large default buffer (8KB) |
| Built-in parsing | Yes — nextInt(), nextDouble(), etc. | No — must parse manually (Integer.parseInt()) |
| Delimiter control | Customizable delimiter (default whitespace) | Fixed line-based (readLine()) |
| Error handling | InputMismatchException for type mismatch | No built-in parsing exceptions |
| Suitable for | User input, config files, small files | Large text files, logs, CSV processing |
| Thread safety | Not thread-safe | Not thread-safe |
| Buffer size | 1,024 characters (internal) | 8,192 characters (configurable) |
In practice, if you need to parse structured input (e.g., integers separated by spaces), use Scanner. If you need high-performance line-by-line reading with manual parsing (e.g., splitting a CSV), use BufferedReader. For most file-processing tasks in production, BufferedReader is the better choice because it gives you control over parsing and is significantly faster.
A common anti-pattern is using Scanner to read a 100MB log file line by line with nextLine(). While it works, it's 3-5x slower than BufferedReader.readLine() and consumes more memory due to Scanner's internal caching. Always benchmark for large files.
If you need both parsing and performance, wrap a BufferedReader in a Scanner: new Scanner(new BufferedReader(new FileReader(file))). This gives you the speed of buffered I/O with the convenience of Scanner's parsing methods.
import java.io.*; import java.nio.file.*; import java.util.Scanner; public class ScannerVsBufferedReaderBenchmark { public static void main(String[] args) throws IOException { Path tempFile = Files.createTempFile("bench", ".txt"); // Write 50,000 lines try (BufferedWriter w = Files.newBufferedWriter(tempFile)) { for (int i = 0; i < 50_000; i++) { w.write("Line number " + i); w.newLine(); } } // BufferedReader long startB = System.nanoTime(); try (BufferedReader reader = Files.newBufferedReader(tempFile)) { String line; while ((line = reader.readLine()) != null) { // Simulate basic processing if (line.startsWith("Line")) { } } } long bufferedTime = System.nanoTime() - startB; // Scanner with nextLine long startS = System.nanoTime(); try (Scanner scanner = new Scanner(tempFile.toFile(), "UTF-8")) { while (scanner.hasNextLine()) { String line = scanner.nextLine(); if (line.startsWith("Line")) { } } } long scannerTime = System.nanoTime() - startS; System.out.println("BufferedReader: " + bufferedTime / 1_000_000 + " ms"); System.out.println("Scanner: " + scannerTime / 1_000_000 + " ms"); System.out.println("Scanner is ~" + (scannerTime / Math.max(bufferedTime, 1)) + "x slower"); Files.deleteIfExists(tempFile); } }
Reading Text Files the Right Way — Line by Line with BufferedReader
The single most powerful feature of BufferedReader over raw FileReader is the readLine() method. It reads an entire line of text, strips the line terminator, and returns it as a String. When the file ends, it returns null — that's your loop exit signal.
This matters for a practical reason: most text-based data — logs, CSVs, config files, JSON-per-line formats — is structured around lines. readLine() matches how humans and programs actually think about that data.
The modern way to construct a BufferedReader for a file is through Files.newBufferedReader(path), introduced in Java 7 with NIO.2. It handles the charset correctly (defaulting to UTF-8), is more concise than chaining constructors, and integrates naturally with the Path API. For legacy code or when you genuinely need to wrap an existing stream, the constructor-chaining approach (new BufferedReader(new FileReader(file))) is still perfectly valid.
Always use try-with-resources. If you manually call close() and an exception fires before you reach it, the file handle leaks. On servers that process thousands of requests, leaked file handles accumulate into a dreaded 'Too many open files' OS error that brings the whole application down.
import java.io.BufferedReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; // Simulates processing a CSV file of employee records public class CsvFileProcessor { record Employee(String name, String department, int salary) {} public static List<Employee> loadEmployeesFromCsv(Path csvFilePath) throws IOException { List<Employee> employees = new ArrayList<>(); // Files.newBufferedReader uses UTF-8 by default and is the modern idiomatic approach try (BufferedReader reader = Files.newBufferedReader(csvFilePath)) { String headerLine = reader.readLine(); // Skip the header row if (headerLine == null) { System.out.println("Warning: CSV file is empty — " + csvFilePath); return employees; } String line; int lineNumber = 2; // Start at 2 since we already read line 1 while ((line = reader.readLine()) != null) { // null signals end-of-file line = line.strip(); // Remove any accidental leading/trailing whitespace if (line.isEmpty()) { lineNumber++; continue; // Skip blank lines gracefully } String[] fields = line.split(","); if (fields.length != 3) { System.out.printf("Skipping malformed line %d: %s%n", lineNumber, line); lineNumber++; continue; } try { String employeeName = fields[0].strip(); String department = fields[1].strip(); int salary = Integer.parseInt(fields[2].strip()); employees.add(new Employee(employeeName, department, salary)); } catch (NumberFormatException e) { System.out.printf("Invalid salary on line %d, skipping: %s%n", lineNumber, line); } lineNumber++; } } // Reader automatically closed here — even if an exception is thrown above return employees; } public static void main(String[] args) throws IOException { // Create a sample CSV file to process Path csvFile = Files.createTempFile("employees", ".csv"); Files.writeString(csvFile, "name,department,salary\n" + "Alice Nguyen,Engineering,95000\n" + "Bob Patel,Marketing,72000\n" + "Carol Smith,Engineering,102000\n" + "", // trailing newline — realistic scenario java.nio.charset.StandardCharsets.UTF_8 ); List<Employee> employees = loadEmployeesFromCsv(csvFile); System.out.println("=== Loaded Employees ==="); for (Employee emp : employees) { System.out.printf("%-15s | %-12s | $%,d%n", emp.name(), emp.department(), emp.salary()); } System.out.println("Total records: " + employees.size()); Files.deleteIfExists(csvFile); } }
Files.newBufferedReader() for UTF-8 by default; don't rely on platform charset.Reader/Writer Method Reference Table
Understanding the core methods of Reader, Writer, and their buffered counterparts is essential for using them correctly. Below is a reference table of the most important methods in the Reader and Writer hierarchy. Use this as a quick lookup when designing your I/O logic.
| Method | Class | Description | Returns | Common Pitfall |
|---|---|---|---|---|
| Reader | Reads a single character | int (0-65535) or -1 at EOF | Forgetting to cast to char; returning -1 on EOF |
read(char[] cbuf, int off, int len) | Reader | Reads characters into an array | int (number of chars read) or -1 | Not checking return value; assuming full buffer fill |
readLine() | BufferedReader | Reads a line of text (null at EOF) | String or null | Checking line.isEmpty() instead of line != null |
skip(long n) | Reader | Skips n characters | long (actual skipped) | Skipping more than available; not checking return |
| Reader/Writer | Closes the stream and releases resources | void | Not using try-with-resources |
write(int c) | Writer | Writes a single character | void | Writing an int without casting — produces garbage |
write(String str, int off, int len) | Writer | Writes a portion of a string | void | Off-by-one errors in len parameter |
write(char[] cbuf, int off, int len) | Writer | Writes a portion of a char array | void | ArrayIndexOutOfBounds if off+len > length |
newLine() | BufferedWriter | Writes platform-specific line separator | void | Hardcoding `. |
| BufferedWriter | Forces buffered data to be written | void | Not calling when real-time visibility needed |
append(CharSequence csq) | Writer | Appends a character sequence | Writer | Forgetting that it returns the writer for chaining |
These methods cover 90% of what you'll use in daily file I/O. Key notes:
returns an int, not a char. You must cast it to char if you need the character. The -1 return indicates end-of-stream.read()readLine()is exclusive to BufferedReader. It returns null at EOF — never an empty string.newLine()is better than hardcoding line separators because it ensures cross-platform compatibility.is critical when multiple processes or monitoring tools need to see data immediately.flush()
For bulk reading, prefer read(char[], off, len) over read() to reduce system calls. For writing, batch writes and call flush() sparingly to balance performance with visibility.
import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; public class MethodReferenceDemo { public static void main(String[] args) throws IOException { Path tempFile = Files.createTempFile("methods", ".txt"); // Demonstrate key methods try (BufferedWriter writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { // write(int) — single character writer.write('H'); writer.write('i'); writer.newLine(); // write(String) writer.write("Hello, Reader!"); writer.newLine(); // write(char[]) + flush char[] chars = { 'B', 'u', 'f', 'f', 'e', 'r' }; writer.write(chars, 0, chars.length); writer.newLine(); writer.flush(); // force to disk } // Read back with various methods try (BufferedReader reader = Files.newBufferedReader(tempFile, StandardCharsets.UTF_8)) { // read() single char int single = reader.read(); System.out.println("First char (int): " + single + " -> " + (char) single); // readLine() String line = reader.readLine(); System.out.println("First line: " + line); // read(char[], off, len) char[] buffer = new char[20]; int charsRead = reader.read(buffer, 0, buffer.length); System.out.print("Bulk read (" + charsRead + " chars): "); for (int i = 0; i < charsRead; i++) System.out.print(buffer[i]); System.out.println(); } Files.deleteIfExists(tempFile); } }
flush() for visibility.Writing Text Files Correctly — BufferedWriter in Practice
BufferedWriter's job is to collect your write() calls in memory and flush them to disk in one efficient batch. Its three most important methods are write(String text), newLine(), and flush().
newLine() is the one you shouldn't skip. Writing a hardcoded works on Linux and macOS, but Windows uses \r as its line terminator. newLine() uses System.lineSeparator() under the hood, making your output correct on every platform. If your application generates files that users open in Notepad, this matters.
flush() forces everything in the buffer out to disk right now, without closing the writer. You'll need this when writing to a file that another process is watching in real time — like a log file that a monitoring tool is tailing. Without flush(), data can sit silently in the buffer while the other process sees nothing.
close() both flushes the buffer and releases the file handle. With try-with-resources, close() is called automatically. But here's the subtlety: if you're writing a long-running process and want to ensure data is on disk periodically without closing the writer, you must call flush() manually at the right checkpoints.
import java.io.BufferedWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; // Simulates an application-level log writer that appends entries to a log file public class ApplicationLogWriter { private static final DateTimeFormatter LOG_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); enum LogLevel { INFO, WARN, ERROR } // Opens the writer in APPEND mode — existing content is preserved public static void writeLogEntries(Path logFilePath, String[] messages, LogLevel[] levels) throws IOException { // StandardOpenOption.APPEND means we add to the file rather than overwriting it // StandardOpenOption.CREATE means the file is created if it doesn't exist yet try (BufferedWriter logWriter = Files.newBufferedWriter( logFilePath, java.nio.charset.StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { for (int i = 0; i < messages.length; i++) { String timestamp = LocalDateTime.now().format(LOG_TIMESTAMP_FORMAT); LogLevel level = (i < levels.length) ? levels[i] : LogLevel.INFO; // Build a properly formatted log line String logEntry = String.format("[%s] [%-5s] %s", timestamp, level, messages[i]); logWriter.write(logEntry); // Write the log message logWriter.newLine(); // Add platform-correct line ending // For ERROR entries, flush immediately so monitoring tools see them instantly if (level == LogLevel.ERROR) { logWriter.flush(); // Force to disk right now — don't wait for buffer to fill System.out.println("ALERT: Error flushed immediately to log."); } } } // Final flush + close happens here automatically } public static void main(String[] args) throws IOException { Path logFile = Paths.get(System.getProperty("java.io.tmpdir"), "app_forge.log"); String[] logMessages = { "Application started successfully", "Processing batch job ID: 4821", "Database connection pool exhausted — retrying in 5s", "Batch job ID: 4821 completed. Records processed: 1,204" }; LogLevel[] logLevels = { LogLevel.INFO, LogLevel.INFO, LogLevel.ERROR, LogLevel.INFO }; writeLogEntries(logFile, logMessages, logLevels); // Read back what we wrote to confirm it looks right System.out.println("\n=== Log File Contents ==="); Files.lines(logFile).forEach(System.out::println); Files.deleteIfExists(logFile); } }
printf() and println() convenience. Just remember that PrintWriter silently swallows IOExceptions — check checkError() if reliability matters, or stick with BufferedWriter directly for error-critical writes.writer.write(line + "\n") instead of writer.newLine() — the log file looked fine on Linux but was unreadable on Windows.flush() on critical writes — a crash after write() but before flush() loses data.close() for final flush.Copying Files and Chaining Readers — A Complete Real-World Pattern
One of the most instructive exercises with BufferedReader and BufferedWriter is implementing a text file copy — it forces you to handle charsets, line endings, and proper resource management all at once.
But the real value here is understanding the decorator pattern these classes use. BufferedReader doesn't replace FileReader — it wraps it. This means you can buffer any Reader: an InputStreamReader decoding network data, a StringReader for testing, a PipedReader for thread communication. The buffering layer is completely agnostic about where the data comes from. Same for BufferedWriter. This composability is intentional Java I/O design.
The example below shows a file copy utility that also tracks statistics — a pattern you'd genuinely find in ETL pipelines, log rotation utilities, and build tools. It also shows a common real-world requirement: transforming content during the copy, in this case normalising inconsistent whitespace.
import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; // A real-world text file copy utility that normalises whitespace during transfer public class TextFileCopyUtility { record CopyResult(int linesCopied, int linesSkipped, long bytesWritten) {} /** * Copies a text file from source to destination, trimming trailing whitespace * from each line. Empty lines in the original are preserved as empty lines. * Returns a summary of what happened. */ public static CopyResult copyAndNormalise(Path sourcePath, Path destinationPath) throws IOException { int linesCopied = 0; int linesSkipped = 0; // Both reader and writer declared in the same try-with-resources block // Java guarantees both will be closed even if an exception occurs mid-copy try ( BufferedReader sourceReader = Files.newBufferedReader(sourcePath, StandardCharsets.UTF_8); BufferedWriter destinationWriter = Files.newBufferedWriter( destinationPath, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) // Overwrite if file already exists ) { String rawLine; while ((rawLine = sourceReader.readLine()) != null) { String normalisedLine = rawLine.stripTrailing(); // Remove trailing spaces/tabs if (normalisedLine.length() < rawLine.length()) { linesSkipped++; // Count lines that had trailing whitespace cleaned up } destinationWriter.write(normalisedLine); // Write normalised content destinationWriter.newLine(); // Always use platform line separator linesCopied++; } // destinationWriter.flush() is called by close() via try-with-resources // No need to call it manually here } long bytesWritten = Files.size(destinationPath); return new CopyResult(linesCopied, linesSkipped, bytesWritten); } public static void main(String[] args) throws IOException { // Build a source file with intentional trailing whitespace on some lines Path sourceFile = Files.createTempFile("source_", ".txt"); Files.writeString(sourceFile, "Product Report — Q4 2024 \n" + // trailing spaces "\n" + // blank line "Widget A: 1,240 units sold \t\n" + // trailing tab + spaces "Widget B: 980 units sold\n" + // clean line "Widget C: 3,100 units sold \n", // trailing spaces StandardCharsets.UTF_8 ); Path destinationFile = Files.createTempFile("normalised_", ".txt"); CopyResult result = copyAndNormalise(sourceFile, destinationFile); System.out.println("=== Copy Utility Results ==="); System.out.println("Lines copied: " + result.linesCopied()); System.out.println("Lines normalised: " + result.linesSkipped()); System.out.println("Bytes written to disk: " + result.bytesWritten()); System.out.println("\n=== Destination File Contents ==="); Files.lines(destinationFile, StandardCharsets.UTF_8) .forEach(line -> System.out.println("[" + line + "]")); Files.deleteIfExists(sourceFile); Files.deleteIfExists(destinationFile); } }
Character Encoding and Charset Handling with BufferedReader and BufferedWriter
One of the most overlooked aspects of buffered I/O is charset handling. Files.newBufferedReader(path) uses UTF-8 by default — a safe, modern choice. But new BufferedReader(new FileReader(file)) uses the platform's default charset, which can be Windows-1252 on one system and UTF-8 on another. This mismatch causes data corruption when files are moved between environments.
Use Files.newBufferedReader(path, charset) or Files.newBufferedWriter(path, charset) to be explicit. Specify StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1, or a Charset.forName() as needed. This is critical when your application processes files from multiple sources (e.g., legacy systems sending ISO-8859-1, modern APIs sending UTF-8).
Another pitfall: BufferedReader reads characters, not bytes. If you're working with binary data or need byte-level operations (e.g., reading image headers, custom protocols), you need InputStream + BufferedInputStream, not Reader. Mixing Reader/Writer with byte streams causes data loss or corruption.
The example below demonstrates reading a file with explicit charset and writing with a different one — a common data migration scenario.
import java.io.*; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.*; public class CharsetConversionUtility { public static void convertFileCharset(Path sourcePath, Charset sourceCharset, Path destPath, Charset destCharset) throws IOException { try ( BufferedReader reader = Files.newBufferedReader(sourcePath, sourceCharset); BufferedWriter writer = Files.newBufferedWriter(destPath, destCharset, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) ) { String line; while ((line = reader.readLine()) != null) { writer.write(line); writer.newLine(); } } } public static void main(String[] args) throws IOException { Path sourceFile = Files.createTempFile("source_", ".txt"); Files.writeString(sourceFile, "Olá, mundo! Müller Straße 123\n", StandardCharsets.ISO_8859_1); Path destFile = Files.createTempFile("dest_", ".txt"); // Convert from ISO-8859-1 to UTF-8 convertFileCharset(sourceFile, StandardCharsets.ISO_8859_1, destFile, StandardCharsets.UTF_8); System.out.println("Original file (ISO-8859-1) bytes: "); byte[] originalBytes = Files.readAllBytes(sourceFile); for (byte b : originalBytes) System.out.printf("%02x ", b); System.out.println(); System.out.println("Converted file (UTF-8) bytes: "); byte[] convertedBytes = Files.readAllBytes(destFile); for (byte b : convertedBytes) System.out.printf("%02x ", b); System.out.println(); // Verify content System.out.println("Original content: " + Files.readString(sourceFile, StandardCharsets.ISO_8859_1)); System.out.println("Converted content: " + Files.readString(destFile, StandardCharsets.UTF_8)); Files.deleteIfExists(sourceFile); Files.deleteIfExists(destFile); } }
Files.newBufferedReader() defaulted to UTF-8 on dev but Windows-1252 on a legacy server. 400 invoices had corrupted names.Flushing: The Silent Nightmare That Corrupts Logs
You've written your data. You called close(). Everything's fine, right? Wrong. If your application crashes between the last write() and the close(), whatever was sitting in that 8 KB buffer evaporates. No exception. No stack trace. Just missing data.
Buffered streams exist to batch writes, but that buffering is a liability if you don't control when the data actually hits disk. The method forces the buffer contents to the underlying stream immediately. Call it after every logical chunk of work — not just at the end.flush()
Every senior dev has debugged a partial log file or a corrupted config file because someone assumed would magically save them. It won't if the JVM dies first. Treat close() like a seatbelt: you don't skip it because the drive is short.flush()
// io.thecodeforge — java tutorial import java.io.*; public class FlushOnCrash { public static void main(String[] args) throws IOException { BufferedWriter writer = new BufferedWriter(new FileWriter("critical.log")); try { writer.write("Payment processed: $12,000"); writer.flush(); // Force to disk NOW // Simulated crash: if we skip flush, this data is lost if (true) throw new RuntimeException("System failure"); } finally { writer.close(); } } }
close() to flush. In long-running processes, flush() periodically or after each transaction. Your logs will thank you.close(). Assume your JVM can die at any moment.Buffer Size: Why 8192 Characters Isn't Magic
The default buffer size is 8192 characters. That's 16 KB in most encodings. It's a reasonable default for general text processing, but it's not optimal for everything. If you're writing large files sequentially, a bigger buffer reduces system calls. If you're writing tiny log entries, a smaller buffer might save memory.
You can pass a custom size to the constructor: new BufferedWriter(new FileWriter("data.txt"), 65536). That's 64 KB. For big sequential writes, you'll see noticeable throughput gains because you're hammering the OS less.
Don't blindly use 8192. Profile your workload. The best buffer size is the one that matches your I/O pattern. Rule of thumb: match the buffer to the block size of your filesystem (usually 4 KB) or the typical write chunk size.
One more thing: BufferedReader also accepts a buffer size. If you're reading large files line-by-line, a larger buffer reduces disk seeks. But don't go overboard — 8 MB buffers waste memory for no gain on modern SSDs.
// io.thecodeforge — java tutorial import java.io.*; public class CustomBufferSize { public static void main(String[] args) throws IOException { // Large buffer for big file writes try (BufferedWriter writer = new BufferedWriter( new FileWriter("bigdata.csv"), 65536)) { for (int i = 0; i < 1_000_000; i++) { writer.write("line," + i + "\n"); } } // Small buffer for bursty writes try (BufferedWriter writer = new BufferedWriter( new FileWriter("events.log"), 1024)) { writer.write("quick event"); writer.flush(); } } }
Big Picture: How BufferedReader and BufferedWriter Are Related
BufferedReader and BufferedWriter are sibling decorators in Java's I/O hierarchy, both extending the abstract Reader and Writer classes respectively. They share a common purpose: wrapping a raw, character-based stream to add an internal buffer that minimizes expensive I/O operations. BufferedReader reads chunks from an underlying reader into memory, letting you call readLine() without touching the disk each time. BufferedWriter collects written characters into a buffer before flushing them to the underlying writer in one batch. Together, they form a matched pair for efficient text processing — one handles input, the other output. In practice, you often chain them end-to-end: read from a BufferedReader, process the data, then write through a BufferedWriter. This symmetry reduces system calls on both sides of a data pipeline. Understanding this relationship helps you design I/O code that performs predictably, because the buffering logic on each side follows the same principle: trade memory for speed, but flush carefully to avoid data loss.
// io.thecodeforge — java tutorial import java.io.*; public class FileCopyBuffered { public static void main(String[] args) throws IOException { try (BufferedReader reader = new BufferedReader(new FileReader("input.txt")); BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))) { String line; while ((line = reader.readLine()) != null) { writer.write(line); writer.newLine(); } } // both flushed and closed automatically } }
Big Picture: The Decorator Pattern in Practice
Java's Reader and Writer classes are built around the Decorator pattern, where you wrap one stream inside another to add functionality. BufferedReader and BufferedWriter are the most common decorators for text I/O. You never use them alone; they always wrap an underlying reader or writer like FileReader, InputStreamReader, or FileWriter. This layering is why you see code like new BufferedReader(new FileReader("file.txt")) — the FileReader handles the file, and BufferedReader adds buffering on top. The same applies to writing. This design lets you mix and match: you can buffer a network stream, a file, or even a string reader. The key insight is that the buffer layer is transparent — your code reads and writes normally, but performance changes dramatically. Understanding this pattern prevents you from needlessly buffering an already-buffered stream (like wrapping a ByteArrayInputStream in BufferedReader, which wastes memory). Always ask: what is the underlying resource? Then add exactly one buffering layer for that resource.
// io.thecodeforge — java tutorial import java.io.*; public class BufferedDecorator { public static void main(String[] args) throws IOException { // FileReader: raw file access // BufferedReader: adds buffering decorator Reader raw = new FileReader("data.txt"); Reader buffered = new BufferedReader(raw); int ch; while ((ch = buffered.read()) != -1) { System.out.print((char) ch); } buffered.close(); // closes raw too } }
buffered.read() fetches a chunk from raw and caches it, so each System.out.print hits memory, not disk. Without the decorator, every call to raw.read() would be a disk read.The Silent Data Loss in Buffered Logging
close(). The buffered data never flushed to disk. The last ~7KB of log data sat in the 8KB buffer and evaporated on JVM crash.flush() on critical writers as a safety net.- try-with-resources is non-negotiable — it guarantees
close()releases the file handle and flushes the buffer even on exceptions. - For high-importance writes (error logs, transaction records), call
flush()after every write and consider using an explicit ShutdownHook. - Never assume a crash will flush buffers. The OS closes file handles, but the Java buffer is in user space — gone when the process dies.
flush() was called before the program exited. Use strace -e trace=write -p <pid> to see if data is being sent to the OS. Add a ShutdownHook to flush critical writers.close(). If the process was killed with SIGKILL (kill -9), even the OS buffer can be lost — use synchronous writes.flush() for real-time monitoring. Use lsof -p <pid> to check if the file descriptor is still open. Check buffer size; smaller buffers flush more often but increase system calls.while ((line = reader.readLine()) != null). If you mistakenly check line.isEmpty(), you'll get an infinite loop at EOF. Use jstack <pid> to see stuck threads.strace -e trace=write -p <pid> 2>&1 | head -20lsof -p <pid> | grep <logfile>writer.flush() after critical writes. Wrap in try-with-resources.jstack <pid> | grep -A 10 'BLOCKED'strace -e trace=read -p <pid> 2>&1 | tail -20= reader.readLine() assignment. Handle blank lines with if (line.isEmpty()) continue;.od -c <file> | head -10file <file>writer.newLine() instead of hardcoding \n or \r\n.| Feature / Aspect | FileReader / FileWriter (Unbuffered) | BufferedReader / BufferedWriter (Buffered) |
|---|---|---|
| System calls per 10,000 chars | ~10,000 individual calls | ~2-3 calls (buffer fills, then flushes) |
| readLine() method | Not available | Available — returns full line as String |
| newLine() method | Not available | Available — uses platform-correct line ending |
| Manual flush control | Not needed — writes immediately | flush() lets you push buffer to disk on demand |
| Typical use case | Very small files, quick prototyping | Any production code reading or writing text files |
| Constructor approach | new FileReader(file) | new BufferedReader(new FileReader(file)) or Files.newBufferedReader(path) |
| Default buffer size | No buffer | 8,192 characters (configurable) |
| Performance on large files | Significantly slower | Dramatically faster — often 10-20x |
| Charset handling | Platform default charset (risky) | Files.newBufferedReader() defaults to UTF-8 (safe) |
| Error on close missed | File handle leak | File handle leak — always use try-with-resources |
Key takeaways
System.lineSeparator() and keeps your output correct across Windows, Linux, and macOS.Common mistakes to avoid
4 patternsForgetting to call newLine() between writes
Not using try-with-resources and losing buffered data
close() was called, so the buffer never flushed to disk. This is insidious because it only fails under error conditions or on JVM exit.close() guarantees a final flush before the file handle is released.Assuming readLine() returns an empty string at end-of-file
Relying on platform default charset with FileReader/FileWriter
Interview Questions on This Topic
Why would you use BufferedReader instead of FileReader directly, and what exactly happens internally that makes it faster?
read() — each call involves a context switch from user space to kernel space, which is expensive. BufferedReader wraps FileReader and reads a large block (default 8,192 characters) into a memory buffer in a single system call. Subsequent read() calls are served from that buffer without touching the OS. The buffer is refilled only when exhausted. This reduces system calls from O(n) to O(n/bufferSize), typically yielding 10-20x speedup. Additionally, BufferedReader adds the readLine() method which is not available in FileReader.What's the difference between flush() and close() on a BufferedWriter, and can you describe a production scenario where you'd call flush() without closing the writer?
close() first flushes the buffer, then releases the file handle. In production, you'd call flush() without close() in a long-running log writer where you want real-time visibility for monitoring tools (e.g., tail or Splunk). By flushing error-level log entries immediately, the monitoring system sees them without waiting for the buffer to fill. You still need to close the writer at shutdown to release resources.If you wrap a BufferedReader in another BufferedReader — new BufferedReader(new BufferedReader(new FileReader(file))) — what happens? Is it harmful, helpful, or just wasteful?
read() call per buffer fill, but the real I/O cost is already paid by the inner buffer. It wastes heap memory (two 8KB buffers) and adds a minor CPU overhead for the extra indirection. The correct answer is: it's unnecessary and should not be done. This tests understanding that the decorator chain only helps when each layer adds unique functionality (e.g., buffering + line reading + character encoding).Frequently Asked Questions
FileReader reads characters directly from a file one at a time, making a system call for every read — which is slow. BufferedReader wraps FileReader and reads a large chunk (8,192 chars by default) into memory at once, then serves individual read() or readLine() calls from that in-memory buffer. BufferedReader also adds the readLine() method, which FileReader doesn't have. In practice, always wrap FileReader in a BufferedReader when reading text files.
Not reliably. The buffer is flushed when close() is called, which happens automatically if you use try-with-resources. If your program crashes, is killed by the OS, or exits abnormally before close() is called, data still sitting in the buffer will be lost and never written to disk. This is why try-with-resources is non-negotiable — it guarantees close() (and therefore the final flush) runs even when exceptions are thrown.
No — BufferedReader is not thread-safe. If multiple threads call readLine() concurrently on the same instance, you'll get garbled data, missed lines, or exceptions, because the internal buffer state isn't protected by synchronisation. For multi-threaded file processing, give each thread its own BufferedReader, or use a single reader on one thread that distributes lines to a work queue that other threads consume.
The default 8,192 characters is good for most use cases. Larger buffers (e.g., 64KB or 128KB) can improve throughput for very large files by reducing the number of refill operations, but consume more heap memory. Smaller buffers (1,024) reduce memory footprint but increase system calls. Benchmark with realistic data: if you're reading a 1MB file, the default is fine. For multi-GB files, consider 65536 (64KB) and test memory vs. speed trade-off. Never set it below 1024 — you lose buffering benefits.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Java I/O. Mark it forged?
13 min read · try the examples if you haven't