File I/O & NIO.2

Writing Files

15 min Lesson 3 of 13

Writing Files

Reading a file is only half the story. Knowing how to write one — and picking the right API for the job — is just as important. Java gives you several tools: the modern Files.write / Files.writeString methods from NIO.2, and the classic BufferedWriter built on the older I/O stack. Each has a distinct sweet spot, and each lets you choose between overwriting an existing file and appending to it.

The Quick Path: Files.write and Files.writeString

Since Java 7 (NIO.2), java.nio.file.Files has provided static helpers that open, write, and close the file in a single call. You do not need to manage streams or writers yourself.

Writing a list of lines:

import java.nio.file.Files; import java.nio.file.Path; import java.nio.charset.StandardCharsets; import java.util.List; Path target = Path.of("notes.txt"); List<String> lines = List.of( "First line", "Second line", "Third line" ); // Overwrites the file (creates it if absent) Files.write(target, lines, StandardCharsets.UTF_8);

Each element in the list becomes one line; the method appends the platform line separator after each. The file is created if it does not exist and the parent directory is present.

Writing a raw byte array: Files.write is overloaded to accept byte[] too, which is useful for binary data or when you already have the bytes:

byte[] data = "Hello, World!".getBytes(StandardCharsets.UTF_8); Files.write(Path.of("hello.bin"), data);

Writing a whole string at once (Java 11+):

import java.nio.file.Files; import java.nio.file.Path; String content = """ # Shopping List - Milk - Bread - Eggs """; Files.writeString(Path.of("shopping.txt"), content);

Files.writeString was added in Java 11 as a convenience for the very common case of writing a single String. It defaults to UTF-8 and is the shortest path from a string value to a file on disk.

Always specify the charset explicitly. Both Files.write and Files.writeString accept an optional Charset argument. Omitting it means UTF-8 (the default for NIO.2 methods), which is safe almost everywhere — but being explicit documents your intent and prevents surprises on rare systems with a different platform default.

Append vs Overwrite: StandardOpenOption

By default, Files.write and Files.writeString overwrite the file completely. Pass a StandardOpenOption to change that:

import java.nio.file.StandardOpenOption; // APPEND — adds to the end instead of replacing Files.writeString( Path.of("log.txt"), "New log entry\n", StandardOpenOption.APPEND ); // CREATE_NEW — fails with an exception if the file already exists Files.writeString( Path.of("report.txt"), reportContent, StandardOpenOption.CREATE_NEW );

The key options you will use most often:

  • WRITE — open for writing (implicit when calling write methods).
  • CREATE — create if absent, open if present (default behaviour).
  • CREATE_NEW — create only; throw FileAlreadyExistsException if present. Useful for safe file generation where overwriting would be a bug.
  • APPEND — move the write position to the end before each write. Combine with CREATE to build up a log file safely.
  • TRUNCATE_EXISTING — truncate to zero bytes on open (the default overwrite behaviour comes from this).
APPEND is not thread-safe at the application level. If multiple threads (or processes) write to the same file with APPEND simultaneously, individual write calls are atomic at the OS level on most platforms, but the order of entries is unpredictable. For multi-threaded logging, prefer a dedicated logging framework or a synchronized wrapper.

BufferedWriter: When You Need Incremental Writes

Files.write is ideal when you have all the data up front — it materialises the whole content in memory before writing. When you need to build a file incrementally (writing line by line inside a loop, for example), BufferedWriter is the right tool. It accumulates writes in an in-memory buffer (default 8 KB) and flushes to disk in large, efficient chunks rather than making a system call for every single line.

import java.io.BufferedWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.stream.IntStream; Path out = Path.of("numbers.txt"); try (BufferedWriter writer = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) { for (int i = 1; i <= 10_000; i++) { writer.write("Line " + i); writer.newLine(); // platform-correct line separator } } // auto-closed; buffer flushed and file closed here

A few things to note:

  • Files.newBufferedWriter is the NIO.2 factory — prefer it over new BufferedWriter(new FileWriter(...)) because it accepts a Path and a Charset directly.
  • writer.newLine() writes the platform's line separator (\r\n on Windows, \n on Unix). Avoid hardcoding "\n" in files that will be read on multiple platforms.
  • The try-with-resources block guarantees the buffer is flushed and the file handle is released even if an exception is thrown inside the loop.

Appending with BufferedWriter

To append rather than overwrite, pass StandardOpenOption.APPEND to the factory:

try (BufferedWriter writer = Files.newBufferedWriter( Path.of("audit.log"), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { writer.write("[INFO] Application started"); writer.newLine(); }

Passing both CREATE and APPEND is the idiomatic pattern: create the file if it does not exist, append to it if it does.

Choosing the Right API

Here is a practical decision rule:

  • Small content, already in memory (a string, a list of lines, a byte array) → use Files.writeString or Files.write. One line, no streams to manage.
  • Large content or incremental generation (loop, streaming results, building a report row by row) → use BufferedWriter via Files.newBufferedWriter. The buffer prevents excessive system calls.
  • Binary data → use Files.write(path, byte[]) or Files.newOutputStream wrapped in a BufferedOutputStream.
Why does buffering matter for performance? Each unbuffered write that reaches the OS is a system call, and system calls are expensive relative to in-process memory operations. A 10,000-line file written one line at a time without buffering may make 10,000 system calls. With a BufferedWriter (8 KB buffer) those 10,000 calls collapse to roughly 5–10 flushes, which is orders of magnitude faster on spinning disks and still meaningfully faster on SSDs.

Exception Handling

All NIO.2 write methods throw IOException (a checked exception). You must either catch it or declare it in the method signature. A minimal pattern for a utility method:

import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; public static void saveReport(Path path, String content) throws IOException { Files.writeString(path, content); }

In application code where you cannot propagate the checked exception (for example, inside a lambda), wrap it in an unchecked exception:

import java.io.UncheckedIOException; paths.forEach(p -> { try { Files.writeString(p, generate(p)); } catch (IOException e) { throw new UncheckedIOException(e); } });

Summary

Use Files.writeString for the simplest case: a single string, one method call, UTF-8, done. Use Files.write when you have a list of lines or a byte array. Use BufferedWriter from Files.newBufferedWriter when you write incrementally or care about performance at scale. Control overwrite vs append with StandardOpenOption. Always use try-with-resources to guarantee the file is closed and the buffer is flushed.