Java FileReader and FileWriter Explained — With Real-World Examples and Common Pitfalls
Every serious application eventually needs to talk to the file system — reading config files, writing logs, importing CSV data, exporting reports. Java's FileReader and FileWriter are the most direct tools for doing exactly that with text files. They're part of the java.io package, which has been in Java since version 1.1, and understanding them properly unlocks a whole layer of practical programming that goes beyond printing to the console.
The problem they solve is straightforward: your program's memory is temporary. The moment your JVM shuts down, everything in RAM is gone. FileWriter lets you persist text data to disk so it survives restarts, reboots, and crashes. FileReader is the flip side — it lets you pull that saved data back into memory so your program can work with it again. Together they form the foundation of text-based file I/O in Java.
By the end of this article you'll know how FileReader and FileWriter work under the hood, how to use them safely with try-with-resources, how to append instead of overwrite, how to read files efficiently character by character or line by line, and exactly which real-world situations call for them versus their more powerful alternatives. You'll also know the three mistakes that trip up most intermediate developers — and how to avoid them entirely.
What FileWriter Actually Does — Writing Text to Disk
FileWriter is a character stream writer. That means it converts Java characters (which are Unicode) into bytes and writes them to a file. By default it uses the platform's default charset — more on why that matters in the pitfalls section.
When you create a FileWriter with just a filename, it opens the file in 'overwrite' mode. Every run wipes the file clean and starts fresh. If you pass true as the second argument, it switches to 'append' mode — new content goes to the end of the existing file. This is how log files work in most basic applications.
FileWriter extends OutputStreamWriter, which extends Writer. So it's fully polymorphic — anywhere you need a Writer, a FileWriter fits. This matters because it means you can wrap it with a BufferedWriter for dramatically better performance. Raw FileWriter hits the disk on every single write() call. BufferedWriter batches those writes into chunks. For anything longer than a few lines, always wrap.
You must close a FileWriter when you're done. Failing to do so is one of the most common bugs — the data never actually reaches the disk because it's still sitting in an internal buffer waiting to be flushed. The safest way to guarantee the file gets closed is try-with-resources, which Java handles automatically.
import java.io.BufferedWriter; import java.io.FileWriter; import java.io.IOException; public class WriteUserReport { public static void main(String[] args) { String reportFilePath = "user_report.txt"; // Try-with-resources ensures the writer is closed automatically, // even if an exception is thrown mid-write. // BufferedWriter wraps FileWriter for performance — without it, // every write() call is a separate OS-level disk operation. try (BufferedWriter reportWriter = new BufferedWriter(new FileWriter(reportFilePath))) { // Write the report header reportWriter.write("=== Monthly User Report ==="); reportWriter.newLine(); // platform-safe newline (\r\n on Windows, \n on Unix) reportWriter.write("Username: alice_dev"); reportWriter.newLine(); reportWriter.write("Logins this month: 47"); reportWriter.newLine(); reportWriter.write("Status: Active"); reportWriter.newLine(); // No need to call flush() or close() — try-with-resources does it System.out.println("Report written successfully to: " + reportFilePath); } catch (IOException writeException) { // IOException covers: file not found, permission denied, disk full, etc. System.err.println("Failed to write report: " + writeException.getMessage()); } } }
[user_report.txt contents]
=== Monthly User Report ===
Username: alice_dev
Logins this month: 47
Status: Active
Appending to a File — The Second Argument That Changes Everything
The most common gotcha with FileWriter is accidentally nuking an existing file. If you're building a logger, an audit trail, or any kind of running history, you need append mode. The fix is a single boolean argument: new FileWriter(filePath, true). That true tells Java to open the file at the end rather than from the beginning.
Under the hood, true maps to the FileOutputStream append flag, which maps to the OS-level open call with O_APPEND. This means even if two processes try to append to the same file simultaneously, the OS handles the ordering — though for true concurrent logging in production you'd use a dedicated logging framework.
The pattern below simulates a simple application event log — each time the program runs, it adds a new timestamped entry without touching anything already in the file. This is exactly how application logs, audit trails, and event histories are built at a basic level.
import java.io.BufferedWriter; import java.io.FileWriter; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; public class AppendEventLog { // Centralized log path — in a real app this comes from config private static final String LOG_FILE_PATH = "application_events.log"; private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static void logEvent(String eventMessage) throws IOException { // The 'true' argument is the key — it enables append mode. // Without it, every call to logEvent() would destroy the previous log. try (BufferedWriter logWriter = new BufferedWriter(new FileWriter(LOG_FILE_PATH, true))) { String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT); // Format: [2024-06-15 14:32:01] User alice_dev logged in logWriter.write("[" + timestamp + "] " + eventMessage); logWriter.newLine(); } // Writer is closed here — the OS commits this line to disk } public static void main(String[] args) throws IOException { // Simulate three events happening across one session logEvent("Application started"); logEvent("User alice_dev logged in"); logEvent("User alice_dev exported report"); System.out.println("Events logged. Check: " + LOG_FILE_PATH); } }
[application_events.log contents after first run]
[2024-06-15 14:32:01] Application started
[2024-06-15 14:32:01] User alice_dev logged in
[2024-06-15 14:32:01] User alice_dev exported report
[application_events.log contents after second run — old entries still there]
[2024-06-15 14:32:01] Application started
[2024-06-15 14:32:01] User alice_dev logged in
[2024-06-15 14:32:01] User alice_dev exported report
[2024-06-15 14:35:44] Application started
[2024-06-15 14:35:44] User alice_dev logged in
[2024-06-15 14:35:44] User alice_dev exported report
Reading Files With FileReader — Character by Character and Line by Line
FileReader is the reading counterpart. Like FileWriter, it's a character stream — it reads bytes from disk and converts them to Java chars using the platform's default charset. Wrapping it with BufferedReader is not optional for real code. BufferedReader adds a read buffer (8KB by default) so Java isn't making a system call to the OS for every single character, and it provides the essential readLine() method.
readLine() returns the next line of text without the line terminator, or null when the file ends. That null check in the while loop is the idiomatic Java pattern for reading a file line by line. Forgetting that null signals EOF (end of file) and not an error is a classic beginner mistake.
The example below reads a simple CSV-style config file — the kind you'd use to store database connection settings or feature flags. It parses each line into a key-value pair, demonstrating a real use case rather than just printing raw file contents.
import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; import java.util.HashMap; import java.util.Map; public class ReadConfigFile { // Assume this file exists on disk with the content shown in the output section private static final String CONFIG_FILE_PATH = "app_config.properties"; public static Map<String, String> loadConfig(String filePath) throws IOException { Map<String, String> configSettings = new HashMap<>(); // BufferedReader wraps FileReader — gives us readLine() and a memory buffer // so we're not hitting the disk character-by-character try (BufferedReader configReader = new BufferedReader(new FileReader(filePath))) { String currentLine; // readLine() returns null at end-of-file — NOT an empty string // This while loop is the standard Java pattern for reading all lines while ((currentLine = configReader.readLine()) != null) { // Skip blank lines and comment lines (lines starting with #) if (currentLine.isBlank() || currentLine.startsWith("#")) { continue; } // Each line is expected to look like: db.host=localhost String[] keyValuePair = currentLine.split("=", 2); if (keyValuePair.length == 2) { String settingKey = keyValuePair[0].trim(); String settingValue = keyValuePair[1].trim(); configSettings.put(settingKey, settingValue); } } } // BufferedReader (and the FileReader inside it) closed automatically here return configSettings; } public static void main(String[] args) { try { Map<String, String> appConfig = loadConfig(CONFIG_FILE_PATH); System.out.println("Loaded " + appConfig.size() + " config settings:"); appConfig.forEach((key, value) -> System.out.println(" " + key + " -> " + value) ); } catch (IOException configLoadException) { System.err.println("Could not load config: " + configLoadException.getMessage()); } } }
# Database configuration
db.host=localhost
db.port=5432
db.name=forge_production
# Feature flags
feature.dark_mode=true
[Console output when running ReadConfigFile]
Loaded 4 config settings:
db.host -> localhost
db.port -> 5432
db.name -> forge_production
feature.dark_mode -> true
Copying a Text File — Putting FileReader and FileWriter Together
The clearest way to understand both classes working together is to build a file copy utility. This is also a surprisingly common real-world task — think copying templates, creating backup files, or duplicating config files before modifying them.
This example reads from a source file line by line and writes each line to a destination file, preserving the structure. It also adds a metadata header to the copy — something a raw Files.copy() call couldn't do without extra steps.
Notice the try-with-resources block manages both the reader and the writer simultaneously. When the block exits — successfully or via exception — both streams are closed in reverse declaration order (writer first, then reader). This is Java's guaranteed cleanup contract, and it's the only safe way to handle multiple I/O resources together.
import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.time.LocalDate; public class TextFileCopier { /** * Copies a text file from sourcePath to destinationPath, * prepending a metadata header to the copy. * * @param sourcePath path to the original text file * @param destinationPath path where the copy will be written * @throws IOException if either file cannot be opened or written */ public static void copyWithMetadata(String sourcePath, String destinationPath) throws IOException { // Both resources declared in one try-with-resources block — // Java closes them both automatically, in reverse order (writer, then reader) try ( BufferedReader sourceReader = new BufferedReader(new FileReader(sourcePath)); BufferedWriter destinationWriter = new BufferedWriter(new FileWriter(destinationPath)) ) { // Add a metadata header that doesn't exist in the original destinationWriter.write("# Copied from: " + sourcePath); destinationWriter.newLine(); destinationWriter.write("# Copy date: " + LocalDate.now()); destinationWriter.newLine(); destinationWriter.write("# ----------------------------------------"); destinationWriter.newLine(); String sourceLine; int lineCount = 0; // Read source line by line — null signals we've reached the end while ((sourceLine = sourceReader.readLine()) != null) { destinationWriter.write(sourceLine); destinationWriter.newLine(); lineCount++; } System.out.println("Copy complete. Lines copied: " + lineCount); System.out.println("Destination: " + destinationPath); } // Both streams flushed and closed here — content is safely on disk } public static void main(String[] args) { try { copyWithMetadata("original_template.txt", "template_backup_copy.txt"); } catch (IOException copyException) { System.err.println("File copy failed: " + copyException.getMessage()); } } }
Destination: template_backup_copy.txt
[template_backup_copy.txt first 3 lines]
# Copied from: original_template.txt
# Copy date: 2024-06-15
# ----------------------------------------
[... rest of original file content follows ...]
| Feature / Aspect | FileReader / FileWriter | Files.readString / Files.writeString (NIO) |
|---|---|---|
| Java version introduced | Java 1.1 | Java 11+ |
| Reading entire file | Requires loop + StringBuilder | One method call |
| Writing entire string | Requires open, write, close | One method call |
| Charset control | Only via InputStreamReader wrapper | Built-in Charset parameter |
| Best for | Streaming large files line by line | Small files, quick reads/writes |
| Buffering needed? | Yes — always wrap with Buffered* | Built in automatically |
| Append mode | new FileWriter(path, true) | StandardOpenOption.APPEND flag |
| Performance on large files | Excellent when buffered | Loads whole file into memory |
| Exception type | Checked IOException | Checked IOException |
| Closing resources | Must use try-with-resources | Handled internally |
🎯 Key Takeaways
- FileWriter has two modes: overwrite (default) and append (pass true as the second argument) — getting this wrong causes silent data loss with no exception or warning.
- Always wrap FileReader in BufferedReader and FileWriter in BufferedWriter — raw FileReader/FileWriter make one OS system call per character, which kills performance on anything larger than a few lines.
- Try-with-resources is non-negotiable — if you don't close a FileWriter, its internal buffer may never flush to disk, meaning your data never actually gets written even though no exception was thrown.
- FileReader and FileWriter inherit the platform's default charset — for any file with non-ASCII content, switch to InputStreamReader/OutputStreamWriter with an explicit StandardCharsets.UTF_8 argument to avoid cross-platform encoding bugs.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Not wrapping FileReader/FileWriter with their Buffered counterparts — Symptom: code works correctly but is extremely slow on files larger than a few KB because every character read/write is a separate OS system call — Fix: always write new BufferedReader(new FileReader(path)) and new BufferedWriter(new FileWriter(path)); never use FileReader or FileWriter raw in production code.
- ✕Mistake 2: Forgetting to close the stream (or not using try-with-resources) — Symptom: data written with FileWriter appears missing or truncated in the output file, because the internal buffer was never flushed to disk — Fix: always use try-with-resources; if you must close manually, put writer.close() in a finally block, never just at the end of the try block where an exception could skip it.
- ✕Mistake 3: Relying on the platform default charset — Symptom: files with accented characters, emojis, or non-ASCII content display as garbage (e.g., '??' or '') on a different machine or OS — Fix: use new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8) and new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8) instead of raw FileReader/FileWriter whenever your content might contain non-ASCII text; this is the most invisible, hardest-to-debug bug in Java I/O.
Interview Questions on This Topic
- QWhy should you always wrap FileReader with BufferedReader rather than using FileReader directly? What exactly happens at the OS level if you don't?
- QWhat is the difference between new FileWriter('log.txt') and new FileWriter('log.txt', true), and what real-world bug does confusing them cause?
- QFileReader and FileWriter use the platform's default charset. Why is this a problem, and how would you rewrite them to guarantee UTF-8 encoding in a cross-platform application?
Frequently Asked Questions
What is the difference between FileReader and BufferedReader in Java?
FileReader is the low-level stream that connects directly to a file on disk and reads one character at a time via OS system calls. BufferedReader is a wrapper that sits on top of FileReader and stores a chunk of characters in memory (8KB by default), dramatically reducing the number of OS calls. BufferedReader also adds the essential readLine() method. You almost always use both together: new BufferedReader(new FileReader(path)).
Does Java FileWriter create the file if it doesn't exist?
Yes — if the file doesn't exist, FileWriter creates it automatically. However, it will throw a FileNotFoundException (which is a subclass of IOException) if any parent directory in the path doesn't exist. So new FileWriter('reports/june/output.txt') will fail if the 'reports/june/' directory hasn't been created yet. Use new File('reports/june/').mkdirs() before writing if you need to guarantee the directory exists.
When should I use FileReader/FileWriter versus Java NIO Files.readString or Files.writeString?
Use Files.readString() and Files.writeString() (available since Java 11) when dealing with small files you want to read or write in a single operation — it's simpler, handles charset properly, and requires less code. Use FileReader and FileWriter (wrapped in their Buffered counterparts) when streaming large files line by line, because NIO's single-call methods load the entire file into memory at once, which can cause OutOfMemoryErrors on multi-gigabyte files.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.