Senior 9 min · March 05, 2026

Java File Handling — Data Loss from Unflushed Buffers

JVM crash truncates log entries due to unflushed BufferedWriter buffers.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Java file I/O uses streams: InputStream/OutputStream for binary, Reader/Writer for text.
  • The File class represents a path, not an open file — use it for existence checks before I/O.
  • Always close streams with try-with-resources; OS file handles are finite (default 1024 per process on Linux).
  • BufferedReader buffers reads to reduce disk hits; Files.lines() provides lazy streaming for large files.
  • FileWriter defaults to overwrite (truncate); pass 'true' for append mode — losing data is a painful rite of passage.
✦ Definition~90s read
What is File Handling in Java?

Java file handling is the set of APIs and patterns for reading from and writing to files on disk, but it's also a minefield of subtle bugs that corrupt data or crash production systems. The core problem is that Java buffers I/O for performance — when you write to a FileWriter or OutputStream, data often sits in an in-memory buffer before being flushed to disk.

Think of your Java program as a person sitting at a desk.

If your application crashes, power fails, or you forget to close the resource, that buffered data is lost silently. This isn't academic; it's the root cause of corrupted logs, incomplete exports, and partial database dumps in real-world systems. The solution isn't just 'use flush()' — it's understanding the contract between your code, the JVM, and the OS file cache.

Java provides three generations of file APIs: the original java.io (File, FileInputStream, FileOutputStream), java.nio (channels and buffers), and the modern java.nio.file (NIO.2) with Files, Path, and FileSystem. The old File class is essentially a path abstraction — it doesn't read or write data, it checks existence, permissions, and metadata.

For actual I/O, you choose between character streams (Reader/Writer for text) and byte streams (InputStream/OutputStream for binary). The critical pattern is try-with-resources, which guarantees close() is called even on exceptions, flushing buffers automatically.

Without it, you're gambling on garbage collection timing, which is not a flush strategy.

For production code, you should default to NIO.2's Files.write() and Files.readString() for simple text operations — they handle buffering, encoding, and resource cleanup internally. For large files, use BufferedReader and BufferedWriter wrapped around FileReader/FileWriter, or the newer Files.newBufferedReader() which returns a BufferedReader directly.

Binary data requires careful stream management: BufferedInputStream for reading, BufferedOutputStream for writing, and always flush before close. The Files.copy() method supports atomic moves across filesystems using StandardCopyOption.ATOMIC_MOVE, which prevents partial writes from being visible to other processes — critical for log rotation, config updates, and any multi-process file access.

When NOT to use these APIs: for high-throughput logging, use a dedicated logging framework (Logback, Log4j) with async appenders; for databases, use JDBC or an ORM — don't write your own file-based persistence. Java file handling is for configuration files, data exports, batch processing, and simple state persistence.

For anything requiring transactions, concurrent access, or crash recovery, reach for a proper storage engine. The key takeaway: unflushed buffers are the #1 cause of data loss in Java file I/O, and the fix is always structural (try-with-resources, NIO.2 helpers) rather than procedural (remembering to call flush()).

Plain-English First

Think of your Java program as a person sitting at a desk. The computer's hard drive is a massive filing cabinet in the corner. File handling is simply the set of actions your program uses to walk over to that cabinet, pull out a folder, read what's inside, add new notes, or create a brand-new folder. Without file handling, your program's memory disappears the moment it stops running — like writing notes on a whiteboard that gets erased every night.

Every meaningful application eventually needs to persist data. A banking app logs transactions. A web server writes error logs. A game saves your progress. Without the ability to read from and write to files, your Java program lives entirely in RAM — and the moment the JVM shuts down, everything it knew is gone forever. File handling is the bridge between your program's temporary world and the permanent world of the disk.

The problem Java historically had was that its original file I/O API (java.io) was clunky, verbose, and made it annoyingly easy to leak resources. You'd open a file, forget to close it, and slowly starve your OS of file handles. The modern Java ecosystem fixed this with try-with-resources, the NIO.2 API introduced in Java 7, and utility classes like Files and Paths that let you accomplish in one line what used to take twenty.

By the end of this article you'll know exactly which Java file API to reach for in which situation, how to safely read and write text files without leaking resources, how to handle real-world edge cases like missing files and encoding issues, and what interviewers mean when they ask you to 'explain the difference between FileReader and BufferedReader.' Let's build this understanding layer by layer.

Why File Handling in Java Is More Than Just File I/O

File handling in Java is the set of APIs and patterns for reading from and writing to files on disk. At its core, it involves creating a stream or channel to a file path, performing byte or character operations, and then closing the resource. The fundamental mechanic is that Java buffers data in memory before writing to disk — a performance optimization that becomes a correctness hazard when buffers are not flushed.

In practice, file handling uses classes like FileInputStream, FileOutputStream, FileReader, FileWriter, and the newer NIO Path and Files API. The key property that matters: buffered streams (BufferedWriter, BufferedOutputStream) hold data in a 8 KB buffer by default. If the JVM crashes or the program exits without flushing, that buffered data is lost. Unbuffered streams write immediately but are slower — typically 10-100x slower for small writes.

Use file handling when you need persistent storage for configuration, logs, user data, or state. It matters in real systems because a single unflushed write can corrupt a log file, lose a transaction record, or leave a configuration file half-written. In production, the difference between a reliable system and a data-loss incident is often just a missing flush() or a try-with-resources block.

Buffered ≠ Safe
Buffered streams improve performance but do not guarantee data is on disk until flush() or close() is called. A crash before that point silently discards data.
Production Insight
A payment processing service wrote transaction receipts using BufferedWriter but forgot to flush before a graceful shutdown hook. Result: 12% of receipts were missing from disk, causing audit failures.
The exact symptom: log files ended mid-line, and file sizes were smaller than expected.
Rule of thumb: always flush() before any shutdown signal, and use try-with-resources to guarantee close() even on exceptions.
Key Takeaway
Buffered writes are not durable until flush() or close() completes.
Use try-with-resources for all file handles — it auto-closes and flushes.
For critical data, call flush() explicitly and consider using FileChannel.force(true) for sync to disk.
Java File Handling: Buffer Flush Pitfalls THECODEFORGE.IO Java File Handling: Buffer Flush Pitfalls Flow from file access to safe write/read with NIO.2 File Class Maps path to file system entry Buffered Writer Writes text with internal buffer try-with-resources Auto-closes stream, flushes buffer Files.write() Atomic write via NIO.2 Buffered Reader Efficient line-by-line reading Binary Streams Raw byte I/O for non-text data ⚠ Unflushed buffer causes data loss on crash Always use try-with-resources or explicit flush() THECODEFORGE.IO
thecodeforge.io
Java File Handling: Buffer Flush Pitfalls
File Handling Java

The File Class: Your Map Before You Open the Territory

Before you read or write anything, Java needs to know WHERE the file lives. That's what the java.io.File class is for. It doesn't open a file — it just represents a path on the filesystem. Think of it as a GPS coordinate: the coordinate itself doesn't move you anywhere, but you need it before you can navigate.

The File class lets you check whether a path exists, whether it's a file or a directory, how large it is, and whether you have permission to read or write it. These checks prevent ugly runtime crashes. If you try to open a file that doesn't exist without checking first, Java throws a FileNotFoundException right in your face.

This class is also how you create new files and directories programmatically. You'd use this when your app needs to set up a log directory on first run, or verify a config file exists before loading it. It's your reconnaissance tool — use it before committing to any I/O operation.

FileInspector.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package io.thecodeforge.filehandling;

import java.io.File;
import java.time.Instant;

public class FileInspector {

    public static void main(String[] args) {
        // Represent a path — no file is opened yet, this is just a reference
        File configFile = new File("app-config.txt");

        // Check existence BEFORE attempting to read — avoids FileNotFoundException
        if (configFile.exists()) {
            System.out.println("File found: " + configFile.getAbsolutePath());
            System.out.println("Size: " + configFile.length() + " bytes");
            System.out.println("Readable: " + configFile.canRead());
            System.out.println("Writable: " + configFile.canWrite());
            System.out.println("Last modified: " +
                Instant.ofEpochMilli(configFile.lastModified()));
        } else {
            System.out.println("Config file not found. Creating it now...");

            try {
                // createNewFile() returns true if file was created, false if it already existed
                boolean wasCreated = configFile.createNewFile();
                System.out.println("File created: " + wasCreated);
            } catch (java.io.IOException e) {
                // IOException is thrown if the parent directory doesn't exist
                // or if you don't have write permission
                System.err.println("Could not create file: " + e.getMessage());
            }
        }

        // Demonstrate directory creation — mkdirs() creates the full path, not just one level
        File logDirectory = new File("logs/2024/january");
        if (!logDirectory.exists()) {
            boolean dirCreated = logDirectory.mkdirs(); // plural 'mkdirs' handles nested dirs
            System.out.println("Log directory created: " + dirCreated);
        }
    }
}
Output
Config file not found. Creating it now...
File created: true
Log directory created: true
Watch Out: mkdir() vs mkdirs()
mkdir() only creates ONE directory level. If you call it on 'logs/2024/january' and 'logs' doesn't exist yet, it silently returns false and creates nothing. Always use mkdirs() (plural) when your path has multiple levels.
Production Insight
File.exists() has a TOCTOU race condition — file could be deleted between check and open.
Use Files.exists() with LinkOption.NOFOLLOW_LINKS for symlink safety.
Even after a check, always catch IOException because the filesystem is shared.
Key Takeaway
File represents a path, not an open file handle.
Check existence and permissions before opening to avoid ugly crashes.
Don't trust check results — race conditions exist; handle IOException anyway.

Writing Files Safely — Why try-with-resources Is Non-Negotiable

Writing to a file in Java involves a stack of stream objects. At the bottom, FileWriter handles the raw bytes. On top of it, BufferedWriter batches those writes into chunks before hitting the disk — this is dramatically faster than flushing every single character individually. Think of it like mailing letters: you wouldn't run to the post office for every individual letter, you'd collect them and make one trip.

The critical lesson here isn't the syntax — it's resource management. Every time you open a file stream, the OS allocates a file handle. Most operating systems have a hard limit on open file handles per process (typically 1024 on Linux). If you forget to call close(), those handles pile up. Your app eventually crashes with 'Too many open files' — and that error is infuriating to debug in production.

The solution is try-with-resources (introduced in Java 7). Any object that implements AutoCloseable — which all I/O streams do — gets automatically closed when the try block exits, whether normally or via an exception. There's no excuse to use the old finally-block pattern anymore.

UserActivityLogger.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package io.thecodeforge.filehandling;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class UserActivityLogger {

    private static final String LOG_FILE_PATH = "user-activity.log";
    private static final DateTimeFormatter TIMESTAMP_FORMAT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    // Logs a user action to a file — the second argument controls append vs overwrite
    public static void logAction(String username, String action) throws IOException {
        String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT);
        String logEntry = String.format("[%s] USER=%s ACTION=%s%n",
            timestamp, username, action);

        // try-with-resources: the writer is GUARANTEED to close when this block exits
        // FileWriter(path, true) — the 'true' flag means APPEND, not overwrite
        try (BufferedWriter writer = new BufferedWriter(
                new FileWriter(LOG_FILE_PATH, true))) {

            writer.write(logEntry);
            // BufferedWriter batches writes — flush() forces the buffer to disk immediately
            // Not needed here because close() calls flush() automatically
        }
        // No need for finally { writer.close() } — try-with-resources handles it
    }

    public static void main(String[] args) throws IOException {
        logAction("alice", "LOGIN");
        logAction("alice", "VIEW_DASHBOARD");
        logAction("bob",   "LOGIN");
        logAction("alice", "LOGOUT");

        System.out.println("Activity log written to: " + LOG_FILE_PATH);
        System.out.println("Check the file to see the 4 entries.");
    }
}
Output
Activity log written to: user-activity.log
Check the file to see the 4 entries.
Pro Tip: Append vs Overwrite
new FileWriter(path) — no second argument — OVERWRITES the file every time. new FileWriter(path, true) APPENDS. For log files you almost always want append. Forgetting this flag and losing a week of logs is a rite of passage nobody wants to experience.
Production Insight
BufferedWriter without flush on every log entry means data stays in JVM buffer — crash loses it.
Each open file handle consumes OS limit (default 1024 per process on Linux).
Rule: always close in finally or use try-with-resources; never leak handles.
Key Takeaway
try-with-resources guarantees closure even on exceptions.
BufferedWriter batches writes for performance but delays persistence.
Append mode (true) prevents overwriting; overwrite loses data.

Reading Files Efficiently — BufferedReader and the Modern Files API

Reading a file line-by-line is one of the most common operations in any backend system: parsing CSVs, loading configs, processing log files. Java gives you two solid approaches — the classic BufferedReader for fine-grained control, and the modern java.nio.file.Files utility for convenience.

FileReader alone reads one character at a time from disk. That's catastrophically slow for large files. Wrapping it in BufferedReader adds an internal buffer (default 8KB) that reads a large chunk from disk, then serves your line-by-line calls from memory. The disk is hit far less often. For a 100MB log file, this difference is measured in seconds vs minutes.

The NIO.2 `Files` class (note: java.nio.file.Files, not java.io.File) is the modern alternative. Files.readAllLines() reads the entire file into a List<String> in one call — perfect for small config files. Files.lines() returns a lazy Stream<String>, which is ideal for large files because it doesn't load everything into memory at once. Know when to use each: small file with random access → readAllLines(). Large file processed sequentially → Files.lines() with a stream pipeline.

CsvReportParser.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package io.thecodeforge.filehandling;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Stream;

public class CsvReportParser {

    // --- APPROACH 1: BufferedReader — great when you need manual control per line ---
    public static void readWithBufferedReader(String filePath) throws IOException {
        System.out.println("=== BufferedReader Approach ===");

        try (BufferedReader reader = new BufferedReader(
                new FileReader(filePath, StandardCharsets.UTF_8))) { // Always specify charset!

            String currentLine;
            int lineNumber = 0;

            // readLine() returns null at end-of-file — that's your loop termination signal
            while ((currentLine = reader.readLine()) != null) {
                lineNumber++;
                if (lineNumber == 1) {
                    System.out.println("Header row: " + currentLine);
                    continue; // Skip the CSV header
                }
                String[] columns = currentLine.split(",");
                if (columns.length >= 3) {
                    System.out.printf("  Record: name=%-15s score=%s grade=%s%n",
                        columns[0].trim(), columns[1].trim(), columns[2].trim());
                }
            }
            System.out.println("Total data rows read: " + (lineNumber - 1));
        }
    }

    // --- APPROACH 2: Files.lines() — best for large files, uses a lazy stream ---
    public static long countHighScorers(String filePath, int minimumScore)
            throws IOException {
        Path csvPath = Paths.get(filePath);

        // Files.lines() does NOT load the whole file into memory at once
        // The try-with-resources is REQUIRED here — the stream holds an open file handle
        try (Stream<String> lineStream = Files.lines(csvPath, StandardCharsets.UTF_8)) {
            return lineStream
                .skip(1)  // skip the header row
                .map(line -> line.split(","))
                .filter(cols -> cols.length >= 2)
                .mapToInt(cols -> {
                    try { return Integer.parseInt(cols[1].trim()); }
                    catch (NumberFormatException e) { return 0; }
                })
                .filter(score -> score >= minimumScore)
                .count();
        }
    }

    // --- APPROACH 3: Files.readAllLines() — for small files where you want a List ---
    public static void readSmallConfigFile(String filePath) throws IOException {
        System.out.println("\n=== Files.readAllLines() Approach ===");
        Path configPath = Paths.get(filePath);

        // Loads ALL lines into memory — only use this for small files (< a few MB)
        List<String> allLines = Files.readAllLines(configPath, StandardCharsets.UTF_8);
        allLines.stream()
            .filter(line -> !line.startsWith("#")) // ignore comment lines
            .filter(line -> line.contains("="))
            .forEach(line -> System.out.println("  Config entry: " + line));
    }

    public static void main(String[] args) throws IOException {
        // To run this, create a file called 'students.csv' with the content:
        // name,score,grade
        // Alice,92,A
        // Bob,74,C
        // Charlie,88,B
        // Diana,95,A

        readWithBufferedReader("students.csv");

        long highScorers = countHighScorers("students.csv", 85);
        System.out.println("\nStudents scoring 85 or above: " + highScorers);
    }
}
Output
=== BufferedReader Approach ===
Header row: name,score,grade
Record: name=Alice score=92 grade=A
Record: name=Bob score=74 grade=C
Record: name=Charlie score=88 grade=B
Record: name=Diana score=95 grade=A
Total data rows read: 4
Students scoring 85 or above: 3
Watch Out: Files.lines() Must Be Closed
Files.lines() returns a lazy Stream that holds an open file handle underneath. If you don't wrap it in try-with-resources, the file stays locked until GC runs — which is unpredictable. This is one of the most common resource leak patterns in modern Java code.
Production Insight
Files.lines() holds an open file handle until stream closed; GC may not close it timely.
Large file processed with Files.readAllLines() blows heap — OOM in production.
Rule: for large files always use Files.lines() with try-with-resources.
Key Takeaway
BufferedReader buffers reads; Files.lines() is lazy.
Files.readAllLines() is convenient but memory-heavy.
Always close Files.lines() inside try-with-resources.

The NIO.2 Power Tools — Files.write(), Files.copy(), and Atomic Operations

Once you've mastered reading and writing, the next level is manipulating files as units: copying, moving, deleting, and writing content in a single call. The java.nio.file.Files utility class is your Swiss Army knife here. It was designed to replace the verbose, error-prone java.io.File operations with clean, predictable alternatives.

Files.write() is brilliant for writing small files — it handles opening, writing, flushing, and closing all in one call. You can also pass StandardOpenOption flags to control exactly how the write behaves: APPEND, CREATE, TRUNCATE_EXISTING, CREATE_NEW (which fails if the file exists — great for preventing accidental overwrites).

Files.copy() and Files.move() are atomic on most operating systems when copying within the same filesystem. Files.move() with the ATOMIC_MOVE option is especially useful for safe file replacement — the classic pattern is to write to a temp file, then atomically rename it to the final destination. This prevents any reader from ever seeing a half-written file, which is critical in high-reliability systems.

SafeConfigWriter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package io.thecodeforge.filehandling;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;

public class SafeConfigWriter {

    private static final Path CONFIG_PATH = Paths.get("application.properties");

    // SAFE write pattern: write to a temp file, then atomically rename
    // This means readers never see a partially-written config file
    public static void writeConfigSafely(List<String> configLines) throws IOException {
        // Create a temp file in the SAME directory — required for atomic move
        Path tempFile = Files.createTempFile(
            CONFIG_PATH.getParent(), // same directory as the real file
            ".app-config-",          // prefix
            ".tmp"                   // suffix
        );

        try {
            // Write all content to the temp file first
            // StandardOpenOption.WRITE + TRUNCATE_EXISTING is the default for Files.write()
            Files.write(tempFile, configLines, StandardCharsets.UTF_8);

            // ATOMIC_MOVE: on Linux/macOS this is a rename() syscall — instantaneous
            // REPLACE_EXISTING: overwrite the destination if it already exists
            Files.move(tempFile, CONFIG_PATH,
                StandardCopyOption.ATOMIC_MOVE,
                StandardCopyOption.REPLACE_EXISTING);

            System.out.println("Config written atomically to: " + CONFIG_PATH.toAbsolutePath());

        } catch (IOException writeError) {
            // If anything goes wrong, clean up the temp file
            Files.deleteIfExists(tempFile); // deleteIfExists won't throw if file is already gone
            throw writeError; // re-throw so the caller knows the write failed
        }
    }

    // Demonstrates other NIO.2 utilities
    public static void demonstrateCopyAndDelete() throws IOException {
        Path sourceFile = Paths.get("application.properties");
        Path backupFile = Paths.get("application.properties.bak");

        // Copy a file — REPLACE_EXISTING prevents CopyOption collision if backup exists
        if (Files.exists(sourceFile)) {
            Files.copy(sourceFile, backupFile, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("Backup created: " + backupFile);
            System.out.println("Backup size: " + Files.size(backupFile) + " bytes");
        }

        // Files.readString() — Java 11+, the most concise way to read a small file
        if (Files.exists(sourceFile)) {
            String content = Files.readString(sourceFile, StandardCharsets.UTF_8);
            System.out.println("\nConfig content:\n" + content);
        }
    }

    public static void main(String[] args) throws IOException {
        List<String> configEntries = List.of(
            "# Application Configuration",
            "app.name=TheCodeForge",
            "app.version=2.1.0",
            "db.host=localhost",
            "db.port=5432",
            "cache.enabled=true"
        );

        writeConfigSafely(configEntries);
        demonstrateCopyAndDelete();
    }
}
Output
Config written atomically to: /home/user/project/application.properties
Backup created: application.properties.bak
Backup size: 112 bytes
Config content:
# Application Configuration
app.name=TheCodeForge
app.version=2.1.0
db.host=localhost
db.port=5432
cache.enabled=true
Interview Gold: The Atomic Write Pattern
Interviewers love asking 'how would you prevent data corruption when writing a config file?' The answer is exactly this: write to a temp file, then atomically rename it. Never write directly to the production file — a crash mid-write leaves it corrupted. This pattern is used by databases, package managers, and virtually every production system.
Production Insight
Atomic move works only on same filesystem; cross-filesystem moves may copy then delete, not atomic.
Files.write() with default options truncates existing file — can accidentally wipe data.
Temp file cleanup is critical; use try-finally or Files.deleteIfExists().
Key Takeaway
Atomic rename prevents readers from seeing half-written config files.
Always set StandardOpenOption exhaustively for clarity.
Clean up temp files even on failure.

Binary File Handling: Streams for Non-Text Data

Not all files are text. Images, PDFs, audio, ZIP archives — these are binary data. For binary files, you use FileInputStream and FileOutputStream (or their buffered counterparts). The core difference: binary streams read and write raw bytes, not characters. No charset conversion happens.

FileInputStream reads one byte at a time from disk — that's slow. Wrap it in BufferedInputStream for speed. Similarly, BufferedOutputStream batches writes. For very large files, consider using FileChannel with a direct buffer to avoid copying data between JVM heap and native memory.

One trap: never use Reader/Writer classes on binary data. They try to decode bytes as characters and will corrupt your file. Always stick to InputStream/OutputStream when the data isn't explicitly text.

FileCopierBinary.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package io.thecodeforge.filehandling;

import java.io.*;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

public class FileCopierBinary {

    // Copy using BufferedInputStream/OutputStream — good for moderate file sizes
    public static void copyBuffered(Path source, Path target) throws IOException {
        try (InputStream in = new BufferedInputStream(new FileInputStream(source.toFile()));
             OutputStream out = new BufferedOutputStream(new FileOutputStream(target.toFile()))) {

            byte[] buffer = new byte[8192]; // 8 KB typical buffer
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
            }
        }
        // try-with-resources ensures both streams are closed
    }

    // Copy using FileChannel with transferTo() — faster for large files, zero-copy on many OS
    public static void copyChannel(Path source, Path target) throws IOException {
        try (FileChannel srcChannel = FileChannel.open(source, StandardOpenOption.READ);
             FileChannel destChannel = FileChannel.open(target, StandardOpenOption.CREATE_NEW,
                 StandardOpenOption.WRITE)) {

            // transferTo uses OS-level sendfile() under the hood — avoids copying bytes into Java heap
            long position = 0;
            long size = srcChannel.size();
            while (position < size) {
                position += srcChannel.transferTo(position, size - position, destChannel);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        Path source = Path.of("large-file.bin");
        Path target1 = Path.of("large-file-buffered.bin");
        Path target2 = Path.of("large-file-channel.bin");

        long start = System.nanoTime();
        copyBuffered(source, target1);
        long bufferedTime = System.nanoTime() - start;
        System.out.println("Buffered copy: " + bufferedTime / 1_000_000 + " ms");

        start = System.nanoTime();
        copyChannel(source, target2);
        long channelTime = System.nanoTime() - start;
        System.out.println("Channel copy: " + channelTime / 1_000_000 + " ms");
    }
}
Output
Buffered copy: 345 ms
Channel copy: 182 ms
(Actual times vary by file size and hardware, but channel is typically 2-3x faster for large files)
Never Mix Reader/Writer with Binary Files
If you wrap a FileInputStream in an InputStreamReader, it tries to decode bytes as characters using a charset. This will silently corrupt binary data (PDF, PNG, ZIP). Always use raw InputStream/OutputStream for binary content. There's no safe charset for binary data.
Production Insight
Buffered streams use 8 KB buffer by default — fine for most cases, but consider 64 KB for large sequential reads.
FileChannel.transferTo() uses sendfile() internally on Linux — zero-copy from disk to socket, huge win for static file serving.
DirectByteBuffer (not shown) avoids JVM heap overhead but requires explicit memory management; use with caution.
Key Takeaway
Binary files require InputStream/OutputStream, not Reader/Writer.
Buffered streams reduce system calls; channel-based copy is fastest.
Never use charset conversion on binary data; data corruption is silent and irrecoverable.

I/O Streams: The Raw Pipeline Nobody Told You About

Every file operation in Java eventually hits the stream layer. The File class is just metadata. The real data moves through streams — a continuous flow of bytes or characters that your code either consumes or produces.

Byte streams read and write raw binary data. 8 bits at a time. No encoding, no translation. Use them for images, archives, executables — anything where a single byte change corrupts the output. FileInputStream and FileOutputStream are your entry points, but wrapping them in BufferedInputStream or BufferedOutputStream is how you avoid melting the disk controller on large files.

Character streams, on the other hand, handle text. They decode byte sequences into Unicode characters using a specified charset (UTF-8, ISO-8859-1, system default — never rely on the default). InputStreamReader, OutputStreamWriter, and their convenience wrappers FileReader/FileWriter sit on top of byte streams. They do the translation so you don't have to mangle bytes into strings manually.

The gotcha? Everyone assumes FileReader uses UTF-8. It doesn't. It uses the platform's default encoding — and that breaks the moment your app containerizes or ships to a different locale. Always specify the charset explicitly.

ByteVsCharacterStreams.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// io.thecodeforge — java tutorial

import java.io.*;
import java.nio.charset.StandardCharsets;

public class ByteVsCharacterStreams {
    public static void main(String[] args) throws IOException {
        // Byte stream - raw binary, no encoding
        try (OutputStream rawOutput = new FileOutputStream("logo.png")) {
            // Write raw bytes from somewhere
            byte[] imageBytes = { (byte) 0x89, 'P', 'N', 'G' };
            rawOutput.write(imageBytes);
        }

        // Character stream - explicit UTF-8 encoding
        try (Writer textOutput = new OutputStreamWriter(
                new FileOutputStream("config.txt"), StandardCharsets.UTF_8)) {
            textOutput.write("database.url=jdbc:postgresql://prod:5432/inventory");
        }

        // Reading it back with explicit charset
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(
                        new FileInputStream("config.txt"), StandardCharsets.UTF_8))) {
            System.out.println(reader.readLine());
        }
    }
}
Output
database.url=jdbc:postgresql://prod:5432/inventory
Encoding Trap:
FileReader and FileWriter use the platform's default charset. On a Windows laptop that's often Windows-1252. On your Linux server it's UTF-8. Your config file gets written on your machine and read garbled in production. Use OutputStreamWriter with an explicit StandardCharsets.UTF_8 instead.
Key Takeaway
Choose byte streams for binary data, character streams for text — and always pin the charset to UTF-8 explicitly.

File Operations That Actually Fail in Production

Competitors list 'canRead', 'canWrite', 'exists' as if they're checking boxes. In production these methods lie to you. The classic trap: exists() returns true for a path that existed 2 milliseconds ago, but the file was rotated or deleted between the check and the actual read. That's the TOCTOU (Time of Check, Time of Use) race condition — and it's how you get NullPointerExceptions at 3 AM on Black Friday.

The fix? Don't check existence as a separate step. Attempt the operation and catch the specific exception. Files.readString() throws NoSuchFileException if the file vanished. Files.newOutputStream() with CREATE or TRUNCATE_EXISTING options atomically creates or overwrites — you don't need a separate canWrite() call.

Create a file? Files.createFile() throws if it already exists. Use Files.write() with CREATE, TRUNCATE_EXISTING, or APPEND to control behaviour. Delete? Files.delete() throws if the file doesn't exist — use Files.deleteIfExists() when you don't care. These atomic operations save you from the race condition hell of check-then-act patterns. Write defensive code, not defensive checks.

AtomicFileOperations.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// io.thecodeforge — java tutorial

import java.io.IOException;
import java.nio.file.*;
import java.nio.charset.StandardCharsets;

public class AtomicFileOperations {
    public static void main(String[] args) throws IOException {
        Path configPath = Path.of("/var/app/service.yml");

        // Atomic create-or-overwrite: no separate check needed
        Files.writeString(configPath, 
            "server.port: 8080\nlogging.level: INFO",
            StandardCharsets.UTF_8,
            StandardOpenOption.CREATE, 
            StandardOpenOption.TRUNCATE_EXISTING);

        // Atomic delete: true if deleted, false if never existed
        boolean deleted = Files.deleteIfExists(configPath);
        System.out.println("Config removed: " + deleted);

        // Attempt the read, catch if it's gone
        try {
            String content = Files.readString(
                Path.of("/var/app/secrets/db_password.txt"),
                StandardCharsets.UTF_8);
            System.out.println("Connection string loaded");
        } catch (NoSuchFileException e) {
            System.err.println("Secrets vault unavailable — failing fast");
            throw e;
        }
    }
}
Output
Config removed: true
Secrets vault unavailable — failing fast
java.nio.file.NoSuchFileException: /var/app/secrets/db_password.txt
Production Trap:
exists(), canRead(), canWrite() are lies waiting to happen. Between the check and the operation, another thread or process changes the file. Always attempt the operation and handle the failure. Use Files.write() with CREATE and TRUNCATE_EXISTING to atomically create or overwrite without two separate calls.
Key Takeaway
Never check-then-act on files. Attempt the operation atomically and handle the exception.

The OpenOptions Parameter: Stop Letting the OS Decide How Your Files Open

You've been leaving file open behavior to default assumptions. That's how you overwrite yesterday's sales report at 2 AM. The OpenOptions parameter — a vararg of StandardOpenOption enums — is your explicit contract with the OS. You tell it: append, not truncate. Create only if missing. Fail if missing. Write atomically.

Every NIO.2 method that touches a file accepts these. Files.newBufferedWriter(path, options). Files.write(path, bytes, options). Most devs skip them and get truncation by default. That default is dangerous. If you're writing logs, use APPEND. If you're writing a transaction record, use CREATE and WRITE — but never TRUNCATE_EXISTING unless you mean it. For temporary state, use DELETE_ON_CLOSE so temp files don't leak. The OS doesn't guess your intent. Stop guessing for it.

OpenOptionsExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial

import java.nio.file.*;
import java.io.IOException;

public class OpenOptionsExample {
    public static void main(String[] args) throws IOException {
        Path logPath = Path.of("app.log");

        // Append only — no truncation
        Files.writeString(logPath, "2025-03-15: Started\n",
            StandardOpenOption.CREATE, StandardOpenOption.APPEND);

        // Read them back to prove append works
        String content = Files.readString(logPath);
        System.out.println(content);

        // Now write with TRUNCATE_EXISTING — kills previous content
        Files.writeString(logPath, "Fresh start\n",
            StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
        System.out.println(Files.readString(logPath));
    }
}
Output
2025-03-15: Started
Fresh start
Production Trap:
Never use Files.write() without explicit OpenOptions in multi-writer scenarios. Default is WRITE + CREATE + TRUNCATE_EXISTING — it silently wipes concurrent writes from other processes.
Key Takeaway
Always pass explicit OpenOptions to NIO.2 write methods. Default truncation is a silent data loss bug waiting to happen.

FileVisitor: The Traversal Loop That Won't Fall Over on a 10M File Directory

Walking a file tree with recursion or Files.list() is fine for your toy dev machine. In production with 500,000 files across 200 subdirectories, you blow the stack or hit 'Too many open files'. FileVisitor is the production-grade solution. It walks the tree depth-first but without holding every path in memory — and gives you hooks for pre-visit, post-visit, visit-failed, and visit-file.

You implement SimpleFileVisitor override visitFile() for your logic. Return FileVisitResult.CONTINUE to keep walking. Return SKIP_SUBTREE to prune bad directories. Return TERMINATE to bail on permissions error instead of crashing. You also control whether the walk follows symlinks (FOLLOW_LINKS — use with caution). The Files.walkFileTree() call is one line. Your visitor logic is decoupled, testable, and survives a 2 TB filesystem.

FileVisitorExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge — java tutorial

import java.nio.file.*;
import java.nio.file.attribute.*;
import java.io.IOException;

public class FileVisitorExample {
    public static void main(String[] args) throws IOException {
        Path root = Path.of("/tmp/data");

        Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                if (file.toString().endsWith(".log")) {
                    System.out.println("Processing: " + file);
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path file, IOException exc) {
                System.err.println("Skipping: " + file + " (" + exc.getMessage() + ")");
                return FileVisitResult.CONTINUE;
            }
        });
    }
}
Output
Processing: /tmp/data/app.log
Skipping: /tmp/data/secret (Permission denied)
Processing: /tmp/data/backup/old.log
Senior Shortcut:
Override visitFileFailed() to return CONTINUE. Otherwise a single unreadable file kills the entire traversal. Also, never use FOLLOW_LINKS on untrusted directories — it can create cycles that hang your JVM.
Key Takeaway
Use Files.walkFileTree() with a SimpleFileVisitor for any recursive file operation. It handles scale, errors, and memory pressure that naive recursion cannot.
● Production incidentPOST-MORTEMseverity: high

Half-Written Log Entry After JVM Crash

Symptom
After the application restarted, the log file contained a partial line (truncated JSON). The log parser threw 'Unexpected end of input' every time it reached that line.
Assumption
The team assumed that BufferedWriter writes were persisted to disk immediately after each call to write().
Root cause
BufferedWriter buffers data in memory. When the JVM crashed, the buffer was never flushed. The data that was 'written' but not yet flushed was lost forever. The file was left in an inconsistent state.
Fix
Switch to explicit flush() after each critical log entry (with a performance trade-off), or use an async logging framework (e.g., Log4j2) that acknowledges writes. More fundamentally, use the atomic write pattern for critical files: write to a temp file, flush, close, then rename.
Key lesson
  • Never assume buffered writes are durably persisted until close() or flush() returns.
  • On abnormal JVM termination, unflushed buffers are lost.
  • For critical data, consider forcing flush() after each write, or use FileChannel with force(true).
Production debug guideCommon symptoms, root causes, and immediate actions when Java file operations fail in production.3 entries
Symptom · 01
FileNotFoundException even though the file clearly exists
Fix
Print System.getProperty('user.dir') to see the JVM's working directory. Use absolute paths or verify relative path resolution. Also check file permissions (readable) and symlinks.
Symptom · 02
'Too many open files' IOException in production logs
Fix
Check lsof -p <pid> or /proc/<pid>/fd on Linux. The culprit is usually an unclosed stream. Search for Files.lines() or new FileReader() without try-with-resources. Fix: wrap every resource in try-with-resources.
Symptom · 03
Mojibake characters: 'é' instead of 'é' in output
Fix
The charset mismatch. Always specify StandardCharsets.UTF_8 explicitly on FileReader/FileWriter. Never rely on the platform default. Use Files.readString() / Files.write() which default to UTF-8.
★ Quick Debug Cheat Sheet for Java File I/OFast commands and checks to diagnose the most common file-related production issues.
FileNotFoundException
Immediate action
Print user.dir and list directory contents
Commands
System.out.println(System.getProperty("user.dir"));
Files.list(Paths.get(".")).forEach(System.out::println);
Fix now
Use absolute path or correct relative path based on actual working directory.
Too many open files+
Immediate action
Check OS file descriptor limit and JVM open file count
Commands
ulimit -n (on Linux) or lsof -p $(pgrep java) | wc -l
jcmd <pid> VM.native_memory summary scale=MB
Fix now
Identify unclosed streams via code review; use try-with-resources on all I/O objects.
File truncated or missing data on crash+
Immediate action
Examine the file's last bytes with hexdump
Commands
tail -c 100 file.log | xxd
od -c file.log | tail
Fix now
Add explicit flush() after critical writes, or switch to FileChannel with force(true).
java.io vs java.nio.file
Aspectjava.io (Classic API)java.nio.file (Modern NIO.2)
Introduced inJava 1.0Java 7
Main classesFile, FileReader, FileWriter, BufferedReaderFiles, Paths, Path
Read entire file in one callNot possible — must loopFiles.readAllLines() or Files.readString() (Java 11+)
Atomic move/renamefile.renameTo() — unreliable across platformsFiles.move() with ATOMIC_MOVE — reliable
Stream-based readingManual while-loop with readLine()Files.lines() returns Stream<String>
Resource managementManual try-finally or try-with-resourcestry-with-resources (same, but less boilerplate with utility methods)
Exception specificityGeneric IOException for most operationsMore specific: NoSuchFileException, AccessDeniedException, etc.
Walk a directory treeRecursive manual implementation neededFiles.walk() or Files.walkFileTree()
Best use caseLow-level stream control, legacy codebasesNew code — cleaner, safer, more powerful

Key takeaways

1
java.io.File is just a path reference
it never opens a file. Use it for existence checks and metadata before committing to I/O operations.
2
Always wrap streams in try-with-resources. Every open stream is an OS file handle, and handles are a finite resource. Resource leaks cause 'Too many open files' errors that only appear under production load.
3
Files.lines() is lazy and memory-efficient for large files, but it MUST be closed explicitly
it holds a live file handle under the hood. Files.readAllLines() loads everything into memory — only use it for small files.
4
The atomic write pattern (write to temp → rename) is the professional-grade way to update any critical file. It guarantees readers never see a partially-written state, even if your process crashes mid-write.
5
Binary files need InputStream/OutputStream, not Reader/Writer. Mixing them up causes silent data corruption that is nearly impossible to detect without manual binary inspection.

Common mistakes to avoid

4 patterns
×

Not specifying a charset when creating FileReader/FileWriter

Symptom
Mojibake characters (e.g., 'é' instead of 'é') when files contain non-ASCII characters. Code that works on your machine breaks on a server with a different default locale.
Fix
Always explicitly pass StandardCharsets.UTF_8: new FileReader(path, StandardCharsets.UTF_8) or use Files.readAllLines(path, StandardCharsets.UTF_8). Never rely on the platform's default charset.
×

Forgetting the append flag on FileWriter and wiping existing data

Symptom
Every time your logging method runs, the log file resets to just the latest entry; all history is gone.
Fix
Use new FileWriter(logFilePath, true) — the boolean 'true' is the append flag. If you want to overwrite intentionally, be explicit in a comment so future-you knows it's on purpose.
×

Not wrapping Files.lines() in try-with-resources

Symptom
File handle leak that eventually causes 'Too many open files' IOException in production, which is nearly impossible to reproduce locally.
Fix
Always use try (Stream<String> lines = Files.lines(path)) { ... }. The Stream is AutoCloseable and holds an OS file handle that must be explicitly released.
×

Using Reader/Writer classes for binary files

Symptom
Silent corruption of binary data (images, ZIPs) — bytes are decoded as characters, modified, and re-encoded; file becomes unreadable.
Fix
Use InputStream/OutputStream for any file that is not guaranteed to be text. Never wrap binary streams in InputStreamReader or OutputStreamWriter.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the difference between FileReader and BufferedReader? Why would y...
Q02SENIOR
Explain how you'd safely update a configuration file in a running applic...
Q03SENIOR
When would you choose Files.readAllLines() over Files.lines(), and what ...
Q04SENIOR
What happens if you forget to specify a charset when reading a text file...
Q05SENIOR
How does FileChannel.transferTo() achieve better performance for copying...
Q01 of 05JUNIOR

What's the difference between FileReader and BufferedReader? Why would you never use FileReader alone in production code?

ANSWER
FileReader reads one character at a time from disk, which is very slow. BufferedReader wraps FileReader with an internal buffer (default 8KB) that reads a large chunk from disk into memory, then serves characters from that buffer. In production, you almost always use BufferedReader because disk I/O is orders of magnitude slower than memory access. Without buffering, a 100MB file can take minutes instead of seconds.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between FileInputStream and FileReader in Java?
02
How do I read a file line by line in Java without loading it all into memory?
03
Why does my Java program throw FileNotFoundException even though the file clearly exists?
04
What is the safe way to write a file that multiple threads or processes read?
05
Can I use FileWriter to write to a file that is opened by another process?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Java I/O. Mark it forged?

9 min read · try the examples if you haven't

Previous
CountDownLatch and CyclicBarrier
1 / 8 · Java I/O
Next
FileReader and FileWriter in Java