Contents

Before Java 8, developers relied on java.util.Date and java.util.Calendar for date/time work. These classes had fundamental design flaws that made them error-prone and frustrating to use:

// Legacy API pitfalls Date date = new Date(2024, 3, 15); // Actually year 3924, month April (0-indexed!) Calendar cal = Calendar.getInstance(); cal.set(Calendar.MONTH, 3); // This is April, not March // Mutable — anyone holding a reference can silently change your date date.setTime(0L); // Now it's epoch, Jan 1 1970

The java.time package (JSR 310) was designed by Stephen Colebourne, the creator of Joda-Time, working alongside Oracle. It adopts the best ideas from Joda-Time while fixing its shortcomings. Every class in java.time follows these principles:

Never use java.util.Date or Calendar in new code. The entire java.time package is available from Java 8 onwards, and back-ported to Java 6/7 via the ThreeTen-Backport library.

LocalDate represents a date without time or time zone — a year, month, and day. It is the right choice for birthdays, holidays, contract dates, or any value where the time of day is irrelevant.

import java.time.LocalDate; import java.time.Month; import java.time.DayOfWeek; // Creating LocalDate instances LocalDate today = LocalDate.now(); // e.g. 2024-11-20 LocalDate specific = LocalDate.of(2024, 3, 15); // 2024-03-15 LocalDate withEnum = LocalDate.of(2024, Month.MARCH, 15); LocalDate parsed = LocalDate.parse("2024-03-15"); // ISO format // Extracting parts int year = today.getYear(); // 2024 Month month = today.getMonth(); // NOVEMBER (enum) int monthValue = today.getMonthValue(); // 11 (1-indexed, unlike legacy API!) int dayOfMonth = today.getDayOfMonth(); // 20 DayOfWeek dow = today.getDayOfWeek(); // WEDNESDAY int dayOfYear = today.getDayOfYear(); // 325 // Date arithmetic — always returns a NEW LocalDate LocalDate nextWeek = today.plusDays(7); LocalDate lastMonth = today.minusMonths(1); LocalDate nextYear = today.plusYears(1); // Comparisons boolean isBefore = specific.isBefore(today); // true boolean isAfter = specific.isAfter(today); // false boolean isEqual = specific.isEqual(LocalDate.of(2024, 3, 15)); // true // Useful utility methods int daysInMonth = today.lengthOfMonth(); // 30 (November) int daysInYear = today.lengthOfYear(); // 366 (2024 is a leap year) boolean leap = today.isLeapYear(); // true // Replacing parts with 'with' methods LocalDate firstOfMonth = today.withDayOfMonth(1); // 2024-11-01 LocalDate inJuly = today.withMonth(7); // 2024-07-20 Months in java.time are 1-indexed: January = 1, December = 12. This eliminates the most common bug from the legacy Calendar API where January was 0.

LocalTime represents a time of day without a date or time zone — hours, minutes, seconds, and nanoseconds. Use it for store opening hours, alarm times, or any time-of-day concept.

import java.time.LocalTime; import java.time.temporal.ChronoUnit; // Creating LocalTime instances LocalTime now = LocalTime.now(); // e.g. 14:30:45.123456789 LocalTime morning = LocalTime.of(9, 30); // 09:30 LocalTime precise = LocalTime.of(14, 30, 45); // 14:30:45 LocalTime withNanos = LocalTime.of(14, 30, 45, 500_000_000); // 14:30:45.5 LocalTime parsed = LocalTime.parse("09:30:00"); // 09:30 // Useful constants LocalTime midnight = LocalTime.MIDNIGHT; // 00:00 LocalTime noon = LocalTime.NOON; // 12:00 LocalTime max = LocalTime.MAX; // 23:59:59.999999999 LocalTime min = LocalTime.MIN; // 00:00 // Extracting parts int hour = now.getHour(); // 0-23 int minute = now.getMinute(); // 0-59 int second = now.getSecond(); // 0-59 int nano = now.getNano(); // 0-999999999 // Arithmetic LocalTime later = now.plusHours(2).plusMinutes(30); LocalTime earlier = now.minusMinutes(45); // Truncation — very useful for rounding LocalTime toMinutes = now.truncatedTo(ChronoUnit.MINUTES); // 14:30 LocalTime toHours = now.truncatedTo(ChronoUnit.HOURS); // 14:00 // Comparisons boolean isOpen = now.isAfter(LocalTime.of(9, 0)) && now.isBefore(LocalTime.of(17, 0)); // business hours check LocalTime has nanosecond precision (9 decimal places), but most system clocks only provide microsecond or millisecond resolution. Use truncatedTo(ChronoUnit.MILLIS) when you need consistent precision for comparisons or storage.

LocalDateTime combines a LocalDate and a LocalTime into a single object. It represents a date-time without any time zone information. Use it for local events, scheduling within a single time zone, or log timestamps where the zone is implied.

import java.time.LocalDate; import java.time.LocalTime; import java.time.LocalDateTime; // Creating LocalDateTime instances LocalDateTime now = LocalDateTime.now(); LocalDateTime specific = LocalDateTime.of(2024, 12, 25, 10, 30); // Christmas morning LocalDateTime parsed = LocalDateTime.parse("2024-12-25T10:30:00"); // ISO format // Combining from LocalDate and LocalTime LocalDate date = LocalDate.of(2024, 12, 25); LocalTime time = LocalTime.of(10, 30); LocalDateTime combined = LocalDateTime.of(date, time); LocalDateTime atTime = date.atTime(10, 30); // same result LocalDateTime atDate = time.atDate(date); // same result // Extracting the date and time parts LocalDate datePart = now.toLocalDate(); LocalTime timePart = now.toLocalTime(); // Arithmetic works the same way LocalDateTime meeting = now.plusDays(3).withHour(14).withMinute(0); // Practical example: scheduling LocalDateTime deadline = LocalDateTime.of(2024, 12, 31, 23, 59, 59); boolean isOverdue = LocalDateTime.now().isAfter(deadline); LocalDateTime does NOT represent a specific moment in time. The same LocalDateTime of "2024-12-25T10:30" means a different instant in New York vs. Tokyo. If you need to track a precise moment (API timestamps, event logs across time zones, database records), use ZonedDateTime, OffsetDateTime, or Instant instead.

When you need a date-time anchored to a specific time zone, use ZonedDateTime. It wraps a LocalDateTime with a ZoneId and handles daylight saving time (DST) transitions automatically. OffsetDateTime is similar but uses a fixed UTC offset instead of a named zone, making it ideal for database storage and wire protocols.

import java.time.*; // Creating ZonedDateTime ZonedDateTime nowNY = ZonedDateTime.now(ZoneId.of("America/New_York")); ZonedDateTime nowTokyo = ZonedDateTime.now(ZoneId.of("Asia/Tokyo")); ZonedDateTime nowUTC = ZonedDateTime.now(ZoneOffset.UTC); // From a LocalDateTime + zone LocalDateTime ldt = LocalDateTime.of(2024, 7, 4, 12, 0); ZonedDateTime july4 = ldt.atZone(ZoneId.of("America/New_York")); // 2024-07-04T12:00-04:00[America/New_York] (EDT in summer) // Converting between time zones ZonedDateTime inLondon = july4.withZoneSameInstant(ZoneId.of("Europe/London")); // 2024-07-04T17:00+01:00[Europe/London] (BST in summer) // OffsetDateTime — fixed offset, no DST rules OffsetDateTime odt = OffsetDateTime.now(ZoneOffset.ofHours(5)); // UTC+05:00 OffsetDateTime fromZoned = july4.toOffsetDateTime(); // Checking available zone IDs Set<String> zones = ZoneId.getAvailableZoneIds(); // ~600 zones

Common time zone IDs you will use frequently:

Zone IDDescriptionUTC Offset (approx.)
America/New_YorkUS EasternUTC-5 / UTC-4 (DST)
America/ChicagoUS CentralUTC-6 / UTC-5 (DST)
America/DenverUS MountainUTC-7 / UTC-6 (DST)
America/Los_AngelesUS PacificUTC-8 / UTC-7 (DST)
Europe/LondonUK / GMT / BSTUTC+0 / UTC+1 (DST)
Europe/ParisCentral EuropeUTC+1 / UTC+2 (DST)
Asia/TokyoJapan (no DST)UTC+9
Asia/KolkataIndia (no DST)UTC+5:30
Australia/SydneyAustralia EasternUTC+10 / UTC+11 (DST)
UTCCoordinated Universal TimeUTC+0

DST transition example:

// US Eastern: clocks spring forward on March 10, 2024 at 2:00 AM // 2:30 AM does not exist — it jumps from 1:59 AM to 3:00 AM LocalDateTime gapTime = LocalDateTime.of(2024, 3, 10, 2, 30); ZonedDateTime adjusted = gapTime.atZone(ZoneId.of("America/New_York")); // Adjusted to 2024-03-10T03:30-04:00[America/New_York] (pushed forward) // US Eastern: clocks fall back on November 3, 2024 at 2:00 AM // 1:30 AM exists TWICE — once in EDT and once in EST LocalDateTime overlapTime = LocalDateTime.of(2024, 11, 3, 1, 30); ZonedDateTime earlier = overlapTime.atZone(ZoneId.of("America/New_York")); // 2024-11-03T01:30-04:00[America/New_York] (EDT, first occurrence) ZonedDateTime later = earlier.withLaterOffsetAtOverlap(); // 2024-11-03T01:30-05:00[America/New_York] (EST, second occurrence) DST transitions can cause surprises. When clocks spring forward, a LocalDateTime in the gap (e.g., 2:30 AM on spring-forward day) will be adjusted automatically by atZone(). When clocks fall back, the same local time exists twice — ZonedDateTime picks the earlier occurrence by default. Use withLaterOffsetAtOverlap() or withEarlierOffsetAtOverlap() to control which one you get. Always test your code around DST boundaries.

When to use which:

Instant represents a single point on the timeline — the number of seconds (and nanoseconds) since the Unix epoch (1970-01-01T00:00:00Z). It has no time zone and no human-readable fields. Think of it as the machine's view of time: perfect for timestamps, measuring elapsed time, and interoperating with legacy APIs.

import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; // Creating Instant instances Instant now = Instant.now(); // current UTC timestamp Instant fromEpoch = Instant.ofEpochSecond(1_700_000_000L); // 2023-11-14T22:13:20Z Instant fromMillis = Instant.ofEpochMilli(1_700_000_000_000L); Instant parsed = Instant.parse("2024-03-15T10:30:00Z"); // must include Z or offset // Epoch values long epochSec = now.getEpochSecond(); // seconds since 1970-01-01T00:00:00Z long epochMilli = now.toEpochMilli(); // milliseconds since epoch // Arithmetic Instant fiveMinutesLater = now.plusSeconds(300); Instant yesterday = now.minus(java.time.Duration.ofDays(1)); // Converting Instant to human-readable ZonedDateTime ZonedDateTime zdt = now.atZone(ZoneId.of("America/New_York")); // Interop with legacy java.util.Date java.util.Date legacyDate = java.util.Date.from(now); // Instant -> Date Instant backToInstant = legacyDate.toInstant(); // Date -> Instant

Comparing and measuring with Instant:

import java.time.Instant; import java.time.Duration; // Measuring elapsed time — ideal for benchmarking Instant start = Instant.now(); // ... some operation ... Instant end = Instant.now(); Duration elapsed = Duration.between(start, end); System.out.println("Took " + elapsed.toMillis() + " ms"); // Comparisons Instant a = Instant.parse("2024-01-01T00:00:00Z"); Instant b = Instant.parse("2024-06-15T12:00:00Z"); boolean aIsFirst = a.isBefore(b); // true boolean same = a.equals(b); // false // Constants Instant epoch = Instant.EPOCH; // 1970-01-01T00:00:00Z Instant max = Instant.MAX; // +1000000000-12-31T23:59:59.999999999Z Instant is the bridge between java.time and the legacy Date API. Whenever you receive a java.util.Date from an older library, call date.toInstant() first, then convert to whatever java.time type you need.

Duration measures time-based amounts (hours, minutes, seconds, nanoseconds) while Period measures date-based amounts (years, months, days). The distinction matters: adding a Period of one month to January 31 yields February 28/29, while a Duration always adds an exact number of seconds.

import java.time.*; import java.time.temporal.ChronoUnit; // Duration — time-based (hours, minutes, seconds) Duration twoHours = Duration.ofHours(2); Duration thirtyMin = Duration.ofMinutes(30); Duration fiveSeconds = Duration.ofSeconds(5); Duration complex = Duration.parse("PT2H30M"); // ISO-8601: 2 hours 30 minutes // Duration between two instants or local times LocalTime start = LocalTime.of(9, 0); LocalTime end = LocalTime.of(17, 30); Duration workDay = Duration.between(start, end); // PT8H30M long totalMinutes = workDay.toMinutes(); // 510 // Period — date-based (years, months, days) Period oneMonth = Period.ofMonths(1); Period twoWeeks = Period.ofWeeks(2); // stored as 14 days Period complex2 = Period.of(1, 6, 15); // 1 year, 6 months, 15 days Period parsed = Period.parse("P1Y6M15D"); // ISO-8601 // Period between two dates LocalDate hire = LocalDate.of(2020, 1, 15); LocalDate now = LocalDate.of(2024, 11, 20); Period tenure = Period.between(hire, now); // 4 years, 10 months, 5 days System.out.println(tenure.getYears()); // 4 System.out.println(tenure.getMonths()); // 10 System.out.println(tenure.getDays()); // 5 // ChronoUnit.between() for a single-unit count long daysBetween = ChronoUnit.DAYS.between(hire, now); // 1771 long monthsBetween = ChronoUnit.MONTHS.between(hire, now); // 58 // Using Duration and Period LocalDateTime meeting = LocalDateTime.now().plus(Duration.ofHours(2)); LocalDate renewal = LocalDate.now().plus(Period.ofYears(1)); Period.between() returns years, months, and days as separate fields — they do not collapse. To get the total number of days between two dates, use ChronoUnit.DAYS.between() instead. Similarly, Duration.between() returns a total seconds value, not separate hour/minute/second fields.

DateTimeFormatter handles both formatting (date to string) and parsing (string to date). Unlike the legacy SimpleDateFormat, it is immutable and thread-safe — you can safely declare formatters as static final constants and share them across threads.

import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Locale; LocalDateTime now = LocalDateTime.of(2024, 11, 20, 14, 30, 0); // Predefined ISO formatters String iso = now.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); // 2024-11-20T14:30:00 // Custom patterns DateTimeFormatter custom = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); String formatted = now.format(custom); // 2024-11-20 14:30 DateTimeFormatter usStyle = DateTimeFormatter.ofPattern("MM/dd/yyyy hh:mm a"); String usFormatted = now.format(usStyle); // 11/20/2024 02:30 PM // Localized formatters DateTimeFormatter german = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG) .withLocale(Locale.GERMANY); String germanDate = LocalDate.of(2024, 11, 20).format(german); // 20. November 2024 // Parsing strings to dates LocalDate parsed = LocalDate.parse("2024-03-15"); // uses ISO_LOCAL_DATE by default LocalDate custom2 = LocalDate.parse("03/15/2024", DateTimeFormatter.ofPattern("MM/dd/yyyy")); LocalDateTime parsedDT = LocalDateTime.parse("2024-11-20 14:30", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));

Common format pattern letters:

SymbolMeaningExample
yyyy4-digit year2024
yy2-digit year24
MMMonth (01-12)03
MMMMonth abbreviationMar
MMMMFull month nameMarch
ddDay of month (01-31)15
EEEDay of week abbreviationFri
EEEEFull day of weekFriday
HHHour 24h (00-23)14
hhHour 12h (01-12)02
mmMinute (00-59)30
ssSecond (00-59)45
SSSMillisecond (000-999)123
aAM/PM markerPM
zTime zone nameEST
ZZone offset-0500
VVTime zone IDAmerica/New_York
DateTimeFormatter is immutable and thread-safe. Declare your formatters as static final fields and reuse them throughout your application — there is no need to create a new instance each time, unlike SimpleDateFormat which required per-thread instances or synchronization.

TemporalAdjusters provides reusable strategies for common date manipulations — finding the next Monday, the last day of the month, or the first Friday in a month. They plug into the with() method of any date class.

import java.time.LocalDate; import java.time.DayOfWeek; import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjuster; import java.time.temporal.Temporal; LocalDate date = LocalDate.of(2024, 11, 20); // a Wednesday // Built-in adjusters LocalDate firstDay = date.with(TemporalAdjusters.firstDayOfMonth()); // 2024-11-01 LocalDate lastDay = date.with(TemporalAdjusters.lastDayOfMonth()); // 2024-11-30 LocalDate firstOfYear = date.with(TemporalAdjusters.firstDayOfYear()); // 2024-01-01 LocalDate nextMonth = date.with(TemporalAdjusters.firstDayOfNextMonth()); // 2024-12-01 // Day-of-week adjusters LocalDate nextMon = date.with(TemporalAdjusters.next(DayOfWeek.MONDAY)); // 2024-11-25 LocalDate prevFri = date.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY)); // 2024-11-15 LocalDate nextOrSame = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.WEDNESDAY)); // 2024-11-20 // Ordinal day-of-week in month LocalDate firstFri = date.with(TemporalAdjusters.firstInMonth(DayOfWeek.FRIDAY)); // 2024-11-01 LocalDate thirdMon = date.with(TemporalAdjusters.dayOfWeekInMonth(3, DayOfWeek.MONDAY)); // 2024-11-18 // Custom TemporalAdjuster: next working day TemporalAdjuster nextWorkingDay = temporal -> { LocalDate d = LocalDate.from(temporal); DayOfWeek dow = d.getDayOfWeek(); if (dow == DayOfWeek.FRIDAY) return d.plusDays(3); if (dow == DayOfWeek.SATURDAY) return d.plusDays(2); return d.plusDays(1); }; LocalDate workDay = date.with(nextWorkingDay); // 2024-11-21 (Thursday) // Chaining adjusters for complex logic // Last working day of the month LocalDate lastWorkingDay = date .with(TemporalAdjusters.lastDayOfMonth()) .with(temporal -> { LocalDate d = LocalDate.from(temporal); DayOfWeek day = d.getDayOfWeek(); if (day == DayOfWeek.SATURDAY) return d.minusDays(1); if (day == DayOfWeek.SUNDAY) return d.minusDays(2); return d; }); // 2024-11-29 (Friday, since Nov 30 is Saturday) Custom TemporalAdjuster implementations are a clean way to encapsulate business date logic (payment dates, settlement dates, next business day). Store them as static final fields or in a utility class so they can be reused and tested independently.

Most real-world projects have legacy code, JDBC drivers, or libraries that still use java.util.Date, Calendar, or java.sql types. Java 8 added bridge methods to make conversions straightforward.

Conversion cheat sheet:

From (Legacy)To (java.time)Code
java.util.Date Instant date.toInstant()
Instant java.util.Date Date.from(instant)
java.util.Calendar ZonedDateTime cal.toInstant().atZone(cal.getTimeZone().toZoneId())
ZonedDateTime java.util.Calendar GregorianCalendar.from(zdt)
java.sql.Date LocalDate sqlDate.toLocalDate()
LocalDate java.sql.Date java.sql.Date.valueOf(localDate)
java.sql.Timestamp LocalDateTime timestamp.toLocalDateTime()
LocalDateTime java.sql.Timestamp Timestamp.valueOf(localDateTime)
import java.time.*; import java.util.Date; import java.util.Calendar; import java.util.GregorianCalendar; // java.util.Date <-> Instant Date legacyDate = new Date(); Instant instant = legacyDate.toInstant(); Date backToDate = Date.from(instant); // Calendar <-> ZonedDateTime Calendar cal = Calendar.getInstance(); ZonedDateTime zdt = cal.toInstant().atZone(cal.getTimeZone().toZoneId()); GregorianCalendar gcal = GregorianCalendar.from(zdt); // java.sql.Date <-> LocalDate java.sql.Date sqlDate = java.sql.Date.valueOf(LocalDate.now()); LocalDate localDate = sqlDate.toLocalDate(); // java.sql.Timestamp <-> LocalDateTime java.sql.Timestamp ts = java.sql.Timestamp.valueOf(LocalDateTime.now()); LocalDateTime ldt = ts.toLocalDateTime();

JPA / Hibernate: Since Hibernate 5.x and JPA 2.2, java.time types are supported natively — no converters needed. Map your entity fields directly:

@Entity public class Event { @Column(name = "event_date") private LocalDate eventDate; // maps to DATE column @Column(name = "start_time") private LocalTime startTime; // maps to TIME column @Column(name = "created_at") private LocalDateTime createdAt; // maps to TIMESTAMP column @Column(name = "scheduled_at") private OffsetDateTime scheduledAt; // maps to TIMESTAMP WITH TIME ZONE @Column(name = "duration") private Duration duration; // maps to BIGINT (nanoseconds) in Hibernate 6+ }

Jackson serialization: Register the JavaTimeModule to serialize java.time types as ISO-8601 strings instead of arrays:

// Add dependency: com.fasterxml.jackson.datatype:jackson-datatype-jsr310 import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // Now LocalDate serializes as "2024-11-20" instead of [2024, 11, 20]

Quick decision guide — which type should I use?

Best practices summary:

ScenarioUseWhy
Birthday, holiday, contract dateLocalDateNo time or zone needed
Store opening hours, alarm timeLocalTimeNo date or zone needed
Local event (same timezone)LocalDateTimeNo zone ambiguity
Meeting across time zonesZonedDateTimeDST-aware scheduling
REST API / database storageOffsetDateTimeFixed offset, no DST rules
Machine timestamp, elapsed timeInstantEpoch-based, zone-free
Time elapsed (hours/seconds)DurationExact time-based amount
Calendar periods (months/years)PeriodDate-based, handles variable month lengths
When storing timestamps in a database, always use TIMESTAMP WITH TIME ZONE (or OffsetDateTime in Java) rather than TIMESTAMP without a zone. A zoneless timestamp loses offset information and becomes ambiguous when read by applications in different time zones or after a server migration.