Instant and Timestamps

Record exact moments with Instant, convert to and from epoch timestamps, and keep precision consistent between Java and the database.

SyntaxInstant now = Instant.now(clock); Instant fromJwt = Instant.ofEpochSecond(exp); long ms = instant.toEpochMilli(); LocalDate day = instant.atZone(zone).toLocalDate();

Instant is a single point on the UTC timeline: a count of seconds and nanoseconds since the epoch, 1970-01-01T00:00:00Z. It has no zone, no calendar fields and no daylight saving. That makes it the right type for anything that records when something happened: createdAt, updatedAt, token expiry, audit log entries, rate-limit windows.

Epoch timestamps

Many systems speak in numbers. JWT exp and iat claims are seconds since the epoch; JavaScript's Date.now() and many message brokers use milliseconds. Convert at the boundary and work with Instant inside:

Instant exp = Instant.ofEpochSecond(1_791_878_400L);
long millis = Instant.parse("2026-10-13T09:30:00Z").toEpochMilli();

The classic bug is mixing the two units. A seconds value read as milliseconds lands in January 1970; a milliseconds value read as seconds lands tens of thousands of years in the future. Name fields with their unit (expiresAtEpochSeconds) when you cannot use Instant.

No calendar fields

An Instant does not know what day it is, because that depends on where you stand. instant.get(ChronoField.YEAR) throws UnsupportedTemporalTypeException: Unsupported field: Year. Pick a zone first: instant.atZone(zone).toLocalDate(). This forces the question "whose today?" to be answered explicitly, which is the point.

Precision

Instant holds nanoseconds, and Instant.now() on a modern JDK and OS often returns microseconds. Databases usually store less: PostgreSQL timestamptz keeps microseconds, MySQL DATETIME keeps whatever fractional precision the column declares (none by default). Save an instant, read it back, and equals can fail because digits were dropped or rounded. Truncate once, before saving, with truncatedTo(ChronoUnit.MICROS) or MILLIS, so the value in memory matches what the database holds.

Comparing and storing

  • Use isBefore and isAfter for expiry checks; Instant also implements Comparable for sorting.
  • JDBC drivers map TIMESTAMP WITH TIME ZONE to OffsetDateTime; call toInstant() on the result. Writing with setObject(i, instant.atOffset(ZoneOffset.UTC)) works with the major drivers.
  • Never derive an Instant from a LocalDateTime without stating the zone. toInstant(ZoneOffset.UTC) is fine only if the local value really was UTC.

Instant.toString() prints ISO-8601 in UTC with a trailing Z, which is also the best format to put in logs and JSON.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Store createdAt and updatedAt as Instant mapped to a timestamptz column, truncated to microseconds before saving.
  • Turn the exp claim of an incoming JWT into an Instant and reject the request once it has passed.
  • Write audit log entries with Instant.toString() so every service's logs sort and compare in UTC.

More examples

Checking token expiry with a skew allowance

Servers' clocks are never perfectly in step, so token checks usually allow a small leeway. Passing now in makes the boundary cases easy to test.

Example · java
record Token(String subject, Instant expiresAt) {}

boolean isValid(Token t, Instant now, Duration skew) {
    return now.isBefore(t.expiresAt().plus(skew));
}

void main() {
    Token t = new Token("user-42", Instant.ofEpochSecond(1_791_878_400L));
    Duration skew = Duration.ofSeconds(30);
    Instant[] checks = {
        Instant.parse("2026-10-13T07:59:59Z"),
        Instant.parse("2026-10-13T08:00:20Z"),
        Instant.parse("2026-10-13T08:00:30Z"),
    };
    for (Instant now : checks) {
        IO.println(now + " valid=" + isValid(t, now, skew));
    }
}
// Output:
// 2026-10-13T07:59:59Z valid=true
// 2026-10-13T08:00:20Z valid=true
// 2026-10-13T08:00:30Z valid=false

The precision trap after a database round trip

The nanoseconds are lost on the way into the column, so the value you read back differs from the one you kept. Truncating once before saving makes the in-memory value and the stored value identical.

Example · java
// Simulates a column that keeps microseconds, like PostgreSQL timestamptz.
Instant saveAndLoad(Instant value) {
    return value.truncatedTo(ChronoUnit.MICROS);
}

void main() {
    Instant inMemory = Instant.parse("2026-10-13T09:30:15.123456789Z");
    Instant fromDb = saveAndLoad(inMemory);
    IO.println("raw equals:       " + inMemory.equals(fromDb));

    Instant normalised = inMemory.truncatedTo(ChronoUnit.MICROS);
    IO.println("truncated equals: " + normalised.equals(saveAndLoad(normalised)));
}
// Output:
// raw equals:       false
// truncated equals: true

Bucketing events by the user's day

The same three logins fall on different days depending on whose calendar you use. A per-day chart for a user must convert with that user's zone, not the server's.

Example · java
void main() {
    List<Instant> logins = List.of(
            Instant.parse("2026-10-12T22:10:00Z"),
            Instant.parse("2026-10-13T01:00:00Z"),
            Instant.parse("2026-10-13T15:45:00Z"));

    for (String zone : List.of("UTC", "Australia/Sydney")) {
        Map<LocalDate, Integer> perDay = new TreeMap<>();
        for (Instant i : logins) {
            perDay.merge(i.atZone(ZoneId.of(zone)).toLocalDate(), 1, Integer::sum);
        }
        IO.println(zone + " " + perDay);
    }
}
// Output:
// UTC {2026-10-12=1, 2026-10-13=2}
// Australia/Sydney {2026-10-13=2, 2026-10-14=1}

Discussion

  • Be the first to comment on this lesson.