Skip to main content
Java · lesson 31 of 66
Open the Java page →
Java intermediate Lesson 31 of 66

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.

By SkillByExample · Updated

Java has two file APIs: the classic java.io streams and readers, and the modern java.nio.file (NIO.2) Path and Files classes from Java 7. Prefer NIO.2 for new code; you will still meet java.io everywhere. Each example is a single file run with the JDK 21 source launcher (java File.java), creates the files it needs in the current directory, and some runs are followed by shell commands that show what ended up on disk.

Writing and reading a whole file

Files.writeString and Files.readString handle small files in one call each.

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

public class WholeFile {
    public static void main(String[] args) throws IOException {
        Path notes = Path.of("notes.txt");
        Files.writeString(notes, "first line\nsecond line\n");
        Files.writeString(notes, "third line\n", StandardOpenOption.APPEND);

        String text = Files.readString(notes);
        List<String> lines = Files.readAllLines(notes);
        System.out.print(text);
        System.out.println(lines.size() + " lines, " + Files.size(notes) + " bytes");
    }
}
$ java WholeFile.java
first line
second line
third line
3 lines, 34 bytes
$ cat notes.txt
first line
second line
third line

Without APPEND, the second write would have replaced the file. readAllLines strips the line terminators, so the list holds three strings. Both methods read the entire file into memory — fine for config files, wrong for gigabyte logs.

Missing files fail loudly

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

public class Missing {
    public static void main(String[] args) {
        try {
            Files.readString(Path.of("nope.txt"));
        } catch (NoSuchFileException e) {
            System.out.println("no such file: " + e.getFile());
        } catch (IOException e) {
            System.out.println("other I/O problem: " + e);
        }
        System.out.println("exists? " + Files.exists(Path.of("nope.txt")));
    }
}
$ java Missing.java
no such file: nope.txt
exists? false

NIO.2 throws specific subclasses of IOException — NoSuchFileException, FileAlreadyExistsException, AccessDeniedException — so you can react to each. The old java.io.File.delete() simply returns false and leaves you guessing why.

Line by line with BufferedReader

For large files, read one line at a time so memory use stays constant.

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

public class LineByLine {
    public static void main(String[] args) throws IOException {
        Files.writeString(Path.of("app.log"), "INFO start\nERROR disk full\nINFO retry\nERROR timeout\n");

        int lineNo = 0;
        try (BufferedReader reader = Files.newBufferedReader(Path.of("app.log"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                lineNo++;
                if (line.startsWith("ERROR")) System.out.println(lineNo + ": " + line);
            }
        }
        System.out.println("read " + lineNo + " lines");
    }
}
$ java LineByLine.java
2: ERROR disk full
4: ERROR timeout
read 4 lines

readLine() returns null at end of file, which ends the loop. Files.newBufferedReader is the NIO.2 equivalent of new BufferedReader(new FileReader(...)), and try-with-resources closes it.

Lazy streams with Files.lines

Files.lines returns a Stream<String> that reads as you consume it. It holds an open file handle, so it belongs in try-with-resources too.

import java.io.IOException;
import java.nio.file.*;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class LogStats {
    public static void main(String[] args) throws IOException {
        Files.writeString(Path.of("app.log"), "INFO start\nERROR disk full\nINFO retry\nWARN slow\nERROR timeout\n");
        try (Stream<String> lines = Files.lines(Path.of("app.log"))) {
            Map<String, Long> byLevel = lines
                .map(l -> l.split(" ", 2)[0])
                .collect(Collectors.groupingBy(level -> level, TreeMap::new, Collectors.counting()));
            System.out.println(byLevel);
        }
    }
}
$ java LogStats.java
{ERROR=2, INFO=2, WARN=1}

Only one line is in memory at a time; the counting happens as lines stream past.

Writing with BufferedWriter and PrintWriter

BufferedWriter batches writes; PrintWriter adds printf. Data reaches the file when the writer is flushed or closed.

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

public class Writers {
    public static void main(String[] args) throws IOException {
        try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of("report.txt")))) {
            out.println("=== Report ===");
            out.printf("%-8s %6.2f%n", "coffee", 4.2);
            out.printf("%-8s %6.2f%n", "lunch", 12.5);
        }

        PrintWriter leaky = new PrintWriter(new FileWriter("unflushed.txt"));
        leaky.println("this never reaches the disk");
        System.out.println("unflushed.txt size: " + Files.size(Path.of("unflushed.txt")));
    }
}
$ java Writers.java
unflushed.txt size: 0
$ cat report.txt
=== Report ===
coffee     4.20
lunch     12.50

report.txt is complete because try-with-resources closed (and flushed) the writer. leaky was never closed, so its text sat in a buffer and the file is still empty — a classic “my file is blank” bug.

Binary data with streams

Bytes, not characters: images, archives, anything that isn’t text. Read into a buffer rather than one byte at a time.

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

public class Binary {
    public static void main(String[] args) throws IOException {
        byte[] data = new byte[10_000];
        for (int i = 0; i < data.length; i++) data[i] = (byte) i;
        Files.write(Path.of("blob.bin"), data);

        long total = 0;
        int chunks = 0;
        try (InputStream in = new FileInputStream("blob.bin")) {
            byte[] buffer = new byte[4096];
            int n;
            while ((n = in.read(buffer)) != -1) {
                chunks++;
                total += n;
                System.out.println("chunk " + chunks + ": " + n + " bytes");
            }
        }
        System.out.println("total " + total + ", first bytes " + data[0] + " " + data[255] + " " + data[256]);
    }
}
$ java Binary.java
chunk 1: 4096 bytes
chunk 2: 4096 bytes
chunk 3: 1808 bytes
total 10000, first bytes 0 -1 0

read(buffer) returns how many bytes it filled — the last chunk is short — and -1 at the end. Java’s byte is signed, so 255 is stored as -1 and 256 wraps to 0.

Paths, directories, copy, move, delete

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

public class PathOps {
    public static void main(String[] args) throws IOException {
        Path p = Path.of("data", "2026", "..", "users.txt");
        System.out.println(p + " -> " + p.normalize() + ", file name " + p.getFileName());

        Files.createDirectories(Path.of("data/archive"));
        Files.writeString(Path.of("data/users.txt"), "alice\nbob\n");
        Files.copy(Path.of("data/users.txt"), Path.of("data/archive/users.txt"), StandardCopyOption.REPLACE_EXISTING);
        Files.move(Path.of("data/users.txt"), Path.of("data/users.old"), StandardCopyOption.REPLACE_EXISTING);

        try (var entries = Files.walk(Path.of("data"))) {
            entries.sorted().forEach(e -> System.out.println((Files.isDirectory(e) ? "dir  " : "file ") + e));
        }
        System.out.println("deleted? " + Files.deleteIfExists(Path.of("data/users.txt")));
    }
}
$ java PathOps.java
data/2026/../users.txt -> data/users.txt, file name users.txt
dir  data
dir  data/archive
file data/archive/users.txt
file data/users.old
deleted? false

normalize() removes the 2026/.. detour. createDirectories makes every missing parent and doesn’t fail if they exist. Files.walk visits the whole tree as a stream; sorting it makes the listing stable, because directory order is up to the file system. deleteIfExists returned false because users.txt had already been moved.

Project: expense tracker with CSV persistence

The tracker loads expenses.csv if it exists, adds three expenses, saves, and prints totals. Running it twice shows that the state really lives on disk.

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

public class ExpenseTracker {
    record Expense(String category, String description, double amount) {
        String toCsv() { return category + "," + description + "," + amount; }
        static Expense fromCsv(String line) {
            String[] p = line.split(",");
            return new Expense(p[0], p[1], Double.parseDouble(p[2]));
        }
    }

    public static void main(String[] args) throws IOException {
        Path file = Path.of("expenses.csv");
        List<Expense> expenses = new ArrayList<>();
        if (Files.exists(file)) Files.readAllLines(file).stream().skip(1).map(Expense::fromCsv).forEach(expenses::add);

        expenses.add(new Expense("Food", "Lunch", 12.50));
        expenses.add(new Expense("Transport", "Bus", 2.80));
        expenses.add(new Expense("Food", "Coffee", 4.20));

        List<String> lines = new ArrayList<>(List.of("category,description,amount"));
        expenses.forEach(e -> lines.add(e.toCsv()));
        Files.write(file, lines);

        Map<String, Double> totals = new TreeMap<>();
        expenses.forEach(e -> totals.merge(e.category(), e.amount(), Double::sum));
        System.out.println(expenses.size() + " expenses " + totals);
    }
}
$ java ExpenseTracker.java
3 expenses {Food=16.7, Transport=2.8}
$ java ExpenseTracker.java
6 expenses {Food=33.4, Transport=5.6}
$ head -3 expenses.csv
category,description,amount
Food,Lunch,12.5
Transport,Bus,2.8

The second run loaded the three rows the first run saved and added three more. Delete expenses.csv and the first run’s totals come back.

Practice

  1. A description like Lunch, with client breaks fromCsv. Reproduce the failure, then fix it so that only the description may contain commas — and say why split(",", 3) alone isn’t enough.
Solution
public class CsvComma {
    public static void main(String[] args) {
        String line = "Food,Lunch, with client,12.5";
        String[] naive = line.split(",");
        System.out.println(naive.length + " fields: " + String.join(" | ", naive));
        try {
            Double.parseDouble(naive[2]);
        } catch (NumberFormatException e) {
            System.out.println("naive parse fails: " + e.getMessage());
        }
        int last = line.lastIndexOf(',');
        String[] head = line.substring(0, last).split(",", 2);
        System.out.println(head[0] + " | " + head[1] + " | " + Double.parseDouble(line.substring(last + 1)));
    }
}
$ java CsvComma.java
4 fields: Food | Lunch |  with client | 12.5
naive parse fails: For input string: "with client"
Food | Lunch, with client | 12.5

split(",", 3) alone would put " with client,12.5" in the third field because the comma is in the middle column. Taking the amount from the last comma and limiting the rest to 2 fields works as long as only the description contains commas; real CSV needs quoting and a CSV library.

  1. Make the tracker append only the new rows with StandardOpenOption.APPEND instead of rewriting the file, writing the header only when the file is new.
Solution
import java.io.IOException;
import java.nio.file.*;
import java.util.List;

public class AppendTracker {
    public static void main(String[] args) throws IOException {
        Path file = Path.of("expenses2.csv");
        if (Files.notExists(file)) Files.writeString(file, "category,description,amount\n");
        Files.write(file, List.of("Food,Lunch,12.5", "Transport,Bus,2.8"), StandardOpenOption.APPEND);
        System.out.println(Files.readAllLines(file).size() - 1 + " rows");
    }
}
$ java AppendTracker.java
2 rows
$ java AppendTracker.java
4 rows

Appending writes only the new bytes, so the cost no longer grows with the file. The trade-off: you can no longer edit or delete earlier rows without rewriting.

  1. Count the .java files under a directory tree with Files.walk. Create a few first so the answer is predictable.
Solution
import java.io.IOException;
import java.nio.file.*;
import java.util.stream.Stream;

public class CountJava {
    public static void main(String[] args) throws IOException {
        Files.createDirectories(Path.of("proj/src/util"));
        for (String f : new String[] {"proj/src/App.java", "proj/src/util/Strings.java", "proj/README.md"}) {
            Files.writeString(Path.of(f), "");
        }
        try (Stream<Path> paths = Files.walk(Path.of("proj"))) {
            System.out.println(paths.filter(p -> p.toString().endsWith(".java")).count() + " .java files");
        }
    }
}
$ java CountJava.java
2 .java files

Files.walk descends into util automatically; README.md is filtered out.

  1. Copy blob.bin-style binary data with InputStream.transferTo and verify the copy is byte-for-byte identical with Files.mismatch.
Solution
import java.io.*;
import java.nio.file.*;

public class CopyBytes {
    public static void main(String[] args) throws IOException {
        byte[] data = new byte[5000];
        new java.util.Random(1).nextBytes(data);
        Files.write(Path.of("in.bin"), data);
        try (InputStream in = new FileInputStream("in.bin"); OutputStream out = new FileOutputStream("out.bin")) {
            System.out.println("copied " + in.transferTo(out) + " bytes");
        }
        System.out.println("first difference at: " + Files.mismatch(Path.of("in.bin"), Path.of("out.bin")));
    }
}
$ java CopyBytes.java
copied 5000 bytes
first difference at: -1

transferTo does the buffer loop for you, and Files.mismatch returns -1 when two files are identical.

Frequently Asked Questions

What is the difference between FileReader and BufferedReader?
FileReader reads characters straight from the file, so many small reads mean many system calls. BufferedReader wraps a Reader, pulls large chunks into memory, and serves characters and whole lines from that buffer, which is much faster and adds readLine().
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.
Which character encoding does Java use when reading files?
The Files methods such as readString, readAllLines and newBufferedReader default to UTF-8. Since Java 18 the platform default charset is also UTF-8 for FileReader and FileWriter. To read a file in another encoding, pass the Charset explicitly, for example StandardCharsets.ISO_8859_1.