Instant and Timestamps
Record exact moments with Instant, convert to and from epoch timestamps, and keep precision consistent between Java and the database.
Instant 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
isBeforeandisAfterfor expiry checks;Instantalso implementsComparablefor sorting. - JDBC drivers map
TIMESTAMP WITH TIME ZONEtoOffsetDateTime; calltoInstant()on the result. Writing withsetObject(i, instant.atOffset(ZoneOffset.UTC))works with the major drivers. - Never derive an
Instantfrom aLocalDateTimewithout 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
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.
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=falseThe 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.
// 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: trueBucketing 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.
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