Files, I/O and JSON
Reading and writing files with java.nio.file, paths, charsets and try-with-resources, dates and times with java.time, calling web APIs with HttpClient, and handling JSON with a library.
The examples on this page are compact source files (see
The JDK, javac, java and Your First Program) in which all of java.base is imported automatically.
Each one creates the files it reads in its own empty working folder,
so relative paths such as readings.csv refer to that
folder.
Paths and java.nio.file
Modern Java file code lives in
java.nio.file
(part of "NIO.2", Java 7): the interface Path
describes a location in the file system, and the class
Files has static methods that act on it — read, write,
copy, move, delete, list. A Path is only a
path; creating
one does not touch the disk, and the file need not exist.
void main() {
Path log = Path.of("/var/log/kestrel/../kestrel-billing/app.log");
IO.println(log.getFileName());
IO.println(log.getParent());
IO.println(log.normalize());
IO.println(log.isAbsolute());
Path reports = Path.of("reports", "2026");
Path october = reports.resolve("october.csv");
IO.println(october);
IO.println(october.isAbsolute());
IO.println(october.resolveSibling("november.csv"));
IO.println(Path.of("reports").relativize(october));
}
app.log /var/log/kestrel/../kestrel-billing /var/log/kestrel-billing/app.log true reports/2026/october.csv false reports/2026/november.csv 2026/october.csv
Path.of("reports", "2026") joins the parts with the
separator of the operating system — / on Linux and
macOS, \ on Windows — so the same code works on both.
resolve appends a path, normalize removes
. and .. steps. A
relative path
is resolved against the working directory of the process, which for
a service is often not the folder you expect.
Reading and writing files
For files that fit comfortably in memory, four methods cover most
needs: Files.writeString and
Files.readString for a whole text, and
Files.write and Files.readAllLines for a
list of lines. Writing replaces an existing file unless you pass
StandardOpenOption.APPEND.
void main() throws IOException {
Path csv = Path.of("readings.csv");
Files.writeString(csv, "meter;kwh\nE-001;412\nE-002;388\n");
Files.writeString(csv, "E-003;750\n", StandardOpenOption.APPEND);
IO.println(Files.exists(csv) + ", " + Files.size(csv) + " bytes");
List<String> lines = Files.readAllLines(csv);
IO.println(lines.size() + " lines, first: " + lines.getFirst());
IO.print(Files.readString(csv));
Files.write(Path.of("meters.txt"), List.of("E-001", "E-002", "E-003"));
IO.println(Files.readAllLines(Path.of("meters.txt")));
}
true, 40 bytes 4 lines, first: meter;kwh meter;kwh E-001;412 E-002;388 E-003;750 [E-001, E-002, E-003]
Almost every method of Files declares
throws IOException, a
checked exception, so the examples declare main with
throws IOException. In an application you would catch
it where you can tell the user something useful.
Large files: line by line
readAllLines loads the whole file. For log files and
exports of many megabytes, process one line at a time.
Files.lines returns a lazy
stream
of lines that keeps the file open until the stream is closed — so it
belongs in a
try-with-resources
statement:
void main() throws IOException {
Path csv = Path.of("readings.csv");
Files.writeString(csv, """
meter;kwh
E-001;412
E-002;388
E-003;750
""");
int total;
try (Stream<String> lines = Files.lines(csv)) {
total = lines.skip(1) // header
.mapToInt(line -> Integer.parseInt(line.split(";")[1]))
.sum();
}
IO.println("Total: " + total + " kWh");
}
Total: 1550 kWh
The classic alternative is a BufferedReader, which
reads ahead in blocks and hands out one line per
readLine() call (null at the end);
BufferedWriter is its counterpart for writing. Both
must be closed — a writer that is not closed may never write its
last block to disk.
void main() throws IOException {
Path report = Path.of("outages.txt");
try (BufferedWriter writer = Files.newBufferedWriter(report)) {
writer.write("INC-10231 Grid station Delft offline");
writer.newLine();
writer.write("INC-10232 Meter gateway GW-07 timeout");
writer.newLine();
}
try (BufferedReader reader = Files.newBufferedReader(report)) {
String line;
int number = 0;
while ((line = reader.readLine()) != null) {
number++;
IO.println(number + ": " + line);
}
}
}
1: INC-10231 Grid station Delft offline 2: INC-10232 Meter gateway GW-07 timeout
Readers and writers handle text. For binary data such as images or
ZIP files, use Files.readAllBytes,
Files.newInputStream and
Files.newOutputStream: the InputStream and
OutputStream classes work with bytes and know nothing
about characters.
Folders and file errors
Files.createDirectories creates a folder and any
missing parents (and does nothing if it already exists);
Files.list returns the entries of one folder and
Files.walk those of a whole tree. Both return streams
that must be closed, and neither promises any order — sort before
you show the result.
void main() throws IOException {
Path exports = Path.of("exports", "2026", "10");
Files.createDirectories(exports);
Files.writeString(exports.resolve("readings.csv"), "E-001;412\n");
Files.writeString(exports.resolve("tickets.csv"), "INC-10231;NOC\n");
Files.createFile(exports.resolve("empty.txt"));
try (Stream<Path> entries = Files.list(exports)) {
IO.println(entries.map(Path::getFileName).map(Path::toString).sorted().toList());
}
try {
Files.createFile(exports.resolve("empty.txt"));
} catch (FileAlreadyExistsException e) {
IO.println("Already there: " + e.getMessage());
}
Files.delete(exports.resolve("empty.txt"));
IO.println(Files.exists(exports.resolve("empty.txt")));
}
[empty.txt, readings.csv, tickets.csv] Already there: exports/2026/10/empty.txt false
The exceptions are specific subclasses of
IOException whose message is the path:
NoSuchFileException,
FileAlreadyExistsException,
AccessDeniedException,
DirectoryNotEmptyException. A missing configuration
file looks like this:
void main() throws IOException {
String tariffs = Files.readString(Path.of("config", "tariffs.csv"));
IO.println(tariffs);
}
Exception in thread "main" java.nio.file.NoSuchFileException: config/tariffs.csv
Charsets
A file stores bytes; text consists of characters. A
charset — Java's name for a
character encoding, represented by the class java.nio.charset.Charset —
defines how characters are turned into bytes and back. The same word
needs a different number of bytes in different charsets:
void main() throws IOException {
String word = "Größe";
IO.println(word.length() + " characters");
IO.println(word.getBytes(StandardCharsets.UTF_8).length + " bytes in UTF-8");
IO.println(word.getBytes(StandardCharsets.ISO_8859_1).length + " bytes in ISO-8859-1");
IO.println(Charset.defaultCharset());
Path legacy = Path.of("legacy-export.txt");
Files.writeString(legacy, "Meter E-001: Größe 412\n", StandardCharsets.ISO_8859_1);
IO.print(Files.readString(legacy, StandardCharsets.ISO_8859_1));
IO.print(Files.readString(legacy));
}
5 characters 7 bytes in UTF-8 5 bytes in ISO-8859-1 UTF-8 Meter E-001: Größe 412 Exception in thread "main" java.nio.charset.MalformedInputException: Input length = 4
The file was written in ISO-8859-1 (Latin-1), as older Windows and
mainframe exports often are. Read with the right charset, it is
fine; read as UTF-8 — the default —
Files.readString refuses the bytes with a
MalformedInputException. That is the helpful outcome.
Other APIs replace undecodable bytes silently with the replacement
character, and the damage shows up much later:
void main() {
byte[] latin1 = "Größe".getBytes(StandardCharsets.ISO_8859_1);
IO.println(new String(latin1, StandardCharsets.UTF_8));
}
Gr��e
Since JDK 18 (JEP 400) the default charset is UTF-8 on every
operating system. Before that it depended on the platform — on
Windows often windows-1252 — so the same program could produce
different files on different servers. Code that must also run on
older Java versions should always pass the charset:
Files.readString(path, StandardCharsets.UTF_8).
Dates and times with java.time
java.time (Java 8) is
the date and time API of the JDK. Its classes are immutable — every
"change" returns a new object — and each describes one clear
concept:
| Class | Represents | Example |
|---|---|---|
LocalDate |
A date without time or zone | 2026-10-05 |
LocalTime |
A time of day | 14:30 |
LocalDateTime |
Date and time, but no zone: not a moment in time | 2026-10-05T14:30 |
Instant |
A moment on the UTC timeline | 2026-10-05T12:00:00Z |
ZonedDateTime |
Date and time in a named time zone | 2026-10-05T14:00+02:00[Europe/Amsterdam] |
Duration / Period |
An amount of time in hours and seconds / in days, months, years | PT2H15M / P8M5D |
void main() {
LocalDate reading = LocalDate.of(2026, 10, 5);
IO.println(reading);
IO.println(reading.getDayOfWeek());
IO.println(reading.plusDays(30));
IO.println(reading.plusMonths(1).withDayOfMonth(1));
IO.println(reading.isBefore(LocalDate.of(2026, 12, 31)));
LocalDate contractStart = LocalDate.of(2026, 1, 31);
IO.println(Period.between(contractStart, reading));
IO.println(ChronoUnit.DAYS.between(contractStart, reading));
LocalDateTime visit = LocalDateTime.of(2026, 10, 5, 14, 30);
LocalTime start = LocalTime.of(8, 0);
IO.println(visit);
IO.println(start.plusMinutes(135));
IO.println(Duration.ofMinutes(135));
}
2026-10-05 MONDAY 2026-11-04 2026-11-01 true P8M5D 247 2026-10-05T14:30 10:15 PT2H15M
Months count from 1, as on a calendar. toString()
produces the
ISO 8601
format, and P8M5D is ISO 8601 for "a period of 8 months
and 5 days".
Formatting and parsing
DateTimeFormatter.ofPattern formats and parses with a
pattern. Names of days and months depend on the
locale, so pass one explicitly:
this machine's default locale is plain English.
void main() {
LocalDate reading = LocalDate.of(2026, 10, 5);
Locale dutch = Locale.forLanguageTag("nl-NL");
IO.println(reading.format(DateTimeFormatter.ofPattern("dd-MM-yyyy")));
IO.println(reading.format(DateTimeFormatter.ofPattern("EEEE d MMMM yyyy", dutch)));
IO.println(reading.format(DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.US)));
LocalDate parsed = LocalDate.parse("05-10-2026", DateTimeFormatter.ofPattern("dd-MM-yyyy"));
IO.println(parsed);
LocalDate lastWeek = LocalDate.of(2026, 12, 28);
IO.println(lastWeek.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
IO.println(lastWeek.format(DateTimeFormatter.ofPattern("YYYY-MM-dd")));
for (String text : List.of("2026-10-05", "2026-02-30", "05-10-2026")) {
try {
IO.println(LocalDate.parse(text));
} catch (DateTimeParseException e) {
IO.println(e.getMessage());
}
}
}
05-10-2026 maandag 5 oktober 2026 Monday, October 5, 2026 2026-10-05 2026-12-28 2027-12-28 2026-10-05 Text '2026-02-30' could not be parsed: Invalid date 'FEBRUARY 30' Text '05-10-2026' could not be parsed at index 0
Instants and time zones
An
Instant is a single
moment on the timeline, counted from the Unix epoch (1 January 1970,
00:00 UTC) and printed in UTC with a Z. Log timestamps,
database columns and API fields that record when something happened
should be instants. Convert to a ZonedDateTime only to
show the time to a person:
void main() {
Instant logged = Instant.parse("2026-10-05T12:00:00Z");
ZoneId amsterdam = ZoneId.of("Europe/Amsterdam");
IO.println(logged);
IO.println(logged.atZone(amsterdam));
IO.println(logged.atZone(ZoneId.of("America/New_York")).toLocalTime());
IO.println(logged.getEpochSecond());
IO.println(Instant.ofEpochSecond(0));
// Daylight saving time ends in the Netherlands on 25 October 2026
ZonedDateTime beforeChange = ZonedDateTime.of(2026, 10, 25, 1, 30, 0, 0, amsterdam);
IO.println(beforeChange.plusHours(1));
IO.println(beforeChange.plusHours(2));
IO.println(Duration.between(beforeChange, beforeChange.plusDays(1)));
}
2026-10-05T12:00:00Z 2026-10-05T14:00+02:00[Europe/Amsterdam] 08:00 1791201600 1970-01-01T00:00:00Z 2026-10-25T02:30+02:00[Europe/Amsterdam] 2026-10-25T02:30+01:00[Europe/Amsterdam] PT25H
On the night the clocks go back, 02:30 happens twice — once at
+02:00 and once at +01:00 — and that "day" lasts 25 hours.
ZonedDateTime handles this with the time-zone rules
that ship with the JDK (and are updated with it). A
LocalDateTime cannot: it has no zone, so it cannot
know.
Instant.now() and LocalDate.now() read the
system clock and the default time zone of the JVM, which on a server
is often UTC. For code you want to test, pass a
Clock instead of calling now() directly.
Calling web APIs with HttpClient
HttpClient (package
java.net.http, standard since Java 11) is the JDK's
HTTP client. It supports HTTP/1.1 and HTTP/2, and HTTP/3 since JDK
26. You build an immutable HttpRequest, send it, and
choose with a body handler what the response body becomes —
here a String. The module java.net.http is
not part of java.base, so even a compact source file
needs the imports.
So that the example runs without internet, it first starts a tiny
"meter API" with the JDK's built-in HTTP server on
127.0.0.1 and a free port; the client part is the same
as for a real REST API:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
// A tiny local "meter API" on 127.0.0.1, any free port
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/meters/E-001", exchange -> {
byte[] body = """
{"meterId": "E-001", "kwh": 412}""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
for (String path : List.of("/meters/E-001", "/meters/E-999")) {
HttpRequest request = HttpRequest.newBuilder(base.resolve(path))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
IO.println(path + " -> " + response.statusCode());
if (response.statusCode() == 200) {
IO.println(response.headers().firstValue("Content-Type").orElse("?"));
IO.println(response.body());
}
}
server.stop(0);
}
/meters/E-001 -> 200
application/json
{"meterId": "E-001", "kwh": 412}
/meters/E-999 -> 404
-
A 404 or 500 is not an exception.
sendthrows only when there is no HTTP answer at all (IOException, includingHttpTimeoutException); checkstatusCode()yourself. - Set both timeouts. Without them, a server that accepts the connection but never answers can block a thread indefinitely — a classic cause of applications that "hang".
-
Create one client and reuse it. An
HttpClientkeeps connections open for reuse and is safe to share between threads. -
sendAsyncreturns aCompletableFutureinstead of waiting; see Concurrency: Threads, Executors and Virtual Threads.
com.sun.net.httpserver is fine for tests and small
tools, but real services use a framework such as Spring Boot.
Secrets such as API keys belong in environment variables
(System.getenv("METER_API_KEY")) or a secrets store,
never in source code.
JSON
Web APIs almost always exchange
JSON.
Turning Java objects into JSON and back is
serialization
and parsing. Unlike Python, JavaScript and .NET,
Java SE has no JSON API as of JDK 27 — no module in
the JDK reads or writes JSON. A "Simple JSON API" (JEP 540) is
targeted at JDK 28 as an incubator module
(jdk.incubator.json), an experimental API that may
still change or disappear; JDK 28 is in development as of October
2026 and expected in March 2027.
Building JSON with string concatenation looks easy but breaks on the first quote or backslash in the data, and parsing it by hand is worse. Use a library.
Jackson
Jackson is the most widely used JSON
library for Java and the default in Spring Boot. Its central class,
ObjectMapper, converts between JSON text and Java
objects, including records. As of October 2026 there are two
supported major versions:
| Line | Maven coordinates (latest, Sep 2026) | Java packages | Minimum Java |
|---|---|---|---|
| Jackson 2.x |
com.fasterxml.jackson.core:jackson-databind:2.22.3
|
com.fasterxml.jackson.* |
8 |
| Jackson 3.x (since October 2025) |
tools.jackson.core:jackson-databind:3.2.3
|
tools.jackson.* (annotations stay in
com.fasterxml.jackson.annotation)
|
17 |
2.x is still actively maintained and the most widely used; most
existing applications and many tutorials use it. The two lines can
exist side by side in one application, but code does not switch from
one to the other by changing a version number. You add the library
through your build tool (see
Packages, Modules and Build Tools); in a Maven pom.xml:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.3</version>
</dependency>
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.22.3")
}
The library cannot be downloaded where this site's examples run, so the code below is not run here. With Jackson 2.x on the classpath:
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
public class ReadingsJson {
record MeterReading(String meterId, int kwh) { }
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper(); // create once, reuse
// JSON text to a record
MeterReading reading = mapper.readValue(
"{\"meterId\": \"E-001\", \"kwh\": 412}", MeterReading.class);
System.out.println(reading); // MeterReading[meterId=E-001, kwh=412]
// A generic type needs a TypeReference because of type erasure
List<MeterReading> readings = mapper.readValue(
"[{\"meterId\": \"E-001\", \"kwh\": 412}, {\"meterId\": \"E-002\", \"kwh\": 388}]",
new TypeReference<List<MeterReading>>() { });
System.out.println(readings.size()); // 2
// A record to JSON text
System.out.println(mapper.writeValueAsString(new MeterReading("E-003", 750)));
// {"meterId":"E-003","kwh":750}
}
}
MeterReading.class tells Jackson which type to create;
for List<MeterReading> there is no such class
object, so the anonymous TypeReference subclass
captures the full type — the workaround for
type erasure. Things to
know about 2.x:
-
Errors throw
JsonProcessingExceptionand its subclasses, which are checked (they extendIOException). By default, a JSON property that the record does not have is an error (UnrecognizedPropertyException);@JsonIgnoreProperties(ignoreUnknown = true)on the record allows extra fields. -
java.timetypes such asInstantneed the extra modulejackson-datatype-jsr310(mapper.registerModule(new JavaTimeModule())).
Jackson 3 changes those points: its exceptions
(JacksonException) are unchecked, an
ObjectMapper is immutable and built with
JsonMapper.builder()…build(), java.time
support is built in, and unknown properties are no longer an error
by default. Follow the project's migration guide when moving an
application to 3.x. Other JSON libraries you may meet are Gson and
the Jakarta EE standards JSON-B and JSON-P.