Java File Handling
Every real program eventually needs to read data from disk or save results for later use — configuration files, logs, CSV exports, saved application state. Java gives you two complementary toolkits for this: the classic stream-based classes in the java.io package, and the newer, path-based API in java.nio.file. This lesson covers both in depth — how data actually moves between your program and the operating system, how to read and write text correctly, and the mistakes that silently lose data or leak file handles.
Overview: How File I/O Works in Java
Java models file access as a stream: a one-directional pipeline of data flowing either into your program (an input stream) or out of it (an output stream). At the lowest level, everything on disk is bytes, so FileInputStream and FileOutputStream read and write raw bytes. Because most files programmers work with are text, Java layers character streams on top: FileReader and FileWriter convert bytes to and from characters using a text encoding (by default, your platform’s default charset, though you should specify one explicitly — more on that below).
On top of these "raw" streams, Java uses a decorator pattern to add functionality. BufferedReader wraps a Reader to add an internal character buffer and a convenient readLine() method. BufferedWriter wraps a Writer the same way, batching characters in memory before writing them to disk in larger chunks. This matters for performance: every direct read or write to a file involves an operating system call, which is thousands of times slower than an in-memory operation. Without buffering, reading a file one character at a time would issue one system call per character.
Most of these I/O operations can fail — a file might not exist, a disk might be full, permissions might be denied — and Java forces you to acknowledge this: nearly every method in java.io declares throws IOException, a checked exception you must either catch or propagate. This is why almost all file-handling code appears inside a try block.
Since Java 7, the java.nio.file package offers a more modern alternative built around two types: Path, which represents a file system location (replacing raw strings and the older File class), and Files, a utility class of static methods for common operations — reading an entire file into a list of lines, writing a list of lines in one call, copying, moving, deleting, and checking existence. For whole-file operations, java.nio.file is usually simpler; for line-by-line processing of large files, the java.io stream classes (or NIO’s buffered stream factories) are still the right tool.
Syntax
// java.io style
try (BufferedReader reader = new BufferedReader(new FileReader("input.txt"))) {
String line = reader.readLine();
} catch (IOException e) {
// handle the error
}
// java.nio.file style
Path path = Path.of("input.txt");
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
Files.write(path, lines, StandardCharsets.UTF_8);
| Constructor / Method | Purpose |
|---|---|
| new FileReader(String path) | Opens a character input stream for reading text |
| new FileWriter(String path) | Opens a character output stream; truncates the file if it exists |
| new FileWriter(String path, boolean append) | Same, but appends to the end of the file when append is true |
| new BufferedReader(Reader r) | Wraps a reader with a buffer and adds readLine() |
| new BufferedWriter(Writer w) | Wraps a writer with a buffer and adds newLine() |
| Files.readAllLines(Path, Charset) | Reads an entire text file into a List of String, one entry per line |
| Files.write(Path, Iterable, Charset) | Writes a collection of lines to a file in one call |
| Files.exists(Path) | Checks whether a file or directory exists |
Any class implementing the AutoCloseable interface — which includes every reader, writer, and stream discussed here — can appear inside the parentheses of a try-with-resources statement. The resource is guaranteed to be closed automatically when the block exits, whether normally or via an exception.
Examples
Example 1: Writing text with BufferedWriter
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
public class Main {
public static void main(String[] args) {
String fileName = "notes.txt";
try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) {
writer.write("Java File Handling");
writer.newLine();
writer.write("Streams, readers, and writers");
writer.newLine();
writer.write("make file I/O possible.");
writer.newLine();
System.out.println("Wrote 3 lines to " + fileName);
} catch (IOException e) {
System.out.println("Could not write file: " + e.getMessage());
}
}
}
Output:
Wrote 3 lines to notes.txt
The try-with-resources statement opens the BufferedWriter (which itself wraps a FileWriter), writes three lines separated by newLine() calls, and closes both writers automatically when the block ends — flushing any buffered characters to disk in the process. If the file did not exist, it is created; since no append flag was passed, an existing file would be overwritten.
Example 2: Reading text with BufferedReader
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
public class Main {
public static void main(String[] args) {
String fileName = "colors.txt";
try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) {
writer.write("Red");
writer.newLine();
writer.write("Green");
writer.newLine();
writer.write("Blue");
writer.newLine();
} catch (IOException e) {
System.out.println("Write failed: " + e.getMessage());
return;
}
try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
String line;
int lineNumber = 1;
while ((line = reader.readLine()) != null) {
System.out.println(lineNumber + ": " + line);
lineNumber++;
}
} catch (IOException e) {
System.out.println("Read failed: " + e.getMessage());
}
}
}
Output:
1: Red
2: Green
3: Blue
This example first writes a small file, then reopens it for reading. readLine() returns each line without its line terminator, and returns null once the end of the file is reached — that null check is what drives the while loop’s termination.
Example 3: Whole-file operations with java.nio.file.Files
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) {
Path path = Path.of("scores.txt");
List<String> records = Arrays.asList("Alice,88", "Ben,92", "Cara,79");
try {
Files.write(path, records, StandardCharsets.UTF_8);
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
int total = 0;
for (String line : lines) {
String[] parts = line.split(",");
int score = Integer.parseInt(parts[1]);
total += score;
System.out.println(parts[0] + " scored " + score);
}
double average = (double) total / lines.size();
System.out.printf("Average score: %.2f%n", average);
} catch (IOException e) {
System.out.println("File operation failed: " + e.getMessage());
}
}
}
Output:
Alice scored 88
Ben scored 92
Cara scored 79
Average score: 86.33
Here, Files.write writes an entire list of strings as lines in a single call, and Files.readAllLines reads them back into a list just as directly — no manual buffering or loop-based reading required. This style is ideal when a file is small enough to comfortably fit in memory, such as configuration data or modest CSV files.
Under the Hood: What Happens on Read and Write
Understanding the mechanics helps explain both the performance advice and the common bugs below.
Opening a file asks the operating system for a file descriptor (or handle) — a reference to an open file that the OS uses to track your read/write position. This is a limited system resource; every process can only have a certain number open at once, which is why leaked file handles eventually cause failures.
Reading with a buffer: when a BufferedReader calls readLine(), it does not ask the OS for one character. Instead, it requests a large chunk of bytes (typically a few kilobytes) from the underlying stream in one system call, stores them in an internal char array, and scans that in-memory array for line terminators. Only when the buffer is exhausted does it go back to the OS for more data. This is what makes buffered reads dramatically faster than unbuffered ones for line-oriented text.
Writing with a buffer works in reverse: calls to write() append characters to an internal buffer rather than touching the disk immediately. Only when the buffer fills up, or when you explicitly call flush(), or when the stream is close()d, does Java issue a system call to actually push those bytes to the file. This is precisely why unclosed, unflushed writers can lose data — the bytes may still be sitting in memory when the program exits.
Closing flushes any remaining buffered data and releases the file descriptor back to the operating system. Try-with-resources guarantees this call happens, in reverse order of resource creation, even if an exception is thrown mid-operation.
Common Mistakes
Mistake 1: Forgetting to close the stream
Without try-with-resources or an explicit close in a finally block, an exception between opening and closing the file skips the close entirely — leaking a file descriptor and possibly losing buffered writes.
FileWriter writer = new FileWriter("log.txt");
writer.write("Application started");
// No close() call here: if anything below throws, this file
// is never flushed or closed, and the descriptor leaks.
The fix is to always use try-with-resources, which closes the resource automatically regardless of how the block exits:
import java.io.FileWriter;
import java.io.IOException;
public class Main {
public static void main(String[] args) {
try (FileWriter writer = new FileWriter("log.txt")) {
writer.write("Application started");
} catch (IOException e) {
System.out.println("Failed to write log: " + e.getMessage());
}
}
}
Mistake 2: Confusing overwrite mode with append mode
new FileWriter(path) truncates the file every time it is opened, which surprises developers trying to build up a log file across multiple runs — each run silently erases the previous content.
// Called once per log entry across many runs of the program:
FileWriter writer = new FileWriter("data.txt");
writer.write("New entry\n");
// Each run wipes out everything written by the previous run,
// because the two-argument append flag was never set.
Passing true as the second constructor argument switches to append mode, preserving existing content:
import java.io.FileWriter;
import java.io.IOException;
public class Main {
public static void main(String[] args) {
String fileName = "data.txt";
try (FileWriter writer = new FileWriter(fileName)) {
writer.write("First run\n");
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
}
try (FileWriter writer = new FileWriter(fileName, true)) {
writer.write("Second run appended\n");
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
}
try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(fileName))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
Output:
First run
Second run appended
Best Practices
- Always open file resources with try-with-resources instead of manual close() calls; it handles exceptions correctly and cannot be forgotten.
- Wrap FileReader and FileWriter in BufferedReader and BufferedWriter for anything beyond trivial file sizes — the performance difference is substantial.
- Specify a character encoding explicitly (for example StandardCharsets.UTF_8) rather than relying on the platform default, so your program behaves the same on every machine.
- For small-to-medium files, prefer the java.nio.file.Files convenience methods (readAllLines, write, readString, writeString) over manual stream loops — less code, fewer chances for bugs.
- Use Files.exists() or catch NoSuchFileException / FileNotFoundException to handle missing files gracefully instead of letting the program crash.
- Remember the two-argument FileWriter and FileOutputStream constructors when you need to append rather than overwrite.
- Never silently swallow IOException with an empty catch block; at minimum log the message so failures are visible.
Practice Exercises
Exercise 1: Write a program that creates a file named inventory.txt containing several lines in the format "itemName,quantity". Then read the file back and print the total quantity across all items.
Exercise 2: Write a program that appends a new timestamped-looking entry (any string you like) to a file named history.txt every time it runs, without erasing previous entries. Run your logic twice within the same program to prove both entries survive.
Exercise 3: Using java.nio.file.Files, write a program that reads a list of numbers from a file (one per line), and prints the largest, smallest, and average value. Handle the case where the file might not exist by catching the appropriate exception and printing a friendly message.
Summary
- Java offers two main toolkits for file I/O: the stream-based java.io classes (FileReader, FileWriter, BufferedReader, BufferedWriter) and the modern java.nio.file classes (Path, Files).
- Character streams convert bytes to and from text using a charset; always specify one explicitly rather than depending on the platform default.
- Buffered classes reduce expensive system calls by batching reads and writes in memory, which is why they should wrap raw file streams for anything beyond trivial use.
- Nearly all file operations throw the checked IOException, so file-handling code must use try/catch or propagate the exception.
- try-with-resources guarantees a stream is closed — and its buffered data flushed — even when an exception occurs, and should be the default way to open any Closeable resource.
- FileWriter and FileOutputStream truncate a file by default; pass true as the append argument to add to existing content instead.
- Files.readAllLines and Files.write make whole-file text operations concise for files small enough to fit comfortably in memory.
