Java Date and Time

Working with dates and times is one of those things every real program eventually needs, whether you’re logging an event, scheduling a task, or calculating someone’s age. Java’s original date and time tools (java.util.Date and Calendar) were notoriously confusing and error-prone, so as of Java 8 the language ships a completely redesigned API in the java.time package. This lesson teaches that modern API from the ground up: how it represents dates and times internally, how to create, format, parse, and compare them, and the mistakes almost every developer makes the first time they touch it.

Overview: How Java Represents Dates and Times

The java.time package (introduced by JSR-310) was built to fix three long-standing problems with the old API. First, java.util.Date and Calendar were mutable, so passing a date object to another method meant that method could silently change it out from under you. Second, they were not thread-safe, which caused subtle bugs in concurrent code. Third, their design mixed up concerns: a Date actually represented an instant in time (milliseconds since 1970), but it printed itself using the local time zone by default, which confused countless developers. The new API solves all three problems: every class in java.time is immutable and thread-safe, and each class has one clear job.

The core classes you’ll use constantly are LocalDate (a date with no time or time zone, like a birthday), LocalTime (a time with no date or zone, like “14:30”), and LocalDateTime (a date and time combined, still with no time zone). When time zones matter, you reach for ZonedDateTime, and for a single point on the global timeline (useful for timestamps and logging) you use Instant. Because every one of these objects is immutable, every method that appears to “change” a date—like adding a day—actually returns a brand-new object and leaves the original untouched. This is the single most important mental model to carry into this lesson, and it is also the source of the most common bug beginners write, which we’ll cover in Common Mistakes.

Internally, a LocalDate is stored as three integers (year, month, day) but is designed around the proleptic ISO-8601 calendar system, and many of its calculations use an internal “epoch day” count—the number of days since January 1, 1970—to make arithmetic like “add 40 days” fast and simple. A LocalTime is stored as an hour, minute, second, and nanosecond. Formatting and parsing are handled by a separate class, DateTimeFormatter, which (unlike its error-prone predecessor SimpleDateFormat) is immutable and thread-safe, so a single formatter instance can safely be shared and reused across your whole application, even from multiple threads at once.

Syntax

There is no single “syntax” for date and time the way there is for a loop, but there is a consistent pattern across every class: you obtain an instance using a static factory method (never new), and you transform it using instance methods that return a new object.

ClassName instance = ClassName.now();            // current value
ClassName instance = ClassName.of(...);           // specific value
ClassName changed  = instance.plusX(n);            // returns a NEW object
String text        = instance.format(formatter);   // to text
ClassName parsed    = ClassName.parse(text, formatter); // from text
Class Represents Example
LocalDate Date only, no time or zone 2024-03-15
LocalTime Time only, no date or zone 14:30:00
LocalDateTime Date and time, no zone 2024-03-15T14:30
ZonedDateTime Date, time, and time zone 2024-03-15T14:30+01:00[Europe/Paris]
Instant A single point on the UTC timeline 2024-03-15T13:30:00Z
Period A date-based amount (years, months, days) 2 months, 5 days
Duration A time-based amount (hours, minutes, seconds) PT1H30M
DateTimeFormatter Converts to/from text "dd/MM/yyyy"

Common factory and instance methods you’ll use on LocalDate/LocalDateTime include of(year, month, day, ...), now(), parse(text), plusDays/plusWeeks/plusMonths/plusYears(n), minusDays/minusMonths(n), isBefore(other), isAfter(other), isEqual(other), and getDayOfWeek().

Examples

Example 1: Creating and inspecting dates and times

import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;

public class Main {
    public static void main(String[] args) {
        LocalDate date = LocalDate.of(2024, 3, 15);
        LocalTime time = LocalTime.of(14, 30, 0);
        LocalDateTime dateTime = LocalDateTime.of(date, time);

        System.out.println("Date: " + date);
        System.out.println("Time: " + time);
        System.out.println("DateTime: " + dateTime);
        System.out.println("Day of week: " + date.getDayOfWeek());
        System.out.println("Month: " + date.getMonth());
        System.out.println("Is leap year: " + date.isLeapYear());
    }
}

Output:

Date: 2024-03-15
Time: 14:30
DateTime: 2024-03-15T14:30
Day of week: FRIDAY
Month: MARCH
Is leap year: true

Notice that LocalTime.of(14, 30, 0) prints as 14:30 rather than 14:30:00—the toString() method uses the shortest ISO-8601 format that still represents the value exactly, so trailing zero fields are omitted. getDayOfWeek() and getMonth() return type-safe enums (DayOfWeek and Month), not raw integers, which avoids the classic off-by-one bug where January is sometimes 0 and sometimes 1 depending on the API.

Example 2: Formatting and parsing with DateTimeFormatter

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class Main {
    public static void main(String[] args) {
        LocalDate date = LocalDate.of(2024, 3, 15);

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
        String formatted = date.format(formatter);
        System.out.println("Formatted: " + formatted);

        DateTimeFormatter longFormatter = DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy");
        System.out.println("Long form: " + date.format(longFormatter));

        String input = "25-12-2024";
        DateTimeFormatter parser = DateTimeFormatter.ofPattern("dd-MM-yyyy");
        LocalDate parsed = LocalDate.parse(input, parser);
        System.out.println("Parsed: " + parsed);
    }
}

Output:

Formatted: 15/03/2024
Long form: Friday, March 15, 2024
Parsed: 2024-12-25

A DateTimeFormatter works in both directions: format() turns a date object into text using a pattern, and the static parse(text, formatter) method on LocalDate turns text back into an object using that same pattern. The pattern letters are case-sensitive and meaningful—yyyy is a four-digit year, MM is a two-digit month, dd is a two-digit day, EEEE is the full weekday name, and MMMM is the full month name.

Example 3: Date arithmetic with Period, Duration, and ChronoUnit

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.Duration;
import java.time.temporal.ChronoUnit;

public class Main {
    public static void main(String[] args) {
        LocalDate start = LocalDate.of(2024, 1, 10);
        LocalDate end = LocalDate.of(2024, 3, 15);

        Period period = Period.between(start, end);
        System.out.println("Period: " + period.getMonths() + " months, " + period.getDays() + " days");

        long daysBetween = ChronoUnit.DAYS.between(start, end);
        System.out.println("Days between: " + daysBetween);

        LocalDate nextWeek = start.plusWeeks(1);
        System.out.println("One week later: " + nextWeek);

        LocalDateTime meetingStart = LocalDateTime.of(2024, 3, 15, 9, 0);
        LocalDateTime meetingEnd = LocalDateTime.of(2024, 3, 15, 10, 30);
        Duration duration = Duration.between(meetingStart, meetingEnd);
        System.out.println("Meeting length: " + duration.toMinutes() + " minutes");
    }
}

Output:

Period: 2 months, 5 days
Days between: 65
One week later: 2024-01-17
Meeting length: 90 minutes

This example shows the difference between Period and Duration, which is a distinction that trips up a lot of learners. Period measures date-based amounts (years, months, days) and is meant for two LocalDate values, while Duration measures time-based amounts (hours, minutes, seconds, nanoseconds) and is meant for two time-based values like LocalDateTime or Instant. ChronoUnit.DAYS.between(...) is a third option that simply gives you a raw count in a single unit, which is often simpler than a Period when you just need “how many days.”

Under the Hood: How It All Works

When you call LocalDate.of(2024, 3, 15), the JVM does not store a formatted string anywhere—it stores the year, month, and day as primitive fields inside a small immutable object, and computes an internal epoch-day value on demand for arithmetic. Because the object is immutable, the JVM never needs to defensively copy it before handing it to another method or storing it in a collection; the same reference can be shared everywhere safely, which is also why immutable date-time objects are naturally thread-safe with no synchronization needed.

Every “modifying” method, like plusDays(10), internally computes a new epoch-day value (current epoch day + 10) and constructs a brand-new LocalDate from it; the original object’s fields are never touched. This is enforced because there are simply no setter methods on these classes—every field is final, so once constructed, the object can never change.

Formatting works through the TemporalAccessor interface. When you call date.format(formatter), the formatter walks through the pattern you gave it (like "dd/MM/yyyy") and, for each pattern letter, asks the date object for the corresponding field (day-of-month, month-of-year, year) via TemporalField queries, then renders each value according to the pattern’s width and padding rules. Parsing runs the process in reverse: the formatter reads characters from your input string according to the pattern, resolves each recognized field, and then builds a new object from the resolved fields—throwing a DateTimeParseException if the text doesn’t match the pattern.

Common Mistakes

Mistake 1: Forgetting that dates are immutable

Because plusDays(), minusMonths(), and similar methods return a new object instead of modifying the original, calling them without capturing the result silently does nothing:

import java.time.LocalDate;

public class Main {
    public static void main(String[] args) {
        LocalDate date = LocalDate.of(2024, 3, 15);
        date.plusDays(10);
        System.out.println("Date: " + date);
    }
}

Output:

Date: 2024-03-15

The date never changed, because the new object returned by plusDays(10) was discarded. The fix is to reassign the result back to a variable:

import java.time.LocalDate;

public class Main {
    public static void main(String[] args) {
        LocalDate date = LocalDate.of(2024, 3, 15);
        date = date.plusDays(10);
        System.out.println("Date: " + date);
    }
}

Output:

Date: 2024-03-25

Mistake 2: Mixing up MM and mm in format patterns

DateTimeFormatter pattern letters are case-sensitive, and it is extremely easy to type lowercase mm (minute-of-hour) when you meant uppercase MM (month-of-year):

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class Main {
    public static void main(String[] args) {
        LocalDateTime dt = LocalDateTime.of(2024, 3, 15, 14, 30);
        DateTimeFormatter wrong = DateTimeFormatter.ofPattern("yyyy-mm-dd");
        System.out.println(dt.format(wrong));
    }
}

Output:

2024-30-15

That printed the minute (30) where the month should be, producing a nonsensical date. Using the correct case fixes it:

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class Main {
    public static void main(String[] args) {
        LocalDateTime dt = LocalDateTime.of(2024, 3, 15, 14, 30);
        DateTimeFormatter correct = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        System.out.println(dt.format(correct));
    }
}

Output:

2024-03-15

Mistake 3: Comparing dates with == instead of equals()

Like any object type, LocalDate and its relatives should be compared with equals(), isEqual(), or compareTo()—never ==, which only checks whether two references point to the exact same object:

import java.time.LocalDate;

public class Main {
    public static void main(String[] args) {
        LocalDate date1 = LocalDate.of(2024, 3, 15);
        LocalDate date2 = LocalDate.parse("2024-03-15");

        if (date1 == date2) {
            System.out.println("Same object (==)");
        } else {
            System.out.println("Different objects (==)");
        }

        if (date1.equals(date2)) {
            System.out.println("Same value (equals)");
        }
    }
}

Output:

Different objects (==)
Same value (equals)

date1 and date2 were built through two different code paths, so they are two separate objects in memory even though they represent the identical calendar date; == reports them as different while equals() correctly reports them as equal. Always use equals() for exact equality, or isBefore()/isAfter()/compareTo() for ordering.

Best Practices

  • Always reassign the result of methods like plusDays() or withYear()—these objects are immutable and never modify themselves.
  • Prefer LocalDate/LocalTime/LocalDateTime for values with no real-world time zone (birthdays, business hours), and reach for ZonedDateTime or Instant only when time zones genuinely matter, such as scheduling across regions.
  • Use Period for date-based differences and Duration (or ChronoUnit) for time-based differences—don’t mix them up.
  • Reuse a single DateTimeFormatter instance instead of creating a new one every time; it is immutable and thread-safe, unlike the legacy SimpleDateFormat.
  • Compare dates and times with equals(), isEqual(), isBefore(), isAfter(), or compareTo()—never ==.
  • Avoid the old java.util.Date and Calendar classes in new code; they are mutable, not thread-safe, and have confusing zero-based month numbering.
  • Wrap DateTimeFormatter.parse() calls in a try/catch for DateTimeParseException when the input text comes from a user or an external source, since malformed input will throw rather than return null.

Practice Exercises

  • Exercise 1: Write a program that stores your birth date as a LocalDate and prints how many total days old you would be on your next birthday in the year 2030, using ChronoUnit.DAYS.between().
  • Exercise 2: Write a program that creates a LocalDateTime for a flight departure and one for its arrival, then prints the flight duration in hours and minutes using Duration.
  • Exercise 3: Write a program that asks the user to type a date as text (for example "03-15-2024") using Scanner, parses it with a DateTimeFormatter pattern of "MM-dd-yyyy", and prints the day of the week it falls on. Handle the case where the user types an invalid date.

Summary

  • java.time (JSR-310) replaced the old mutable, non-thread-safe Date/Calendar classes with immutable, thread-safe classes.
  • LocalDate, LocalTime, and LocalDateTime represent date/time values with no time zone; use ZonedDateTime or Instant when zones matter.
  • Every “modifying” method returns a new object—you must reassign the result or the change is lost.
  • DateTimeFormatter converts between date objects and text in both directions, and its pattern letters are case-sensitive (MM for month, mm for minute).
  • Period measures date-based differences; Duration and ChronoUnit measure time-based differences.
  • Always compare date-time objects with equals(), isBefore(), isAfter(), or compareTo()—never ==.