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. send throws only when there is no HTTP answer at all (IOException, including HttpTimeoutException); check statusCode() 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 HttpClient keeps connections open for reuse and is safe to share between threads.
  • sendAsync returns a CompletableFuture instead 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 JsonProcessingException and its subclasses, which are checked (they extend IOException). 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.time types such as Instant need the extra module jackson-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.