IO #

Input and Output are operations present in almost every application — reading configuration files, writing logs, processing CSVs, or saving computation results. Java provides two families of I/O APIs: java.io, which is stream-based and has existed since Java 1, and java.nio (New I/O), introduced in Java 4 and heavily updated in Java 7 with NIO.2. For almost all modern file needs, NIO.2 through java.nio.file.Files and java.nio.file.Path is the right choice — more expressive, safer, and equipped with utility methods that complete common jobs in a single line. The java.io API is still relevant for streaming and decorator chaining, but for file and path manipulation, NIO.2 is far better. This article covers both in depth, with an emphasis on correct patterns and the anti-patterns that often cause bugs and resource leaks.

The Two I/O API Families #

Before writing code, understand when to use each:

java.io
├── InputStream / OutputStream     ← byte streams (binary)
├── Reader / Writer                 ← character streams (text)
├── FileInputStream/FileOutputStream← low-level file access
├── BufferedReader/BufferedWriter   ← buffering for performance
└── File                           ← path representation (legacy)

java.nio.file (NIO.2 — Java 7+)
├── Path                           ← path representation (modern)
├── Paths / Path.of()              ← factories for creating Paths
├── Files                          ← file operation utilities (one-liners!)
└── FileSystem / FileSystems       ← filesystem abstraction
flowchart TD
    TASK{Need}

    TASK -->|Read/write simple text\nor binary files| NIO2["NIO.2 — Files + Path\nPrimary choice"]
    TASK -->|Stream processing\ndecorator pattern| JAVAIO["java.io\nInputStream/Reader"]
    TASK -->|Non-blocking I/O\nthousands of connections| NIO["java.nio\nChannel + Selector"]
    TASK -->|Path operations\ndirectory manipulation| NIO2

    NIO2 -->|if streaming is needed| JAVAIO

Path — Representing File Locations #

Path is Java’s modern way of representing a filesystem location — replacing the old java.io.File.

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

// Creating a Path (Java 11+: Path.of() is more idiomatic than Paths.get())
Path p1 = Path.of("/home/user/documents/report.txt");       // absolute
Path p2 = Path.of("data", "input", "file.csv");          // relative, cross-platform
Path p3 = Paths.get("/tmp/cache");                          // old way, still valid

// Path navigation
Path dir = Path.of("/home/user/project");
Path file = dir.resolve("src/Main.java");                   // combine paths
// "/home/user/project/src/Main.java"

Path parent = file.getParent();                             // "/home/user/project/src"
Path fileName = file.getFileName();                         // "Main.java"
Path root = file.getRoot();                                 // "/" (Unix) or "C:\" (Windows)

// Path information
int depth = file.getNameCount();                        // number of segments
Path segment = file.getName(2);                              // 2nd segment (0-based)

// Normalization and absolutization
Path messy = Path.of("/home/user/../user/./documents");
Path clean = messy.normalize();                            // "/home/user/documents"
Path absolute = Path.of("relative/path").toAbsolutePath();   // from the current working dir

// Relativize — compute the relative path between two paths
Path base = Path.of("/home/user");
Path target = Path.of("/home/user/documents/report.txt");
Path relative = base.relativize(target);                    // "documents/report.txt"

// Comparison
boolean same = p1.equals(p2);                               // compare path values
boolean startsWith = file.startsWith(dir);               // true

// Conversion to URI and File (for interop with old APIs)
java.net.URI uri = file.toUri();                            // "file:///home/user/..."
java.io.File fileObj = file.toFile();                       // interop with java.io

// Cross-platform — Path.of() automatically uses the correct separator
Path crossPlatform = Path.of("data", "output", "result.csv");
// Windows: "data\output\result.csv"
// Unix:    "data/output/result.csv"

Files — One-Line File Operations #

Files is a utility class with static methods for all common file operations. This is the API you should use for most file needs.

Reading and Writing Text Files #

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

public class TextFileOperations {

    public void demo() throws Exception {
        Path filePath = Path.of("data/report.txt");

        // ===== READING =====

        // Read the entire content as a String — for small files
        String content = Files.readString(filePath);                        // Java 11+
        String contentCharset = Files.readString(filePath, StandardCharsets.UTF_8);

        // Read all lines as a List<String>
        List<String> lines = Files.readAllLines(filePath);              // UTF-8 default
        List<String> latinLines = Files.readAllLines(filePath,
            StandardCharsets.ISO_8859_1);

        // Read as a Stream<String> — for large files (lazy, doesn't load everything into memory)
        try (var stream = Files.lines(filePath)) {
            stream
                .filter(l -> !l.isBlank())
                .map(String::trim)
                .forEach(System.out::println);
        } // stream closed automatically

        // Read as a byte array — for small binary files
        byte[] bytes = Files.readAllBytes(filePath);

        // ===== WRITING =====

        // Write a String to a file (create new or overwrite existing)
        Files.writeString(filePath, "New content");                     // Java 11+
        Files.writeString(filePath, "UTF-8 content", StandardCharsets.UTF_8);

        // Write with additional options
        Files.writeString(filePath, "Additional line\n",
            StandardOpenOption.APPEND);         // append to the end of the file
        Files.writeString(filePath, "Only if not exists",
            StandardOpenOption.CREATE_NEW);     // fails if the file already exists

        // Write a List<String> as lines
        List<String> contentLines = List.of("line 1", "line 2", "line 3");
        Files.write(filePath, contentLines);
        Files.write(filePath, contentLines, StandardCharsets.UTF_8,
            StandardOpenOption.APPEND);

        // Write a byte array
        byte[] data = "binary data".getBytes(StandardCharsets.UTF_8);
        Files.write(filePath, data);
    }
}

File and Directory Operations #

public class FileManagement {

    public void demo() throws Exception {
        Path src = Path.of("source.txt");
        Path dst = Path.of("destination.txt");
        Path dir = Path.of("new/directory");

        // ===== EXISTENCE AND ATTRIBUTES =====
        boolean exists = Files.exists(src);
        boolean notExists = Files.notExists(src);
        boolean isFile = Files.isRegularFile(src);
        boolean isDir = Files.isDirectory(dir);
        boolean readable = Files.isReadable(src);
        boolean writable = Files.isWritable(src);
        boolean executable = Files.isExecutable(src);
        long size = Files.size(src);                          // in bytes
        java.time.Instant modified = Files.getLastModifiedTime(src).toInstant();

        // ===== COPY AND MOVE =====

        // Copy a file
        Files.copy(src, dst);                                   // fails if dst exists
        Files.copy(src, dst,
            java.nio.file.StandardCopyOption.REPLACE_EXISTING, // overwrite if it exists
            java.nio.file.StandardCopyOption.COPY_ATTRIBUTES); // copy file attributes

        // Move / rename a file
        Files.move(src, dst);
        Files.move(src, dst,
            java.nio.file.StandardCopyOption.REPLACE_EXISTING,
            java.nio.file.StandardCopyOption.ATOMIC_MOVE);      // atomic on supporting filesystems

        // ===== DELETE =====
        Files.delete(src);              // throws an exception if it doesn't exist
        Files.deleteIfExists(src);      // doesn't throw if it doesn't exist

        // ===== DIRECTORIES =====
        Files.createDirectory(dir);             // create one directory level
        Files.createDirectories(dir);           // create all levels (mkdir -p)

        // Temporary files
        Path tmpFile = Files.createTempFile("prefix-", ".tmp");
        Path tmpDir = Files.createTempDirectory("tmpdir-");
        // REMEMBER: delete temporary files when done
        tmpFile.toFile().deleteOnExit(); // delete when the JVM exits
    }
}

Reading Files with BufferedReader #

For large text files that need line-by-line processing, BufferedReader is more efficient than Files.readAllLines() because it doesn’t load the entire file into memory at once.

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

public class BufferedReaderDemo {

    // ✗ ANTI-PATTERN: FileReader without a charset — depends on the platform default
    public void readWithoutCharset(String path) throws IOException {
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        }
    }

    // ✓ CORRECT: always specify an explicit charset
    public void readWithCharset(Path path) throws IOException {
        try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            String line;
            while ((line = reader.readLine()) != null) {
                // process the line
            }
        }
        // try-with-resources automatically closes the reader even if an exception occurs
    }

    // ✓ MORE MODERN: Files.lines() with the Stream API
    public long countNonEmptyLines(Path path) throws IOException {
        try (var lines = Files.lines(path, StandardCharsets.UTF_8)) {
            return lines.filter(l -> !l.isBlank()).count();
        }
    }

    // Process a large CSV file without loading everything into memory
    public void processLargeCSV(Path csvPath) throws IOException {
        try (var lines = Files.lines(csvPath, StandardCharsets.UTF_8)) {
            lines
                .skip(1)                          // skip the header
                .map(line -> line.split(","))   // split into columns
                .filter(columns -> columns.length >= 3)
                .forEach(columns -> {
                    String name = columns[0].trim();
                    String price = columns[1].trim();
                    System.out.println("Product: " + name + " | Price: " + price);
                });
        }
    }
}

Writing Files with BufferedWriter #

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

public class BufferedWriterDemo {

    // ✓ CORRECT: BufferedWriter with an explicit charset
    public void writeWithBuffer(Path path, List<String> data) throws IOException {
        try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
            for (String line : data) {
                writer.write(line);
                writer.newLine(); // \n or \r\n depending on the OS
            }
        }
    }

    // Append to an existing file
    public void appendLog(Path logPath, String message) throws IOException {
        try (BufferedWriter writer = Files.newBufferedWriter(
                logPath,
                StandardCharsets.UTF_8,
                StandardOpenOption.CREATE,  // create if it doesn't exist
                StandardOpenOption.APPEND)) // append to the end
        {
            writer.write(java.time.LocalDateTime.now() + " | " + message);
            writer.newLine();
        }
    }

    // PrintWriter — more convenient for formatted output
    public void writeReport(Path path) throws IOException {
        try (PrintWriter pw = new PrintWriter(
                Files.newBufferedWriter(path, StandardCharsets.UTF_8))) {
            pw.println("=== Sales Report ===");
            pw.printf("Date: %tF%n", java.time.LocalDate.now());
            pw.printf("Total: Rp %.2f%n", 15000000.0);
            pw.flush(); // make sure all data is written
        }
    }
}

Reading and Writing Binary Files #

For binary files (images, PDFs, archives, serialized data), use byte streams without character encoding.

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

public class BinaryFileDemo {

    // Read a binary file (for small files — loads everything into memory)
    public byte[] readBinaryFile(Path path) throws IOException {
        return Files.readAllBytes(path); // all bytes at once
    }

    // Read a large binary file with a buffer
    public void processLargeFile(Path src, Path dst) throws IOException {
        try (InputStream in = new BufferedInputStream(Files.newInputStream(src));
             OutputStream out = new BufferedOutputStream(Files.newOutputStream(dst))) {

            byte[] buffer = new byte[8192]; // 8 KB buffer
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
            }
        }
        // Both are closed automatically — even if an exception occurs mid-process
    }

    // Transfer a file — the most efficient way using an OS-level copy
    public void copyFile(Path src, Path dst) throws IOException {
        try (InputStream in = Files.newInputStream(src);
             OutputStream out = Files.newOutputStream(dst)) {
            in.transferTo(out); // Java 9+ — delegate to the OS for maximum efficiency
        }
    }

    // DataInputStream / DataOutputStream — read/write primitive types to a binary stream
    public void writeBinaryData(Path path) throws IOException {
        try (DataOutputStream dos = new DataOutputStream(
                new BufferedOutputStream(Files.newOutputStream(path)))) {
            dos.writeInt(42);
            dos.writeDouble(3.14);
            dos.writeBoolean(true);
            dos.writeUTF("hello");         // write a String as UTF-8 with a length prefix
        }
    }

    public void readBinaryData(Path path) throws IOException {
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(Files.newInputStream(path)))) {
            int number = dis.readInt();    // must be read in the same order as written!
            double decimal = dis.readDouble();
            boolean bool = dis.readBoolean();
            String text = dis.readUTF();

            System.out.printf("int=%d, double=%.2f, bool=%b, str=%s%n",
                number, decimal, bool, text);
        }
    }
}

Directory Traversal #

Listing Directory Contents #

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

public class DirectoryTraversal {

    // List directory contents (one level only)
    public List<Path> listContents(Path dir) throws IOException {
        try (var stream = Files.list(dir)) {
            return stream
                .sorted()       // sort by name
                .toList();
        }
    }

    // Filter only specific files
    public List<Path> findJavaFiles(Path dir) throws IOException {
        try (var stream = Files.list(dir)) {
            return stream
                .filter(Files::isRegularFile)
                .filter(p -> p.toString().endsWith(".java"))
                .sorted()
                .toList();
        }
    }

    // Recursive traversal of the entire directory tree
    public List<Path> allFiles(Path root) throws IOException {
        try (var stream = Files.walk(root)) {
            return stream
                .filter(Files::isRegularFile)
                .sorted()
                .toList();
        }
    }

    // walk with a maximum depth
    public List<Path> filesAtDepth2(Path root) throws IOException {
        try (var stream = Files.walk(root, 2)) { // max 2 levels down
            return stream
                .filter(Files::isRegularFile)
                .toList();
        }
    }

    // Calculate the total size of a directory
    public long totalDirSize(Path dir) throws IOException {
        try (var stream = Files.walk(dir)) {
            return stream
                .filter(Files::isRegularFile)
                .mapToLong(p -> {
                    try { return Files.size(p); }
                    catch (IOException e) { return 0L; }
                })
                .sum();
        }
    }

    // find() — traversal with stronger filters
    public List<Path> findLargeFiles(Path root, long minSize) throws IOException {
        try (var stream = Files.find(root, Integer.MAX_VALUE,
                (path, attrs) ->
                    attrs.isRegularFile() &&
                    attrs.size() > minSize)) {
            return stream.toList();
        }
    }

    // Delete a directory and its contents (recursively)
    public void deleteDirectory(Path dir) throws IOException {
        // Files.delete() fails if the directory isn't empty
        // Must delete the contents first using walk in reverse order
        try (var stream = Files.walk(dir)) {
            stream.sorted(java.util.Comparator.reverseOrder())
                .forEach(path -> {
                    try { Files.delete(path); }
                    catch (IOException e) {
                        throw new RuntimeException("Failed to delete: " + path, e);
                    }
                });
        }
    }

    // Copy a directory and its contents
    public void copyDirectory(Path src, Path dst) throws IOException {
        try (var stream = Files.walk(src)) {
            stream.forEach(sourcePath -> {
                Path targetPath = dst.resolve(src.relativize(sourcePath));
                try {
                    if (Files.isDirectory(sourcePath)) {
                        Files.createDirectories(targetPath);
                    } else {
                        Files.copy(sourcePath, targetPath,
                            StandardCopyOption.REPLACE_EXISTING);
                    }
                } catch (IOException e) {
                    throw new RuntimeException("Failed to copy: " + sourcePath, e);
                }
            });
        }
    }
}

File Attributes and Metadata #

import java.nio.file.*;
import java.nio.file.attribute.*;
import java.time.Instant;

public class FileAttributeDemo {

    public void readAttributes(Path path) throws Exception {
        // Basic attributes
        BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);

        System.out.println("Regular file: " + attrs.isRegularFile());
        System.out.println("Directory: " + attrs.isDirectory());
        System.out.println("Symbolic link: " + attrs.isSymbolicLink());
        System.out.println("Size: " + attrs.size() + " bytes");
        System.out.println("Created: " + attrs.creationTime().toInstant());
        System.out.println("Last modified: " + attrs.lastModifiedTime().toInstant());
        System.out.println("Last accessed: " + attrs.lastAccessTime().toInstant());

        // Change the modification time
        Files.setLastModifiedTime(path,
            FileTime.from(Instant.now()));

        // POSIX attributes (Unix/Linux/Mac)
        if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
            PosixFileAttributes posix = Files.readAttributes(path, PosixFileAttributes.class);
            System.out.println("Owner: " + posix.owner().getName());
            System.out.println("Group: " + posix.group().getName());
            System.out.println("Permissions: " + PosixFilePermissions.toString(posix.permissions()));

            // Change permissions (equivalent to chmod 644)
            Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-r--r--"));
        }
    }
}

WatchService — Watching for File Changes #

WatchService lets an application react to directory changes — new files added, modified, or deleted — without repeated polling.

import java.nio.file.*;

public class FileWatcherDemo {

    public void watchDirectory(Path dir) throws Exception {
        WatchService watcher = FileSystems.getDefault().newWatchService();

        // Register the events to watch
        dir.register(watcher,
            StandardWatchEventKinds.ENTRY_CREATE,  // new file created
            StandardWatchEventKinds.ENTRY_MODIFY,  // file modified
            StandardWatchEventKinds.ENTRY_DELETE   // file deleted
        );

        System.out.println("Watching directory: " + dir);

        while (true) {
            // take() — blocks until an event arrives
            WatchKey key = watcher.take();

            for (WatchEvent<?> event : key.pollEvents()) {
                WatchEvent.Kind<?> kind = event.kind();

                // OVERFLOW means some events may have been missed
                if (kind == StandardWatchEventKinds.OVERFLOW) {
                    System.out.println("Some events may have been missed!");
                    continue;
                }

                // The name of the changed file
                @SuppressWarnings("unchecked")
                WatchEvent<Path> pathEvent = (WatchEvent<Path>) event;
                Path fileName = pathEvent.context();
                Path fullPath = dir.resolve(fileName);

                if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
                    System.out.println("File created: " + fullPath);
                } else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
                    System.out.println("File modified: " + fullPath);
                } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
                    System.out.println("File deleted: " + fullPath);
                }
            }

            // Reset the key — required to receive the next events
            boolean valid = key.reset();
            if (!valid) {
                System.out.println("The directory can no longer be watched");
                break;
            }
        }
    }
}

Object Serialization #

Java provides a built-in serialization mechanism to convert objects to a byte stream and back. Note that Java’s built-in serialization has many weaknesses and should be replaced with more modern formats (JSON, Protobuf, Avro) for inter-system communication.

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

// A serializable class must implement Serializable
public class Product implements Serializable {

    // serialVersionUID is important! If it's missing and the class structure changes,
    // deserializing old objects fails with an InvalidClassException
    @Serial
    private static final long serialVersionUID = 1L;

    private String name;
    private double price;

    // Fields that don't need serialization — marked transient
    private transient String cacheKey; // won't be serialized

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

    // getters...
}

public class SerializationDemo {

    // Serialization — object to file
    public void save(Product product, Path path) throws IOException {
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new BufferedOutputStream(Files.newOutputStream(path)))) {
            oos.writeObject(product);
        }
    }

    // Deserialization — file to object
    public Product load(Path path) throws IOException, ClassNotFoundException {
        try (ObjectInputStream ois = new ObjectInputStream(
                new BufferedInputStream(Files.newInputStream(path)))) {
            return (Product) ois.readObject();
        }
    }
}
Java’s built-in serialization (ObjectInputStream/ObjectOutputStream) has serious security risks — deserializing untrusted data can lead to Remote Code Execution. For data exchanged between systems or stored long-term, use JSON (Jackson/Gson), Protobuf, or a more modern format. Java’s built-in serialization is only safe when the data comes from a fully trusted source.

Safe Resource Handling Patterns #

Try-with-Resources #

Always use try-with-resources to ensure resources are closed even if an exception occurs:

// ✗ ANTI-PATTERN: manual closing — resource leak if an exception occurs before close()
BufferedReader reader = null;
try {
    reader = Files.newBufferedReader(path);
    String line = reader.readLine();
    // if an exception occurs here, reader.close() is never called!
} finally {
    if (reader != null) {
        reader.close(); // boilerplate and easy to forget
    }
}

// ✓ CORRECT: try-with-resources — close() is called automatically, even on exceptions
try (BufferedReader reader = Files.newBufferedReader(path)) {
    String line = reader.readLine();
    // exception here? reader.close() is still called!
}

// Multiple resources — closed in reverse order (ois before bis)
try (InputStream bis = new BufferedInputStream(Files.newInputStream(src));
     ObjectInputStream ois = new ObjectInputStream(bis)) {
    Object obj = ois.readObject();
}

// Custom resources — implement AutoCloseable
public class DBConnection implements AutoCloseable {
    public DBConnection(String url) { /* open the connection */ }

    @Override
    public void close() {
        /* close the connection — called automatically by try-with-resources */
        System.out.println("DB connection closed");
    }
}

try (DBConnection db = new DBConnection("jdbc:postgresql://...")) {
    // use db
} // db.close() is called automatically

When to Use Which API #

USE Files (NIO.2) WHEN:
  ✓ Reading/writing text files — Files.readString(), Files.writeString()
  ✓ Simple binary read/write — Files.readAllBytes(), Files.write()
  ✓ File/directory manipulation — copy, move, delete, createDirectories
  ✓ Directory traversal — Files.list(), Files.walk(), Files.find()
  ✓ Checking file attributes and metadata
  ✓ Almost all modern file needs

USE java.io Streams WHEN:
  ✓ Very large files that don't fit in memory — line-by-line streaming
  ✓ Decorator chaining (Buffer + Gzip + Cipher + stream)
  ✓ Java object serialization
  ✓ Interoperability with old APIs that use InputStream/Reader

USE WatchService WHEN:
  ✓ The application needs to react to file changes in real time
  ✓ Hot-reloading configuration without restarting the application
  ✓ Pipelines that process files as they arrive in a directory

Summary #

  • Files and Path (NIO.2) are the modern APIs to use for almost all file operations — Files.readString(), Files.writeString(), Files.copy(), Files.walk() complete common jobs in one expressive line.
  • Always use try-with-resources for all I/O resources — BufferedReader, InputStream, Stream<Path> are all AutoCloseable and must be closed to prevent file handle leaks.
  • Specify the charset explicitly (StandardCharsets.UTF_8) every time you work with text. Relying on the platform default causes bugs that are hard to reproduce in different environments.
  • Files.lines() for large text files — it returns a lazy Stream<String> that doesn’t load the entire file into memory like Files.readAllLines().
  • BufferedInputStream/BufferedOutputStream always wrap raw InputStream/OutputStream for performance — unbuffered I/O is very slow because every read()/write() becomes a system call.
  • Path.of() (Java 11+) replaces Paths.get() and new File() — use it for all new path representations. It’s cross-platform and automatically uses the correct separator.
  • in.transferTo(out) (Java 9+) is the most efficient way to copy streams — delegate to the OS level to avoid buffer copying in Java.
  • Avoid Java’s built-in serialization for data exchanged between systems or stored long-term — use JSON, Protobuf, or Avro, which are safer, more portable, and easier to evolve.

← Previous: Strings   Next: Math →

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