Java Date-Time API (java.time) Complete Guide
If you've ever had the misfortune of working with java.util.Date
or java.util.Calendar
, you know the pain. Mutable objects, confusing APIs, months that start at zero (seriously?)—it was a mess. Thankfully, Java 8 gave us java.time
(JSR-310), and it's genuinely excellent.
This is the datetime API Java should've had from day one. Let's explore everything you need to know.
Overview of java.time Package
The java.time
package is beautifully designed. Each class has a specific purpose, and they compose together elegantly. Here's what you've got:
Core Classes:
Instant
- Point in time on the timeline (UTC)LocalDate
- Date without time or timezoneLocalTime
- Time without date or timezoneLocalDateTime
- Date-time without timezoneZonedDateTime
- Date-time with timezoneOffsetDateTime
- Date-time with UTC offset
Supporting Classes:
Duration
- Time-based amount (hours, minutes, seconds)Period
- Date-based amount (years, months, days)ZoneId
- Timezone identifierZoneOffset
- Offset from UTCDateTimeFormatter
- Formatting and parsing
Instant: The Foundation
Think of Instant
as your source of truth. It's a point in time on the UTC timeline—no timezone nonsense, no ambiguity. This is what you should be storing in your database.
import java.time.Instant;
// Current instant (UTC)
Instant now = Instant.now();
System.out.println(now); // 2024-01-15T19:00:00Z
// From Unix timestamp (seconds)
Instant fromSeconds = Instant.ofEpochSecond(1705341600L);
// From Unix timestamp (milliseconds)
Instant fromMillis = Instant.ofEpochMilli(1705341600000L);
// Get Unix timestamp
long epochSecond = now.getEpochSecond(); // 1705341600
long epochMilli = now.toEpochMilli(); // 1705341600000
// Add/subtract time
Instant later = now.plusSeconds(3600); // +1 hour
Instant earlier = now.minusSeconds(3600); // -1 hour
// Compare instants
boolean isBefore = now.isBefore(later); // true
boolean isAfter = now.isAfter(earlier); // true
Instant vs. Unix Timestamp
import java.time.Instant;
// Instant provides methods Date/Calendar don't
Instant instant = Instant.now();
// Precision: nanoseconds (not just milliseconds)
long nanos = instant.getNano();
// ISO 8601 formatting built-in
String iso = instant.toString(); // "2024-01-15T19:00:00Z"
// Parse ISO 8601
Instant parsed = Instant.parse("2024-01-15T19:00:00Z");
// Duration between instants
Instant start = Instant.now();
// ... do work ...
Instant end = Instant.now();
Duration duration = Duration.between(start, end);
System.out.println("Took: " + duration.toMillis() + "ms");
LocalDateTime: Date-Time Without Timezone
LocalDateTime
represents date-time without timezone information.
import java.time.LocalDateTime;
import java.time.Month;
// Current local date-time (system timezone)
LocalDateTime now = LocalDateTime.now();
// Create specific date-time
LocalDateTime dt = LocalDateTime.of(2024, 1, 15, 19, 0, 0);
LocalDateTime dt2 = LocalDateTime.of(2024, Month.JANUARY, 15, 19, 0);
// From components
LocalDate date = LocalDate.of(2024, 1, 15);
LocalTime time = LocalTime.of(19, 0, 0);
LocalDateTime combined = LocalDateTime.of(date, time);
// Parsing
LocalDateTime parsed = LocalDateTime.parse("2024-01-15T19:00:00");
// Formatting
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formatted = dt.format(formatter); // "2024-01-15 19:00:00"
// Arithmetic
LocalDateTime tomorrow = now.plusDays(1);
LocalDateTime nextWeek = now.plusWeeks(1);
LocalDateTime in2Hours = now.plusHours(2);
// Get components
int year = now.getYear();
Month month = now.getMonth();
int day = now.getDayOfMonth();
int hour = now.getHour();
When to Use LocalDateTime
// ✅ GOOD: User enters "January 15, 2024 at 2:00 PM" without timezone
LocalDateTime userInput = LocalDateTime.of(2024, 1, 15, 14, 0);
// ✅ GOOD: Business logic that doesn't care about timezones
LocalDateTime businessHoursStart = LocalDateTime.of(now.toLocalDate(), LocalTime.of(9, 0));
// ❌ BAD: Storing in database (use Instant or ZonedDateTime)
// ❌ BAD: API responses (ambiguous without timezone)
ZonedDateTime: Timezone-Aware DateTime
Here's where things get interesting. ZonedDateTime
combines date, time, and timezone—everything you need for user-facing timestamps. It knows about DST, it handles edge cases, and it just works.
import java.time.ZonedDateTime;
import java.time.ZoneId;
// Current time in specific timezone
ZonedDateTime nyNow = ZonedDateTime.now(ZoneId.of("America/New_York"));
ZonedDateTime tokyoNow = ZonedDateTime.now(ZoneId.of("Asia/Tokyo"));
System.out.println(nyNow); // 2024-01-15T14:00:00-05:00[America/New_York]
System.out.println(tokyoNow); // 2024-01-16T04:00:00+09:00[Asia/Tokyo]
// Create from components with timezone
ZonedDateTime zdt = ZonedDateTime.of(
2024, 1, 15, 19, 0, 0, 0,
ZoneId.of("America/New_York")
);
// From LocalDateTime + timezone
LocalDateTime local = LocalDateTime.of(2024, 1, 15, 19, 0);
ZonedDateTime withZone = local.atZone(ZoneId.of("America/New_York"));
// Convert between timezones
ZonedDateTime ny = ZonedDateTime.now(ZoneId.of("America/New_York"));
ZonedDateTime london = ny.withZoneSameInstant(ZoneId.of("Europe/London"));
ZonedDateTime tokyo = ny.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
// Same instant, different display
System.out.println("NY: " + ny); // 2024-01-15T14:00:00-05:00
System.out.println("London: " + london); // 2024-01-15T19:00:00Z
System.out.println("Tokyo: " + tokyo); // 2024-01-16T04:00:00+09:00
Handling DST with ZonedDateTime
import java.time.ZonedDateTime;
import java.time.ZoneId;
// Spring forward: March 10, 2024, 2:00 AM → 3:00 AM in New York
// Attempting to create 2:30 AM (doesn't exist)
ZoneId nyZone = ZoneId.of("America/New_York");
LocalDateTime nonExistent = LocalDateTime.of(2024, 3, 10, 2, 30);
ZonedDateTime adjusted = nonExistent.atZone(nyZone);
System.out.println(adjusted);
// 2024-03-10T03:30:00-04:00[America/New_York]
// Automatically moved to 3:30 AM EDT
// Fall back: November 3, 2024, 2:00 AM → 1:00 AM
// 1:30 AM occurs twice
LocalDateTime ambiguous = LocalDateTime.of(2024, 11, 3, 1, 30);
ZonedDateTime first = ambiguous.atZone(nyZone);
System.out.println(first);
// 2024-11-03T01:30:00-04:00[America/New_York]
// First occurrence (EDT)
ZonedDateTime vs. OffsetDateTime
// ZonedDateTime: Has timezone rules (knows about DST)
ZonedDateTime zdt = ZonedDateTime.now(ZoneId.of("America/New_York"));
// Displays: 2024-01-15T14:00:00-05:00[America/New_York]
// OffsetDateTime: Just UTC offset (no DST awareness)
OffsetDateTime odt = OffsetDateTime.now(ZoneOffset.ofHours(-5));
// Displays: 2024-01-15T14:00:00-05:00
// Use ZonedDateTime for future dates (handles DST)
// Use OffsetDateTime for past events or API responses
Working with Timezones
ZoneId and Available Timezones
import java.time.ZoneId;
import java.util.Set;
// System default timezone
ZoneId defaultZone = ZoneId.systemDefault();
System.out.println("System timezone: " + defaultZone);
// Specific timezone
ZoneId nyZone = ZoneId.of("America/New_York");
ZoneId londonZone = ZoneId.of("Europe/London");
// All available timezone IDs
Set<String> allZones = ZoneId.getAvailableZoneIds();
System.out.println("Total timezones: " + allZones.size()); // ~600
// Filter timezones
allZones.stream()
.filter(zone -> zone.contains("America/"))
.sorted()
.limit(10)
.forEach(System.out::println);
// Normalized timezone (handles aliases)
ZoneId normalized = ZoneId.of("US/Eastern");
System.out.println(normalized); // America/New_York
Converting Between Instant and ZonedDateTime
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.ZoneId;
// Instant → ZonedDateTime
Instant instant = Instant.now();
ZonedDateTime ny = instant.atZone(ZoneId.of("America/New_York"));
ZonedDateTime tokyo = instant.atZone(ZoneId.of("Asia/Tokyo"));
// ZonedDateTime → Instant
ZonedDateTime zdt = ZonedDateTime.now(ZoneId.of("America/New_York"));
Instant backToInstant = zdt.toInstant();
// Instant is always UTC, ZonedDateTime adds display timezone
Formatting and Parsing
DateTimeFormatter
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
LocalDateTime dt = LocalDateTime.of(2024, 1, 15, 19, 0, 0);
// Predefined formatters
String iso = dt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
// "2024-01-15T19:00:00"
String basic = dt.format(DateTimeFormatter.BASIC_ISO_DATE);
// "20240115"
// Custom patterns
DateTimeFormatter custom = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formatted = dt.format(custom);
// "2024-01-15 19:00:00"
DateTimeFormatter readable = DateTimeFormatter.ofPattern("MMMM dd, yyyy 'at' hh:mm a");
String readable = dt.format(readable);
// "January 15, 2024 at 07:00 PM"
// With timezone
ZonedDateTime zdt = ZonedDateTime.now(ZoneId.of("America/New_York"));
DateTimeFormatter withZone = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z");
String withTz = zdt.format(withZone);
// "2024-01-15 14:00:00 EST"
Pattern Syntax
// Common pattern symbols
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss.SSS Z z"
);
/*
yyyy = Year (4 digits) → 2024
MM = Month (01-12) → 01
dd = Day of month (01-31) → 15
HH = Hour (00-23) → 19
hh = Hour (01-12) → 07
mm = Minute (00-59) → 00
ss = Second (00-59) → 00
SSS = Millisecond → 123
a = AM/PM → PM
Z = Timezone offset → +0000
z = Timezone abbreviation → UTC
VV = Timezone ID → America/New_York
*/
Parsing Strings
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
// Parse ISO format
LocalDateTime parsed = LocalDateTime.parse("2024-01-15T19:00:00");
// Parse custom format
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime custom = LocalDateTime.parse("2024-01-15 19:00:00", formatter);
// Parse with timezone
ZonedDateTime zdt = ZonedDateTime.parse(
"2024-01-15T19:00:00-05:00[America/New_York]"
);
// Safe parsing with error handling
public static Optional<LocalDateTime> parseDateTime(String dateStr) {
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return Optional.of(LocalDateTime.parse(dateStr, formatter));
} catch (DateTimeParseException e) {
System.err.println("Failed to parse: " + dateStr);
return Optional.empty();
}
}
Duration and Period
Duration: Time-Based Amount
import java.time.Duration;
import java.time.Instant;
// Create duration
Duration oneHour = Duration.ofHours(1);
Duration twoMinutes = Duration.ofMinutes(2);
Duration fiveSeconds = Duration.ofSeconds(5);
Duration complex = Duration.ofHours(2).plusMinutes(30).plusSeconds(15);
// Between two instants
Instant start = Instant.now();
// ... do work ...
Instant end = Instant.now();
Duration elapsed = Duration.between(start, end);
System.out.println("Milliseconds: " + elapsed.toMillis());
System.out.println("Seconds: " + elapsed.getSeconds());
System.out.println("Minutes: " + elapsed.toMinutes());
// Arithmetic
Duration doubled = oneHour.multipliedBy(2); // 2 hours
Duration half = oneHour.dividedBy(2); // 30 minutes
Duration sum = oneHour.plus(twoMinutes); // 1 hour 2 minutes
// Comparison
boolean isLonger = oneHour.compareTo(twoMinutes) > 0; // true
Period: Date-Based Amount
import java.time.Period;
import java.time.LocalDate;
// Create period
Period oneYear = Period.ofYears(1);
Period twoMonths = Period.ofMonths(2);
Period thirtyDays = Period.ofDays(30);
Period complex = Period.of(1, 2, 15); // 1 year, 2 months, 15 days
// Between two dates
LocalDate start = LocalDate.of(2024, 1, 15);
LocalDate end = LocalDate.of(2025, 3, 20);
Period period = Period.between(start, end);
System.out.println(period.getYears()); // 1
System.out.println(period.getMonths()); // 2
System.out.println(period.getDays()); // 5
// Add to date
LocalDate future = start.plus(oneYear).plus(twoMonths);
Duration vs. Period
// Duration: Time-based (hours, minutes, seconds)
Duration duration = Duration.ofHours(24);
// Period: Date-based (years, months, days)
Period period = Period.ofDays(1);
// Important: They're NOT interchangeable!
LocalDateTime dt = LocalDateTime.now();
dt.plus(Duration.ofHours(24)); // ✅ Works
dt.plus(Period.ofDays(1)); // ✅ Works
// But semantics differ with DST:
// Duration: Exactly 24 hours
// Period: 1 calendar day (might be 23 or 25 hours with DST!)
Migrating from Legacy Date/Calendar
Old vs. New API
// ❌ OLD (java.util.Date)
Date oldDate = new Date();
long timestamp = oldDate.getTime();
// ✅ NEW (java.time.Instant)
Instant instant = Instant.now();
long timestamp = instant.toEpochMilli();
// ❌ OLD (java.util.Calendar)
Calendar calendar = Calendar.getInstance();
calendar.set(2024, Calendar.JANUARY, 15, 19, 0, 0);
Date date = calendar.getTime();
// ✅ NEW (java.time.LocalDateTime)
LocalDateTime dt = LocalDateTime.of(2024, 1, 15, 19, 0, 0);
Converting Between Old and New
import java.util.Date;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
// Date → Instant
Date oldDate = new Date();
Instant instant = oldDate.toInstant();
// Instant → Date
Instant instant = Instant.now();
Date newDate = Date.from(instant);
// Date → LocalDateTime
Date date = new Date();
LocalDateTime ldt = date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
// LocalDateTime → Date
LocalDateTime ldt = LocalDateTime.now();
Date date = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
Database Integration
JDBC with java.time
import java.sql.*;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
// Storing Instant (recommended)
public void saveEvent(Connection conn, String title, Instant scheduledAt) throws SQLException {
String sql = "INSERT INTO events (title, scheduled_at) VALUES (?, ?)";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, title);
stmt.setTimestamp(2, Timestamp.from(scheduledAt));
stmt.executeUpdate();
}
}
// Retrieving as Instant
public Instant getScheduledTime(Connection conn, long eventId) throws SQLException {
String sql = "SELECT scheduled_at FROM events WHERE id = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setLong(1, eventId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
Timestamp ts = rs.getTimestamp(1);
return ts.toInstant();
}
}
}
return null;
}
// Storing ZonedDateTime
public void saveWithTimezone(Connection conn, ZonedDateTime zdt) throws SQLException {
String sql = "INSERT INTO events (scheduled_at, timezone) VALUES (?, ?)";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setTimestamp(1, Timestamp.from(zdt.toInstant()));
stmt.setString(2, zdt.getZone().getId());
stmt.executeUpdate();
}
}
JPA/Hibernate
import javax.persistence.*;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
@Entity
@Table(name = "events")
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
// Instant (recommended for UTC storage)
@Column(name = "scheduled_at")
private Instant scheduledAt;
// LocalDateTime (no timezone info)
@Column(name = "local_time")
private LocalDateTime localTime;
// ZonedDateTime requires custom converter
@Column(name = "zoned_time")
@Convert(converter = ZonedDateTimeConverter.class)
private ZonedDateTime zonedTime;
// Getters and setters...
}
// Custom converter for ZonedDateTime
@Converter
public class ZonedDateTimeConverter implements AttributeConverter<ZonedDateTime, Timestamp> {
@Override
public Timestamp convertToDatabaseColumn(ZonedDateTime zonedDateTime) {
return (zonedDateTime == null) ? null : Timestamp.from(zonedDateTime.toInstant());
}
@Override
public ZonedDateTime convertToEntityAttribute(Timestamp timestamp) {
return (timestamp == null) ? null :
ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC"));
}
}
Testing with Clock
This is my favorite part of java.time
: the Clock
interface. It makes testing time-dependent code trivial.
Injecting Clock for Testability
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
// ❌ BAD: Hard to test
public class SessionService {
public boolean isExpired(Instant expiresAt) {
return Instant.now().isAfter(expiresAt);
}
}
// ✅ GOOD: Injectable clock
public class SessionService {
private final Clock clock;
public SessionService(Clock clock) {
this.clock = clock;
}
public boolean isExpired(Instant expiresAt) {
return Instant.now(clock).isAfter(expiresAt);
}
}
// Test with fixed clock
@Test
public void testExpiry() {
// Fix time to 2024-01-15 19:00:00 UTC
Instant fixed = Instant.parse("2024-01-15T19:00:00Z");
Clock fixedClock = Clock.fixed(fixed, ZoneId.of("UTC"));
SessionService service = new SessionService(fixedClock);
Instant expiresAt = fixed.plusSeconds(3600); // Expires in 1 hour
assertFalse(service.isExpired(expiresAt));
// Advance clock by 2 hours
Clock advancedClock = Clock.offset(fixedClock, Duration.ofHours(2));
SessionService advancedService = new SessionService(advancedClock);
assertTrue(advancedService.isExpired(expiresAt));
}
Mock Clock for Tests
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
public class MockClock extends Clock {
private Instant instant;
private final ZoneId zone;
public MockClock(Instant instant, ZoneId zone) {
this.instant = instant;
this.zone = zone;
}
@Override
public Instant instant() {
return instant;
}
@Override
public ZoneId getZone() {
return zone;
}
@Override
public Clock withZone(ZoneId zone) {
return new MockClock(instant, zone);
}
public void advance(Duration duration) {
instant = instant.plus(duration);
}
public void setInstant(Instant instant) {
this.instant = instant;
}
}
// Usage in tests
@Test
public void testWithMockClock() {
MockClock clock = new MockClock(
Instant.parse("2024-01-15T19:00:00Z"),
ZoneId.of("UTC")
);
SessionService service = new SessionService(clock);
// Initial state
assertTrue(service.isValid());
// Advance time
clock.advance(Duration.ofHours(2));
// Check new state
assertFalse(service.isValid());
}
Best Practices
1. Use Instant for Storage
// ✅ CORRECT: Store as Instant (UTC)
@Entity
public class Event {
@Column(name = "created_at")
private Instant createdAt;
// Display in user's timezone
public ZonedDateTime getCreatedAtIn(ZoneId zone) {
return createdAt.atZone(zone);
}
}
2. Use ZonedDateTime for Calculations
// ✅ CORRECT: ZonedDateTime handles DST
public ZonedDateTime scheduleNextWeek(ZonedDateTime current) {
return current.plusWeeks(1); // DST-aware
}
// ❌ WRONG: Duration doesn't account for DST
public Instant scheduleNextWeek(Instant current) {
return current.plus(Duration.ofDays(7)); // Might be off by 1 hour
}
3. Always Specify Timezone
// ❌ BAD: Implicit system timezone
LocalDateTime.now();
// ✅ GOOD: Explicit timezone
ZonedDateTime.now(ZoneId.of("America/New_York"));
// ✅ GOOD: UTC
Instant.now();
4. Validate Datetime Inputs
public Instant parseTimestamp(String input) {
try {
// Try parsing as ISO instant
return Instant.parse(input);
} catch (DateTimeParseException e) {
// Try parsing as epoch seconds
try {
long seconds = Long.parseLong(input);
return Instant.ofEpochSecond(seconds);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("Invalid timestamp: " + input);
}
}
}
Common Pitfalls
1. Mixing UTC and Local Time
// ❌ WRONG
Instant instant = Instant.now();
LocalDateTime local = LocalDateTime.now();
// Can't compare directly!
// instant.compareTo(local); // Compile error
// ✅ CORRECT: Convert to same type
ZonedDateTime zonedFromInstant = instant.atZone(ZoneId.systemDefault());
ZonedDateTime zonedFromLocal = local.atZone(ZoneId.systemDefault());
boolean isBefore = zonedFromInstant.isBefore(zonedFromLocal);
2. Forgetting Nanosecond Precision
// Instant has nanosecond precision
Instant instant = Instant.now();
System.out.println(instant.getEpochSecond()); // Seconds
System.out.println(instant.getNano()); // Nanosecond fraction
// Be careful when comparing
Instant i1 = Instant.ofEpochSecond(1705341600, 123456789);
Instant i2 = Instant.ofEpochSecond(1705341600, 987654321);
System.out.println(i1.getEpochSecond()); // Same: 1705341600
System.out.println(i1.equals(i2)); // false (different nanos)
3. Using LocalDateTime for API Responses
// ❌ WRONG: Ambiguous without timezone
@GetMapping("/event")
public Event getEvent() {
return new Event(
"Meeting",
LocalDateTime.now() // Which timezone?
);
}
// ✅ CORRECT: Use Instant or include timezone
@GetMapping("/event")
public Event getEvent() {
return new Event(
"Meeting",
Instant.now(), // UTC
ZoneId.systemDefault().getId() // Timezone info
);
}
Conclusion
Java finally got datetime right with java.time
. Seriously—it's one of the best datetime APIs in any mainstream language.
What You Should Remember:
Store Instant
in databases (always UTC). Use ZonedDateTime
when you need timezone-aware calculations or display. Inject Clock
to make your code testable. And please, please never use java.util.Date
or Calendar
in new code—they're legacy artifacts, and they should stay that way.
The Golden Rules:
Always specify timezone explicitly. Validate datetime inputs. Let ZonedDateTime
handle DST for you. Trust the immutability of java.time
classes—they won't bite you with unexpected mutations.
The best Java codebases I've seen all follow these patterns religiously. Start using java.time
properly from day one, and you'll never have to debug a subtle timezone bug at 2am again.
Further Reading
- Complete Guide to Unix Timestamps - Understand epoch time
- Timezone Conversion Best Practices - Cross-language timezone patterns
- Testing Time-Dependent Code - Test Java datetime with Clock
- API Design: Timestamp Formats - Build Java APIs with timestamps
- Database Timestamp Storage - Store Java timestamps in databases
Building Java applications? Contact us for consultation on datetime handling.