Skip to main content
Java intermediate Lesson 31 of 58

File Handling in Java

Read and write files in Java using the File API, BufferedReader/Writer, FileInputStream/OutputStream, and the modern NIO.2 Path/Files API.

Java provides two generations of file I/O APIs: the classic java.io package and the modern java.nio.file (NIO.2) package introduced in Java 7. Both are in active use — NIO.2 is preferred for new code because its methods are more expressive, its error messages are clearer, and it handles edge cases like symbolic links and atomic moves more robustly.

The File Class (Legacy)

java.io.File represents a file path as an object. It does not read or write content directly — it is purely about the path itself and the metadata of what exists at that path. You will encounter it in older codebases and APIs that predate NIO.2.

import java.io.File;

File file = new File("data.txt");
File dir  = new File("src/main/resources");

// Check existence and type before operating on a file
System.out.println(file.exists());       // true/false
System.out.println(file.isFile());       // true if it's a regular file
System.out.println(file.isDirectory());  // true if it's a directory

// File metadata
System.out.println(file.getName());         // "data.txt"
System.out.println(file.getAbsolutePath()); // full path from filesystem root
System.out.println(file.length());          // size in bytes
System.out.println(file.lastModified());    // timestamp in milliseconds

// Create and delete
file.createNewFile(); // creates empty file if it doesn't exist
file.delete();        // deletes the file

// Directory operations
dir.mkdirs();                      // creates directory and all missing parents
String[] entries = dir.list();     // names of contents (strings only)
File[]   files   = dir.listFiles(); // contents as File objects (useful for filtering)

Reading Files

BufferedReader — Line by Line

Reading character by character is extremely slow. BufferedReader reads a large chunk of the file into an internal buffer and then serves lines from that buffer, making it orders of magnitude faster. Always use try-with-resources to ensure the reader is closed even if an exception occurs mid-read.

import java.io.*;

// try-with-resources: reader is automatically closed when the block exits
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    System.err.println("Failed to read file: " + e.getMessage());
}

Reading All Lines at Once (NIO.2)

For smaller files where you want the whole content in memory, NIO.2 provides one-liner methods that are cleaner than managing a reader loop. Files.lines() is the lazy alternative for large files — it streams lines without loading them all at once.

import java.nio.file.*;
import java.util.List;

// Read all lines into a List — simple but loads the entire file into memory
List<String> lines = Files.readAllLines(Path.of("data.txt"));
lines.forEach(System.out::println);

// Read entire file as one String — Java 11+
String content = Files.readString(Path.of("data.txt"));
System.out.println(content);

// Stream lines lazily — good for large files; only processes lines you consume
try (var stream = Files.lines(Path.of("data.txt"))) {
    stream.filter(l -> l.contains("error"))
          .forEach(System.out::println);
}

FileInputStream — Reading Binary Data

Text methods assume a character encoding. For binary data — images, PDFs, ZIP files — use FileInputStream and work with raw bytes. Reading into a buffer avoids per-byte overhead.

try (FileInputStream fis = new FileInputStream("image.png")) {
    byte[] buffer = new byte[4096]; // read 4KB at a time
    int bytesRead;
    while ((bytesRead = fis.read(buffer)) != -1) {
        // process buffer[0..bytesRead-1] — bytesRead may be less than buffer.length on the last chunk
        System.out.println("Read " + bytesRead + " bytes");
    }
}

Writing Files

BufferedWriter

Just as BufferedReader buffers reads, BufferedWriter batches writes into a buffer before flushing to disk, which is significantly faster than writing character by character. Pass true as the second argument to FileWriter to append rather than overwrite.

// Overwrite (or create) the file
try (BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))) {
    writer.write("Line 1");
    writer.newLine();   // platform-safe newline (\n on Unix, \r\n on Windows)
    writer.write("Line 2");
    writer.newLine();
}

// Append to an existing file — pass true to FileWriter
try (BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt", true))) {
    writer.write("Line 3 (appended)");
    writer.newLine();
}

PrintWriter — Formatted Output

PrintWriter adds printf-style formatting on top of BufferedWriter. It is convenient when you need to write structured text like reports or CSVs.

try (PrintWriter pw = new PrintWriter(new FileWriter("report.txt"))) {
    pw.println("=== Report ===");
    pw.printf("Total: %d items%n", 42);
    pw.printf("Average: %.2f%n", 3.14);
}

Writing All Content at Once (NIO.2)

For simple write operations, NIO.2 one-liners are cleaner than managing a writer. StandardOpenOption flags give fine-grained control over whether to create, overwrite, or append.

import java.nio.file.*;
import java.util.List;

// Write a String — overwrites if the file exists
Files.writeString(Path.of("output.txt"), "Hello, file!");

// Write a list of lines — each element becomes one line
List<String> lines = List.of("Line 1", "Line 2", "Line 3");
Files.write(Path.of("lines.txt"), lines);

// Append — CREATE creates the file if it doesn't exist; APPEND adds to the end
Files.writeString(Path.of("log.txt"), "New entry\n",
    StandardOpenOption.APPEND, StandardOpenOption.CREATE);

NIO.2 Path and Files API

Path is the NIO.2 replacement for File. It is more expressive, handles platform path separators transparently, and supports operations like normalize() and resolve() that the old File class lacks. The companion Files class provides static methods for all common file operations.

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

// Path.of() builds a path safely using the platform separator
Path path = Path.of("data", "users.txt");

// Path information
System.out.println(path.getFileName());    // "users.txt"
System.out.println(path.getParent());      // "data"
System.out.println(path.toAbsolutePath()); // full absolute path
System.out.println(path.normalize());      // resolves . and .. segments

// Existence and type checks
Files.exists(path)
Files.isRegularFile(path)
Files.isDirectory(path)
Files.isReadable(path)
Files.isWritable(path)

// Creating files and directories
Files.createFile(path);                          // create empty file; throws if exists
Files.createDirectories(Path.of("a/b/c"));      // create all missing parent directories

// Copying and moving — REPLACE_EXISTING prevents failure if destination exists
Files.copy(Path.of("src.txt"), Path.of("dst.txt"), StandardCopyOption.REPLACE_EXISTING);
Files.move(Path.of("old.txt"), Path.of("new.txt"), StandardCopyOption.REPLACE_EXISTING);

// Deleting
Files.delete(path);         // throws NoSuchFileException if not found
Files.deleteIfExists(path); // silent if not found — safer for cleanup code

// Metadata
long size = Files.size(path);
var lastModified = Files.getLastModifiedTime(path);

Walking Directory Trees

NIO.2 makes recursive directory operations trivial. Files.walk() returns a lazy Stream<Path>, so you can filter and process with standard stream operations rather than writing recursive loops.

// List the immediate contents of a directory
try (var stream = Files.list(Path.of("src"))) {
    stream.forEach(System.out::println);
}

// Walk recursively — returns every file and directory under the root
try (var stream = Files.walk(Path.of("src"))) {
    stream.filter(Files::isRegularFile)
          .filter(p -> p.toString().endsWith(".java"))
          .forEach(System.out::println);
}

// Find files matching attributes — more efficient than walk + filter for deep trees
try (var stream = Files.find(Path.of("src"), 10,
        (p, attrs) -> attrs.isRegularFile() && p.toString().endsWith(".java"))) {
    stream.forEach(System.out::println);
}

Practical Patterns

Reading a CSV File

A reusable CSV reader that maps headers to values, handling variable numbers of columns cleanly.

import java.nio.file.*;
import java.util.*;

public static List<Map<String, String>> readCsv(String filePath) throws IOException {
    List<String> lines = Files.readAllLines(Path.of(filePath));
    if (lines.isEmpty()) return List.of();

    String[] headers = lines.get(0).split(",");
    List<Map<String, String>> records = new ArrayList<>();

    for (int i = 1; i < lines.size(); i++) {
        String[] values = lines.get(i).split(",");
        Map<String, String> row = new LinkedHashMap<>(); // LinkedHashMap preserves column order
        for (int j = 0; j < headers.length; j++) {
            row.put(headers[j].trim(), j < values.length ? values[j].trim() : "");
        }
        records.add(row);
    }
    return records;
}

Writing a Log File

A simple append-based file logger using NIO.2. The CREATE option ensures the file is created on first use.

import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class FileLogger {
    private final Path logFile;
    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public FileLogger(String filePath) { this.logFile = Path.of(filePath); }

    public void log(String level, String message) throws IOException {
        String entry = String.format("[%s] %s %s%n", FMT.format(LocalDateTime.now()), level, message);
        // APPEND + CREATE: adds to existing file, or creates it if it doesn't exist
        Files.writeString(logFile, entry, StandardOpenOption.APPEND, StandardOpenOption.CREATE);
    }
}

Project: Expense Tracker

This project combines NIO.2 file I/O with a CSV persistence layer. The tracker loads existing expenses on startup, appends new ones, and computes totals by category — all using the cleaner NIO.2 API throughout.

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.time.LocalDate;

public class ExpenseTracker {

    record Expense(LocalDate date, String category, String description, double amount) {
        // Serialise to a CSV line
        String toCsv() { return date + "," + category + "," + description + "," + amount; }

        // Deserialise from a CSV line — limit to 4 fields so description can contain commas
        static Expense fromCsv(String line) {
            String[] p = line.split(",", 4);
            return new Expense(LocalDate.parse(p[0]), p[1], p[2], Double.parseDouble(p[3]));
        }
    }

    private final Path dataFile;
    private final List<Expense> expenses = new ArrayList<>();

    public ExpenseTracker(String dataFilePath) throws IOException {
        this.dataFile = Path.of(dataFilePath);
        if (Files.exists(dataFile)) {
            Files.readAllLines(dataFile).stream()
                 .skip(1)  // skip the CSV header row
                 .map(Expense::fromCsv)
                 .forEach(expenses::add);
        }
    }

    public void add(Expense expense) throws IOException {
        expenses.add(expense);
        save(); // persist after every addition
    }

    private void save() throws IOException {
        List<String> lines = new ArrayList<>();
        lines.add("date,category,description,amount"); // header row
        expenses.stream().map(Expense::toCsv).forEach(lines::add);
        Files.write(dataFile, lines); // overwrites entire file
    }

    public Map<String, Double> totalByCategory() {
        Map<String, Double> totals = new TreeMap<>(); // TreeMap keeps categories sorted
        for (Expense e : expenses) {
            totals.merge(e.category(), e.amount(), Double::sum);
        }
        return totals;
    }

    public static void main(String[] args) throws IOException {
        ExpenseTracker tracker = new ExpenseTracker("expenses.csv");
        tracker.add(new Expense(LocalDate.now(), "Food",      "Lunch",  12.50));
        tracker.add(new Expense(LocalDate.now(), "Transport", "Bus",     2.80));
        tracker.add(new Expense(LocalDate.now(), "Food",      "Coffee",  4.20));

        System.out.println("Expenses by category:");
        tracker.totalByCategory().forEach((cat, total) ->
            System.out.printf("  %-15s $%.2f%n", cat, total));
    }
}

Frequently Asked Questions

What is the difference between FileReader and BufferedReader?
FileReader reads one character at a time from a file — very slow. BufferedReader wraps FileReader and reads large chunks into memory, then serves characters from that buffer. Always wrap FileReader in BufferedReader for performance.
Should I use the old File API or the new NIO.2 Path API?
Prefer the NIO.2 API (java.nio.file.Path and Files) for new code — it has cleaner methods, better error messages, and handles edge cases more robustly. The old File class is still widely used in legacy code, so you should know both.
How do I make sure files are always closed even if an exception occurs?
Use try-with-resources. Any class that implements AutoCloseable (FileReader, BufferedReader, FileInputStream, etc.) will be automatically closed when the try block exits, whether normally or via exception.