I/O #

Almost every real application needs to interact with the outside world — reading configuration from a file, saving reports to disk, importing CSV data, or writing logs. Java has two generations of APIs for this: java.io, which has existed since the beginning, and java.nio.file (NIO.2), introduced in Java 7 as a major improvement. Both are still relevant and often used together. This article covers how to read and write text and binary files, when to use byte-based vs character-based streams, how NIO.2 simplifies file operations that used to be verbose, and how to store Java objects to disk via serialization.

Overview #

Java has two API layers for file I/O:

APIPackageIntroducedAdvantages
Classic I/Ojava.ioJava 1.0Familiar, stream-based, good for large data
NIO.2java.nio.fileJava 7More concise, Files utility, metadata support

For everyday file operations — read, write, copy, delete — NIO.2 (Files + Path) is the more modern and concise choice. Classic I/O is still needed for streaming large data, serialization, and classes like BufferedReader/BufferedWriter that are often used together with NIO.2.

flowchart TD
    A[Data Source] --> B{Data type?}
    B -- "Binary\n(image, PDF, ZIP)" --> C["Byte-Based I/O\nInputStream / OutputStream"]
    B -- "Text\n(txt, csv, log)" --> D["Character-Based I/O\nReader / Writer"]
    C --> E["FileInputStream\nFileOutputStream\nBufferedInputStream"]
    D --> F["FileReader / FileWriter\nBufferedReader / BufferedWriter\nFiles.readString() / writeString()"]
Always use try-with-resources (try (Resource r = ...)) when working with streams and files. This ensures the stream is always closed when done — even when an exception occurs — without needing a manual finally block.

NIO.2 — The Modern Way to Work with Files #

java.nio.file introduces Path (a representation of a file/directory path) and Files (a utility class with static methods for almost every file operation). Many operations that needed dozens of lines in java.io can be done in one line with Files.

Path — Representing a File Location #

import java.nio.file.Path;
import java.nio.file.Paths;

// Creating a Path
Path file    = Path.of("data/report.txt");          // Java 11+
Path fileOld = Paths.get("data", "report.txt");     // Java 7+, equivalent

// Path navigation
Path dir     = file.getParent();       // data
Path name    = file.getFileName();     // report.txt
Path abs     = file.toAbsolutePath();  // /home/user/project/data/report.txt

// Join paths safely (not string concatenation)
Path subdir  = Path.of("data").resolve("2025").resolve("report.txt");
// data/2025/report.txt

// Relativize: calculate the relative path between two paths
Path from  = Path.of("/home/user/project");
Path to    = Path.of("/home/user/project/data/report.txt");
Path rel   = from.relativize(to); // data/report.txt

Files — File Operations in One Line #

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.io.IOException;

Path src  = Path.of("source.txt");
Path dst  = Path.of("destination.txt");
Path dir  = Path.of("new-directory");

// Existence checks
boolean exists      = Files.exists(src);
boolean isDir = Files.isDirectory(dir);
boolean readable = Files.isReadable(src);

// Metadata
long size      = Files.size(src);            // in bytes
var modifiedTime    = Files.getLastModifiedTime(src);

// Create and delete
Files.createFile(Path.of("new.txt"));         // create an empty file
Files.createDirectory(dir);                    // create a single directory
Files.createDirectories(Path.of("a/b/c"));     // create nested directories at once
Files.delete(src);                             // delete, throws an exception if missing
Files.deleteIfExists(src);                     // delete if present, silent if not

// Copy and move
Files.copy(src, dst);                          // copy
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING); // overwrite if present
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING); // move/rename

Reading Text Files #

For text files, Java provides several ways with different trade-offs — choose based on file size and needs.

Reading an Entire File at Once (Small Files) #

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.List;

Path file = Path.of("config.txt");

// Read the entire contents as a String — Java 11+
String contents = Files.readString(file);                          // UTF-8 default
String latinContents = Files.readString(file, StandardCharsets.ISO_8859_1);

// Read as a List<String> (one element per line)
List<String> lines = Files.readAllLines(file);
lines.forEach(System.out::println);

// Read as a byte array (for small binary files)
byte[] bytes = Files.readAllBytes(file);
readString() and readAllLines() read the entire file into memory at once. For large files (hundreds of MB or more), use BufferedReader with streaming so you don’t run out of heap.

Reading Line by Line with BufferedReader #

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

Path log = Path.of("server.log");

// try-with-resources: BufferedReader is closed automatically
try (BufferedReader br = Files.newBufferedReader(log)) {
    String line;
    int number = 1;
    while ((line = br.readLine()) != null) {
        System.out.printf("%4d: %s%n", number++, line);
    }
} catch (IOException e) {
    System.err.println("Failed to read: " + e.getMessage());
}

Reading with a Stream (Java 8+) #

import java.nio.file.Files;
import java.util.stream.Stream;

Path log = Path.of("server.log");

// Files.lines() returns a Stream<String> — lazy, lines are read one at a time
try (Stream<String> stream = Files.lines(log)) {
    long errorCount = stream
        .filter(l -> l.contains("ERROR"))
        .count();
    System.out.println("Number of ERROR lines: " + errorCount);
} catch (IOException e) {
    e.printStackTrace();
}

// Extract all ERROR lines and store them in a list
try (Stream<String> stream = Files.lines(log)) {
    List<String> errors = stream
        .filter(l -> l.startsWith("ERROR"))
        .collect(java.util.stream.Collectors.toList());
}

Writing Text Files #

Writing at Once (Small Files) #

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

Path output = Path.of("result.txt");

// Write a String directly — Java 11+, overwrites if present
Files.writeString(output, "First line\nSecond line\n");

// Write with options: APPEND to add at the end of the file
Files.writeString(output, "Additional line\n", StandardOpenOption.APPEND);

// Write a List<String> (each element becomes one line)
List<String> lines = List.of("Apple", "Mango", "Orange");
Files.write(output, lines);

Writing with BufferedWriter #

For output generated incrementally — for example writing thousands of lines in a loop — BufferedWriter is far more efficient because it batches many small write operations into one large disk operation.

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

Path output = Path.of("report.csv");

// BufferedWriter — write line by line
try (BufferedWriter bw = Files.newBufferedWriter(output)) {
    bw.write("name,score,grade");
    bw.newLine();
    bw.write("Budi,85,A");
    bw.newLine();
    bw.write("Ani,72,B");
    bw.newLine();
} catch (IOException e) {
    e.printStackTrace();
}

// PrintWriter — an alternative with println() and format()
try (PrintWriter pw = new PrintWriter(Files.newBufferedWriter(output))) {
    pw.println("name,score,grade");
    pw.printf("%-10s,%3d,%s%n", "Budi", 85, "A");
    pw.printf("%-10s,%3d,%s%n", "Ani", 72, "B");
}

// Append to an existing file
try (BufferedWriter bw = Files.newBufferedWriter(output, StandardOpenOption.APPEND)) {
    bw.write("Citra,91,A+");
    bw.newLine();
}

Byte-Based I/O #

For binary data — images, PDFs, audio files, compressed data — use InputStream/OutputStream. These operations work at the byte level, not characters.

Reading Binary Files #

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

// Way 1: Files.readAllBytes() — for small files
byte[] image = Files.readAllBytes(Path.of("photo.jpg"));
System.out.println("Size: " + image.length + " bytes");

// Way 2: BufferedInputStream — for large files, read per chunk
Path input = Path.of("large-video.mp4");
try (BufferedInputStream bis = new BufferedInputStream(
        new FileInputStream(input.toFile()))) {

    byte[] buffer = new byte[8192]; // 8 KB per read
    int bytesRead;
    long totalBytes = 0;

    while ((bytesRead = bis.read(buffer)) != -1) {
        // process buffer[0..bytesRead-1]
        totalBytes += bytesRead;
    }
    System.out.println("Total read: " + totalBytes + " bytes");
} catch (IOException e) {
    e.printStackTrace();
}

Writing Binary Files #

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

// Way 1: Files.write() — for byte[] data already in memory
byte[] data = {0x48, 0x65, 0x6C, 0x6C, 0x6F}; // "Hello" in ASCII
Files.write(Path.of("output.bin"), data);

// Way 2: BufferedOutputStream — for streaming large data
Path output = Path.of("copy.mp4");
Path source  = Path.of("video.mp4");

try (BufferedInputStream  bis = new BufferedInputStream(new FileInputStream(source.toFile()));
     BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(output.toFile()))) {

    byte[] buffer = new byte[8192];
    int bytesRead;
    while ((bytesRead = bis.read(buffer)) != -1) {
        bos.write(buffer, 0, bytesRead);
    }
    System.out.println("Copy finished.");
} catch (IOException e) {
    e.printStackTrace();
}

// Way 3: Files.copy() — the most concise way to copy files
Files.copy(source, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING);

Object Serialization #

Serialization is the process of converting a Java object into a byte sequence that can be stored to a file or sent over a network, then restored back into an object (deserialization). A class must implement Serializable to be serializable.

Defining a Serializable Class #

import java.io.Serializable;

public class Product implements Serializable {
    // serialVersionUID: the class version identity for deserialization compatibility
    // Always define this explicitly
    private static final long serialVersionUID = 1L;

    private String name;
    private double price;
    private int stock;

    // Fields marked 'transient' will NOT be serialized
    private transient String temporaryCache;

    public Product(String name, double price, int stock) {
        this.name = name;
        this.price = price;
        this.stock = stock;
    }

    @Override
    public String toString() {
        return "Product{name='%s', price=%.2f, stock=%d}".formatted(name, price, stock);
    }
}

Writing Objects to a File #

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

List<Product> catalog = List.of(
    new Product("Laptop", 12_000_000, 5),
    new Product("Mouse", 150_000, 20),
    new Product("Keyboard", 450_000, 15)
);

Path file = Path.of("catalog.dat");

try (ObjectOutputStream oos = new ObjectOutputStream(
        new BufferedOutputStream(Files.newOutputStream(file)))) {
    oos.writeObject(catalog); // write the entire list at once
    System.out.println("Catalog saved.");
} catch (IOException e) {
    e.printStackTrace();
}

Reading Objects from a File #

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

Path file = Path.of("catalog.dat");

try (ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(Files.newInputStream(file)))) {

    @SuppressWarnings("unchecked")
    List<Product> catalog = (List<Product>) ois.readObject();

    catalog.forEach(System.out::println);

} catch (IOException | ClassNotFoundException e) {
    e.printStackTrace();
}
Java’s built-in serialization has several limitations: the format isn’t human-readable, it’s prone to version compatibility issues, and it can be a security hole if you accept serialized data from untrusted sources. For modern persistence needs, consider JSON (with Jackson or Gson) or a database.

Directory Operations #

Creating, Reading, and Deleting Directories #

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

Path dir = Path.of("data/archive/2025");

// Create a directory along with its parents at once
Files.createDirectories(dir);

// List directory contents (one level)
try (var stream = Files.list(Path.of("data"))) {
    stream.forEach(p -> System.out.println(p.getFileName()));
}

// List directory contents recursively (all subdirectories)
try (var stream = Files.walk(Path.of("data"))) {
    stream
        .filter(Files::isRegularFile)
        .filter(p -> p.toString().endsWith(".txt"))
        .forEach(System.out::println);
}

// Delete an empty directory
Files.delete(dir);

// Delete a directory along with all its contents (recursive)
Files.walkFileTree(Path.of("data"), new SimpleFileVisitor<>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        Files.delete(file);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
        Files.delete(d);
        return FileVisitResult.CONTINUE;
    }
});

Finding Files with glob #

// Find all .java files across the whole project
try (var stream = Files.find(Path.of("src"), Integer.MAX_VALUE,
        (path, attrs) -> attrs.isRegularFile() && path.toString().endsWith(".java"))) {
    stream.forEach(System.out::println);
}

// Use a PathMatcher with a glob pattern
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:**.{java,kt}");
try (var stream = Files.walk(Path.of("src"))) {
    stream
        .filter(p -> matcher.matches(p))
        .forEach(System.out::println);
}

Real-World Cases #

Reading a CSV File #

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;

record Student(String name, String major, double gpa) {}

public static List<Student> readCSV(Path file) throws IOException {
    try (var lines = Files.lines(file)) {
        return lines
            .skip(1) // skip the header
            .filter(l -> !l.isBlank())
            .map(l -> {
                String[] columns = l.split(",");
                return new Student(
                    columns[0].trim(),
                    columns[1].trim(),
                    Double.parseDouble(columns[2].trim())
                );
            })
            .collect(Collectors.toList());
    }
}

// CSV: name,major,gpa
// Budi,Informatics,3.8
// Ani,Mathematics,3.5
List<Student> data = readCSV(Path.of("students.csv"));
data.forEach(System.out::println);

Writing a Report to a File #

import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public static void writeReport(Path output, List<Student> data) throws IOException {
    DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");

    try (PrintWriter pw = new PrintWriter(Files.newBufferedWriter(output))) {
        pw.println("=== STUDENT REPORT ===");
        pw.println("Printed: " + LocalDateTime.now().format(fmt));
        pw.println("-".repeat(40));
        pw.printf("%-20s %-15s %s%n", "Name", "Major", "GPA");
        pw.println("-".repeat(40));

        data.forEach(s ->
            pw.printf("%-20s %-15s %.2f%n", s.name(), s.major(), s.gpa())
        );

        pw.println("-".repeat(40));
        double avgGpa = data.stream().mapToDouble(Student::gpa).average().orElse(0);
        pw.printf("Average GPA: %.2f%n", avgGpa);
    }
}

Copying All Files from One Directory to Another #

public static void copyAll(Path src, Path dst) throws IOException {
    Files.createDirectories(dst);

    try (var stream = Files.list(src)) {
        stream
            .filter(Files::isRegularFile)
            .forEach(file -> {
                try {
                    Files.copy(file, dst.resolve(file.getFileName()),
                        StandardCopyOption.REPLACE_EXISTING);
                    System.out.println("Copied: " + file.getFileName());
                } catch (IOException e) {
                    System.err.println("Failed to copy " + file + ": " + e.getMessage());
                }
            });
    }
}

When to Use Each Approach #

Use Files.readString() / writeString() when:
  ✓ Small text files (< a few MB) that need to be fully loaded into memory
  ✓ You want the most concise, readable code

Use BufferedReader / BufferedWriter when:
  ✓ Large files that must be processed line by line without loading everything into memory
  ✓ Output generated incrementally in a loop

Use Files.lines() with Streams when:
  ✓ You need to filter, map, or reduce file lines functionally
  ✓ Remember: always close the stream with try-with-resources

Use FileInputStream / OutputStream when:
  ✓ Binary data: images, audio, video, PDF, compressed files
  ✓ Add BufferedInputStream/OutputStream for better performance

Use Serialization when:
  ✓ You need to save and restore Java objects with complex structures
  ✗ Avoid it for inter-system communication — use JSON/XML instead

Use Files.copy() / move() when:
  ✓ Copying or moving files — far more concise than manual read-write

Summary #

  • NIO.2 (Path + Files) is the modern wayFiles.readString(), writeString(), copy(), move(), createDirectories() replace lots of verbose code from the old java.io.
  • Always use try-with-resourcestry (var r = ...) ensures streams are always closed, even on exceptions. Forgetting to close streams is a common source of bugs and resource leaks.
  • BufferedReader/BufferedWriter for large filesreadAllLines() and readString() load the entire file into memory. For large files, read line by line with BufferedReader or stream with Files.lines().
  • BufferedInputStream/BufferedOutputStream for byte I/O — without buffering, every read()/write() requires one syscall to the OS. With an 8 KB buffer, thousands of small operations are batched into one — the performance difference can be 10–100×.
  • Files.lines() must be closed — it returns a Stream<String> holding an open file handle. Always wrap it in try-with-resources.
  • Java serialization for internal data — easy to use but not portable and prone to version issues. For APIs and long-term persistence, use JSON or another more interoperable format.
  • transient excludes fields from serialization — use it for fields that don’t need to be or can’t be serialized (caches, database connections, etc.).
  • Files.walk() for recursive operations — safer and more concise than manual iteration. Use Files.walkFileTree() with SimpleFileVisitor when you need more control, like recursively deleting directories.

← Previous: Multi Threading   Next: Socket →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact