BufferedReader and BufferedWriter are wrappers from java.io that sit on top of an existing reader or writer and add an in-memory buffer. The buffer turns many tiny reads or writes into a small number of larger ones, which is the single biggest performance win in classic Java file I/O. This lesson covers the decorator pattern that makes wrapping work, why unbuffered I/O is slow, the BufferedReader and BufferedWriter APIs, the canonical line-by-line reading pattern, the lines() stream, and the pitfalls that bite beginners.
A FileReader reads characters from disk. Each read() call asks the operating system for data. That request is a system call: the program leaves user space, the kernel handles the request, and control returns. A single system call is fast in absolute terms, but it's hundreds to thousands of times slower than a normal method call. Reading a 1 MB file one character at a time means roughly a million system calls. That's the real cost.
A buffer fixes this. Instead of asking the OS for one character per read(), the wrapper asks for, say, 8192 characters in one shot and stores them in a char[]. Subsequent read() calls hand back characters from that array, no system call needed. When the array is empty, the wrapper goes back to the OS for another 8192. The number of system calls drops by a factor of thousands for the same workload.
The caller still makes a million read() calls, but only about 120 of them reach the OS. The rest are served from the in-memory buffer. That's the whole idea.
Unbuffered single-character reads cost roughly one system call each. For a 1 MB text file, that's about a million context switches versus about 120 with an 8 KB buffer. The wrapper has low cost; the absence of it is what's expensive.
The same logic applies to writing. FileWriter.write(char) without a buffer can mean a system call per character. BufferedWriter accumulates characters in a char[] and flushes them to the underlying writer in chunks. Writing a 1 MB log goes from a million OS calls to about a hundred.
BufferedReader doesn't read from disk on its own. It wraps another Reader and delegates the actual I/O to it. This is the decorator pattern: a class that takes another instance of the same type in its constructor and adds behavior on top. Java's java.io package is built around this pattern.
The constructor signature makes it explicit:
You give it a Reader. It gives you a Reader that happens to be buffered. The wrapped reader does the real work; the wrapper adds the buffer and a few useful methods like readLine().
Read it left to right. Bytes leave the file, the FileReader decodes them into characters, the BufferedReader buffers those characters and exposes readLine(), and your code reads from the buffered wrapper. Each layer has one job.
The minimal correct construction looks like this:
Output (given `orders.csv` starting with `O`):
Closing br also closes fr. The wrapper owns the underlying reader for cleanup purposes, which matters when you switch to try-with-resources in a moment.
You'll almost never see the two-line construction in real code. The idiomatic form chains the constructors:
Same thing, one line. The FileReader is constructed first, then passed straight into the BufferedReader constructor. There's no need to keep a separate reference to the FileReader; the wrapper holds it.
The same pattern applies on the write side:
A FileWriter that writes characters to disk, wrapped in a BufferedWriter that adds a buffer. Whoever holds the BufferedWriter doesn't need to know about the FileWriter underneath.
BufferedReader extends Reader and adds two things that matter: a buffer (for speed) and readLine() (for convenience). The full constructor list is short.
| Constructor | What it does |
|---|---|
BufferedReader(Reader in) | Wraps in with the default buffer size of 8192 characters. |
BufferedReader(Reader in, int sz) | Wraps in with a buffer of sz characters. |
The default buffer size is 8192 characters, defined in the JDK source as defaultCharBufferSize. For most files that's a reasonable default. Tune it up only if you have measured evidence that bigger reads help on your specific workload, and tune it down only if memory is tight (rare for an 8 KB allocation).
The core read methods:
| Method | Returns | Behaviour |
|---|---|---|
int read() | int | Reads one character, returns its code point as an int, or -1 at end of stream. |
int read(char[] cbuf) | int | Reads up to cbuf.length characters into cbuf, returns the count read, or -1 at EOF. |
int read(char[] cbuf, int off, int len) | int | Reads up to len characters starting at offset off. |
String readLine() | String | Reads a line of text, or returns null at end of stream. |
Stream<String> lines() | Stream<String> | Returns the remaining lines as a Stream<String>. Added in Java 8. |
boolean ready() | boolean | Returns true if the next read() is guaranteed not to block. |
void close() | void | Closes the buffer and the wrapped reader. |
A small program that touches the basics, reading the first two characters and then the rest of the first line:
Output (given `orders.csv` whose first line is `Order-1001,Notebook,4.99`):
read() advances the position by one character at a time, and readLine() picks up from wherever the cursor sits, reading until a line terminator or end of file. The line terminator itself (whether \n, \r, or \r\n) is consumed but not included in the returned String. Three different line endings, one consistent behaviour, which is one benefit of using readLine() over rolling your own.
readLine() returns null at end of stream. That null is the signal you've run out of data. It's not an error and it's not an empty string. An empty line in the file shows up as "" (a zero-length String); null only appears once, at the very end. That distinction is the entire reason the canonical loop is shaped the way it is.
read() and readLine() on a BufferedReader are normal method calls that hit the buffer. They only become system calls when the buffer is empty and needs refilling. Don't conflate "reading a million characters" with "making a million OS calls"; with a buffer in the picture, those are different numbers.
The optional int sz constructor lets you pick a custom buffer size. The signature accepts a positive int. There's no upper limit beyond available memory, but the returns flatten out quickly past 16 KB.
That's it for the API surface. The rest of the value of BufferedReader comes from how you compose readLine() into a loop.
This is the most common use of BufferedReader. Almost every Java file-reading task uses this loop shape.
Output (given a file with 1,243 order lines):
There are three things going on in that loop header:
br.readLine() reads the next line and returns it.line.null. If it's null, the loop exits.The parentheses around (line = br.readLine()) are mandatory. Java evaluates the assignment, the whole assignment expression has the value of the right-hand side, and that value is what != null compares against. Without the parens, the precedence is wrong and the code won't compile.
The state machine has four states. CALL invokes readLine() and stores its result. CHECK compares against null. If the line is real, control moves to BODY where you process it, then back to CALL for the next iteration. If the line is null, control moves to DONE and the loop exits.
Why not a for loop or forEach? The while shape exists because there's no clear "next" without actually trying to read. You don't know the file has more lines until you ask for one and either get a String or null. A for loop with a counter would force you to know the line count up front, which defeats the point of streaming through the file.
A more realistic version counts how many orders mention a specific product:
Output (given an `orders.csv` with 312 lines containing "Notebook"):
This version processes a file of arbitrary size without ever holding the whole file in memory. At any given moment, only one line lives in String line, plus the 8 KB buffer. A 2 GB log file works the same as a 200-line one, just slower.
BufferedWriter is the mirror image of BufferedReader. It extends Writer, wraps another Writer, and buffers writes. The constructors follow the same pattern:
| Constructor | What it does |
|---|---|
BufferedWriter(Writer out) | Wraps out with the default 8192-character buffer. |
BufferedWriter(Writer out, int sz) | Wraps out with a buffer of sz characters. |
The core methods you'll use:
| Method | Behaviour |
|---|---|
void write(int c) | Writes a single character. |
void write(char[] cbuf, int off, int len) | Writes part of a char[]. |
void write(String s) | Writes a whole String. |
void write(String s, int off, int len) | Writes part of a String. |
void newLine() | Writes the platform's line separator. |
void flush() | Forces the buffer to the underlying writer. |
void close() | Flushes and closes (including the wrapped writer). |
A short example that writes a wishlist, one item per line, using newLine() for the line breaks:
`wishlist.txt` contents:
Two things. newLine() writes the platform's line separator, which is \r\n on Windows and \n on Linux and macOS. If you hard-code bw.write("\n") instead, the file will work fine on Unix systems but may look wrong in some Windows tools. newLine() is the portable choice for files that stay on the same machine. For interoperable formats (CSV, JSON, anything moved across systems), pick \n explicitly and use bw.write("\n"), because mixed line endings are their own form of bug.
Second, nothing reaches disk until the buffer fills or you flush or close. If your program crashes mid-write, the last partial buffer is lost. That's the price of buffered writing, and it's usually fine because close() flushes for you. try-with-resources makes this automatic.
flush() exists for cases where you want intermediate guarantees. If a long-running program writes log lines and you want them visible to tail -f immediately, call bw.flush() after each line. Without a flush, the buffer holds onto those lines until it fills or the program ends.
A buffered writer is fast because it batches small writes into one OS call. The trade-off is that data is in memory until flushed. If you need durability after every line (an audit log, say), call flush() per line or use an unbuffered PrintWriter with auto-flush. For most files, accept the trade and rely on close() to flush at the end.
A larger example that combines several writes per line, useful for CSV output:
`orders.csv` contents:
Each write call hits the buffer, not the disk. With 4 lines that's negligible, but with 4 million lines the buffered version finishes in seconds while an unbuffered version takes much longer.
Java 8 added BufferedReader.lines(), which returns the remaining lines as a Stream<String>. The result is lazy: lines are read from the file as the stream is consumed, not all at once. That keeps memory usage flat even on large files, while letting you use the stream API for filtering, mapping, and counting.
Output (given an `orders.csv` with 312 matching lines):
The pipeline reads from the buffered reader on demand. The terminal operation count() pulls lines through the filter one at a time, never building an intermediate list. That's the same streaming behaviour you'd get from a while loop, expressed as a pipeline.
One point about resource management. The Stream<String> returned by lines() is backed by the BufferedReader. If the reader is closed while the stream is being consumed, you'll get an exception. The clean pattern is to do all the work inside the try-with-resources block, as the example above does. Once the block ends, the reader closes and any further use of the stream would fail.
The file feeds the buffered reader, the buffered reader feeds the stream, and the stream feeds the pipeline. Each level pulls only what the next level asks for. A 50 GB log with three matching lines doesn't load 50 GB into memory; it streams through, character by character, line by line.
For very large inputs where you want a stream but don't need the buffered reader for anything else, Files.lines(Path) is shorter. The factory methods Files.newBufferedReader(path) and Files.newBufferedWriter(path) build a BufferedReader or BufferedWriter with sensible defaults (UTF-8 charset, line separator handled correctly).
For BufferedReader and BufferedWriter, try-with-resources is the standard. The pattern handles cleanup, exceptions, and the awkward question of "what do I do if close() itself throws?".
Output (given a 3-line `orders.csv`):
The try (...) declares the BufferedReader as a managed resource. Whether the block exits normally or by exception, br.close() runs. And because closing a wrapper closes its underlying reader, the FileReader is cleaned up too. One declaration, one cleanup, no leaks.
A common question: if you wrap a FileReader in a BufferedReader, do you need to declare both in the try header? The answer is no. The outer wrapper's close() closes the inner one. Declaring both is harmless but verbose:
The single-declaration form is cleaner:
There's one edge case where the two-declaration form matters. If the BufferedReader constructor itself throws (very rare, but theoretically possible if sz is non-positive in the two-arg form), the FileReader is left open because it was never assigned to a managed variable. Declaring both in the try header guarantees cleanup even in that case. For everyday code, the single-line form is fine.
The writing side mirrors the read side exactly:
`wishlist.txt` contents:
close() flushes the buffer before releasing the file handle. If you forget the try-with-resources and your program ends without closing the writer, the buffered (but unflushed) data is lost forever. That's the most common buffered-writer bug, and try-with-resources makes it impossible.
Forgetting to close a BufferedWriter doesn't just leak a file handle. It loses any data sitting in the buffer at exit. Buffered writes are not safe until flushed, and the JVM does not flush writers on shutdown automatically.
A program that reads a clickstream log, keeps only the lines for a specific product, and writes them to a new file. It uses a BufferedReader to read and a BufferedWriter to write, both in one try-with-resources block.
Output (given a clickstream with 18 matching lines):
Two resources, one try-with-resources header (separated by ;), both closed in reverse order at the end. The reader streams the input file one line at a time. The writer buffers the matching lines and flushes them on close. Memory usage is constant regardless of input size, which is the whole point.
If the input file has 100 million lines, this program still works without changes. Memory stays flat because only one String line is alive at a time, plus the two 8 KB buffers. Compare that to Files.readAllLines(...), which loads the entire file into a List<String> before you do anything: fine for small files, ruinous for big ones.
The byte-oriented equivalents BufferedInputStream and BufferedOutputStream exist for non-text data (images, archives, anything binary). They follow the same wrap-for-speed pattern but work in byte[] instead of char[]. Use the character classes for text; use the byte classes for binary. Mixing them is how files end up corrupted.
A short tour of common mistakes.
Forgetting to close the writer. Without close() (or try-with-resources), buffered data sits in memory and is lost when the program exits. The output file looks truncated or empty.
Fix:
Treating `null` from `readLine()` as an error. null means "end of stream", not "something went wrong". Checking against null exits the loop cleanly; throwing or logging on null adds noise.
Forgetting the parentheses in the loop header.
The compiler reads this as line = (br.readLine() != null), which tries to assign a boolean to a String variable. The error is "incompatible types: boolean cannot be converted to String".
Fix:
The outer parentheses around the assignment fix the precedence.
Wrapping a writer twice. Wrapping a BufferedWriter in another BufferedWriter doesn't double the speed; it just adds a useless layer and an extra buffer. Wrap once, near the file.
Mixing `newLine()` with hard-coded `"\n"` in the same file. Use one or the other consistently. Mixed line endings show up as files that look fine in your editor but break in someone else's tools.
Reading with `read()` in a tight loop and expecting buffering to help on its own. It does help, but if you call read() 10 million times to read 10 MB, you still pay for 10 million method calls and 10 million bounds checks. readLine() or read(char[]) is faster for bulk reads because it moves more data per call.
Single-character read() even on a buffered reader is faster than unbuffered single-character read(), but slower than readLine() or read(char[]). The buffer eliminates the OS overhead; it doesn't eliminate the per-call Java overhead. For bulk reads, prefer batch methods.
10 quizzes