The java.nio.file package replaced the old stream-based file API with one that is easier to use and harder to misuse. Paths are objects, reading and writing are static calls on Files, and every method that holds an open file supports the try-with-resources statement so the handle is always closed.
What is a Path?
A Path is an object that represents the location of a file. You almost always build one with Paths.get or Path.of, and you combine parts with resolve instead of concatenating strings.
Path file = Paths.get("data", "value.txt");That form works the same on every operating system, because the separator is chosen by the platform rather than by you. The object carries useful operations: getParent, getFileName, toAbsolutePath and resolve for joining.
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class SafeFiles {
public static void main(String[] args) throws Exception {
Path target = Paths.get("data");
Files.createDirectories(target);
Path file = target.resolve("value.txt");
Files.writeString(file, "42");
System.out.println("absolute : " + file.toAbsolutePath());
System.out.println("parent : " + file.getParent().getFileName());
System.out.println("extension : " + file.getFileName().toString().split("\\.")[1]);
System.out.println("exists : " + Files.exists(file));
System.out.println("is directory : " + Files.isDirectory(target));
System.out.println("missing file : " + Files.exists(Paths.get("no-such-file.txt")));
Files.deleteIfExists(file);
Files.deleteIfExists(target);
}
}absolute : C:\Users\iamfa\AppData\Local\Temp\opencode\jlw\data\value.txt
parent : data
extension : txt
exists : true
is directory : true
missing file : falseThe resolve call in the middle is the pattern to copy: you keep a directory as a Path and join file names to it, so you never build a path by pasting strings together with a separator. The last two lines show the two questions you will ask most often - does this exist, and is it a directory.
How do you read a whole file?
For a file that fits comfortably in memory, Files.readString gives you the whole content as one string, and Files.readAllLines gives you a list of lines. Both are one-liners, and both specify a character set so the result does not depend on the machine's default encoding.
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
public class FileBasics {
public static void main(String[] args) throws Exception {
Path file = Paths.get("notes.txt");
Files.writeString(file, "first line\nsecond line\n");
System.out.println("exists : " + Files.exists(file));
System.out.println("size : " + Files.size(file));
String content = Files.readString(file);
System.out.println("read : " + content.strip());
List<String> lines = Files.readAllLines(file);
System.out.println("lines : " + lines.size());
Files.deleteIfExists(file);
System.out.println("exists after delete : " + Files.exists(file));
}
}exists : true
size : 23
read : first line
second line
lines : 2
exists after delete : falseNotice that Files.writeString created the file, and Files.deleteIfExists removed it without checking whether it was there. That method is the safe one to use in cleanup code, because the "if exists" part is built in.
What about large files?
Reading a huge file with readString loads all of it into memory at once. For anything sizeable, use a buffered reader and process it line by line. Files.newBufferedReader gives you a BufferedReader, and the try-with-resources block closes it for you - even if an exception is thrown inside the loop.
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;
public class BufferedReading {
public static void main(String[] args) throws IOException {
Path file = Paths.get("rows.csv");
Files.writeString(file, "id,name\n1,ana\n2,bo\n", StandardCharsets.UTF_8);
try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split(",");
System.out.println(parts.length + " columns, first is " + parts[0]);
}
}
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
long count = lines.filter(l -> l.contains(",")).count();
System.out.println("data rows : " + count);
}
Files.deleteIfExists(file);
}
}2 columns, first is id
2 columns, first is 1
2 columns, first is 2
data rows : 3The second part of that program uses Files.lines, which returns a stream of the lines. The stream must be closed too, which is why it sits inside a try-with-resources block. Streams are the more compact option, and they read the file lazily - only the lines the pipeline actually consumes are pulled from disk.
How do you write to a file?
Files.writeString creates or overwrites by default. To add to the end instead, pass StandardOpenOption.APPEND. There is also Files.write, which takes a collection of lines and writes them one per line.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
public class WritingOptions {
public static void main(String[] args) throws Exception {
Path file = Paths.get("log.txt");
Files.writeString(file, "first\n");
Files.writeString(file, "second\n", StandardOpenOption.APPEND);
Files.writeString(file, "third\n", StandardOpenOption.APPEND);
System.out.println("content :");
for (String line : Files.readAllLines(file)) {
System.out.println(" " + line);
}
Path listFile = Paths.get("items.txt");
Files.write(listFile, List.of("alpha", "beta"));
System.out.println("list : " + Files.readAllLines(listFile));
Files.copy(listFile, Paths.get("items-copy.txt"));
System.out.println("copy exists : " + Files.exists(Paths.get("items-copy.txt")));
Files.deleteIfExists(file);
Files.deleteIfExists(listFile);
Files.deleteIfExists(Paths.get("items-copy.txt"));
}
}content :
first
second
third
list : [alpha, beta]
copy exists : trueThe first block of output shows the append behaviour - three writes, three lines, because the first write created the file and the next two appended. The second block shows Files.write with a list. The copy at the end is worth noting too: Files.copy makes a duplicate rather than a second name for the same bytes, and Files.move does the same for renaming.
Why does character encoding matter?
Text is bytes until it is decoded, and the decoding rules depend on a character set. If you write with one set and read with another - or read with the machine's default - you can get replacement characters where accents or non-Latin scripts should be. The safe pattern is to pass StandardCharsets.UTF_8 explicitly at every read and write, or to set the default once at the start of the program with Charset.defaultCharset() being avoided in favour of explicit sets.
What are the safe habits with files?
- Always close file handles - use try-with-resources and let the language do it.
- Always pass an explicit character set for text.
- Use
deleteIfExistsrather thandeletein cleanup code. - Check
Files.existsbefore reading a file you did not create. - Stream a large file line by line instead of reading it whole.
- Build paths with
resolve, never with string concatenation.
One more habit worth forming: when you write a file that another program reads, write to a temporary file and then move it into place. The move is usually atomic on the same filesystem, so a reader never sees a half-written file.
What else can you do with a file besides reading it?
The Files class reaches well past the contents of a file. It can tell you the size and the modification time, it can walk a directory tree, and it can create a temporary file for you when you only need somewhere to put bytes.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.stream.Stream;
public class FileMetadata {
public static void main(String[] args) throws IOException {
Path folder = Paths.get("notes");
Files.createDirectories(folder);
Path file = folder.resolve("day-one.txt");
Files.writeString(file, "first note");
System.out.println("size : " + Files.size(file));
System.out.println("has a modification time : " + (Files.getLastModifiedTime(file).toMillis() > 0));
System.out.println("is regular file : " + Files.isRegularFile(file));
try (Stream<Path> entries = Files.list(folder)) {
entries.forEach(entry -> System.out.println("entry : " + entry.getFileName()));
}
Path temp = Files.createTempFile("scratch", ".txt");
System.out.println("temp file created : " + Files.exists(temp));
Files.deleteIfExists(temp);
Files.deleteIfExists(file);
Files.deleteIfExists(folder);
}
}size : 10
has a modification time : true
is regular file : true
entry : day-one.txt
temp file created : trueFiles.list gives you the immediate children of a directory and Files.walk gives you the whole tree, both as streams that must be closed. The stream form is the right one, because it lets you filter and collect without holding every entry in memory at once.
createTempFile deserves a mention because it removes a whole category of bug. When two processes pick names in the same folder by hand, they can collide. A temporary file is created atomically with a unique name, so the operating system guarantees nobody else takes it.
The habits worth keeping
To summarise the API in one line each: Paths.get and Path.of build a path, resolve joins parts, Files.readString and Files.readAllLines read, Files.writeString and Files.write write, Files.newBufferedReader and Files.lines stream, and Files.copy, Files.move and Files.deleteIfExists manage files on disk.
Try it yourself: write a program that creates a folder, writes three small text files into it with different content, reads them back with Files.readString, prints each file name with its character count, and then deletes the folder and everything in it. Add a step that copies one file before deleting, and print the copy's content to prove the copy survived.