Dates and times with java.time
Every row you parsed in the last lesson began with a date. Every backend job involves them: billing periods, delivery schedules, "orders in the last 30 days", timestamps that have to mean the same thing in Pune and London.
Java's modern answer is java.time, added in Java 8. It replaced java.util.Date
and Calendar, which were mutable, had months numbered from zero, and had years
counted from 1900.
Never use Date, Calendar or SimpleDateFormat in new code. You will meet
them in old code, and the conversion methods are Date.toInstant() and
Date.from(instant).
Choosing the type
This is the whole of the difficulty, and getting it right first time saves a rewrite.
| Type | Holds | Use for |
|---|---|---|
LocalDate |
A date, no time, no zone | A birthday, a billing month, a delivery date |
LocalTime |
A time, no date, no zone | "Deliveries start at 07:30" |
LocalDateTime |
Both, still no zone | A wall-clock appointment |
ZonedDateTime |
Both, plus a zone | A specific moment somewhere |
Instant |
A point on the timeline, UTC | A timestamp in a log or database |
Duration |
A length of time | Elapsed time, timeouts |
Period |
A length in years, months, days | "Three months from now" |
YearMonth |
A month | A billing cycle |
DayOfWeek, Month |
Enums | Day and month names |
LocalDate : 2026-09-27
LocalTime : 07:30
LocalDateTime : 2026-09-27T07:30
ZonedDateTime : 2026-09-27T07:30+05:30[Asia/Kolkata]
Instant : 2026-09-27T02:00:00Z
The rule: if it is a moment — something happened, a record was created —
use Instant, stored in UTC. If it is a calendar concept — a birthday, a
delivery date, a billing month — use LocalDate. LocalDateTime is for the
narrower case of a wall-clock time whose zone is implied by context, and it is
the one most often chosen wrongly.
A LocalDateTime of 2026-09-27T07:30 is not a moment. Two people in different
cities reading it mean different instants.
Everything is immutable
LocalDate start = LocalDate.of(2026, 9, 1);
start.plusDays(30);
System.out.println(start);
2026-09-01
The same trap as String. Every plus, minus and with returns a new object.
Assign the result.
System.out.println(start.plusDays(30));
2026-10-01
Arithmetic
plusMonths(1) : 2026-10-01
minusWeeks(2) : 2026-08-18
end of month : 2026-09-30
day of week : TUESDAY
is leap year : false
31 Jan plus 1 month: 2026-02-28
That last line is worth stopping on. LocalDate.of(2026, 1, 31).plusMonths(1)
is 28 February, because 31 February does not exist and java.time clamps to
the end of the month.
That is a reasonable choice and it is not reversible: adding a month and then subtracting one does not get you back to the 31st. For a monthly billing date on the 31st, decide explicitly what you mean — last day of the month, or the 28th every time — rather than letting the clamp decide.
Months are numbered from 1, unlike Calendar. Month.SEPTEMBER is also
available and is clearer in a literal.
Comparing and measuring
isBefore : true
days between: 29
Period : P29D
Duration : PT4H45M
| Call | Gives |
|---|---|
a.isBefore(b) / isAfter(b) / isEqual(b) |
Comparison |
ChronoUnit.DAYS.between(a, b) |
A count in one unit |
Period.between(a, b) |
Years, months and days |
Duration.between(t1, t2) |
Hours, minutes, seconds |
a.until(b, ChronoUnit.MONTHS) |
The same as ChronoUnit |
ChronoUnit.DAYS.between is what you want most of the time. Period is for
displaying "2 years, 3 months"; it is awkward to do arithmetic with because
months have different lengths.
Note both are exclusive of the end: 1 September to 30 September is 29 days,
not 30. For "how many days in the billing period" you usually want + 1, and
forgetting it is a classic off-by-one on an invoice.
Parsing and formatting
LocalDate.parse("2026-09-27"); // ISO, no formatter needed
DateTimeFormatter indian = DateTimeFormatter.ofPattern("dd/MM/yyyy");
date.format(indian);
LocalDate.parse("27/09/2026", indian);
DateTimeFormatter pretty = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.UK);
parse ISO : 2026-09-27
format : 27/09/2026
parse custom: 2026-09-27
pretty : 27 September 2026
bad parse : Text '27-09-2026' could not be parsed at index 0
The patterns you will use:
| Pattern | Means | Example |
|---|---|---|
yyyy |
Four-digit year | 2026 |
MM / M |
Month number, padded or not | 09 / 9 |
MMM / MMMM |
Month name | Sep / September |
dd / d |
Day of month | 27 / 27 |
EEE / EEEE |
Day name | Tue / Tuesday |
HH |
Hour, 24-hour | 07 |
hh + a |
Hour, 12-hour, plus AM/PM | 07 AM |
mm |
Minutes | 30 |
ss |
Seconds | 00 |
z / XXX |
Zone name / offset | IST / +05:30 |
Case matters and the mistakes are silent-ish. mm is minutes and MM is
months; DD is day-of-year, not day-of-month; and YYYY is week-based year,
which differs from yyyy in the last days of December — a bug that appears once
a year and is very hard to believe.
Store and exchange dates in ISO format (2026-09-27), which is what
toString() produces and parse accepts with no formatter. Format for display
only, at the edge.
DateTimeFormatter is immutable and thread-safe, so make it a static final
constant. SimpleDateFormat, its predecessor, was neither — sharing one between
threads corrupted output, which is one of the reasons java.time exists.
Time zones
Pune : 2026-09-27T07:30+05:30[Asia/Kolkata]
London : 2026-09-27T03:00+01:00[Europe/London]
UTC : 2026-09-27T02:00Z
offset : +05:30
withZoneSameInstant converts a moment to how it reads elsewhere — the same
instant, a different wall clock. withZoneSameLocal keeps the clock reading and
changes the instant, which is rarely what you want.
Use region IDs, not offsets. ZoneId.of("Asia/Kolkata"), not +05:30. A
region carries its daylight-saving history, so a date in a European zone six
months ago converts correctly. India has no daylight saving, which makes it easy
to forget this until a client in London reports that everything is an hour out
for half the year.
Store timestamps in UTC. Convert to the user's zone when you display them.
Instant is UTC by definition, which is why it is the right database type for
"when did this happen".
Testing time
Code calling LocalDate.now() cannot be tested — the answer changes daily, and
a test that passes on the 1st may fail on the 31st.
Clock fixed = Clock.fixed(Instant.parse("2026-09-27T02:00:00Z"), ZoneId.of("Asia/Kolkata"));
LocalDate.now(fixed);
LocalTime.now(fixed);
LocalDate.now(fixed): 2026-09-27
LocalTime.now(fixed): 07:30
Every now() method takes an optional Clock. Inject one into classes that
need the time, pass Clock.systemDefaultZone() in production and
Clock.fixed(...) in tests. This is the single most useful testability trick in
java.time, and module 10 uses it.
A billing month
Putting it together:
YearMonth month = YearMonth.of(2026, 9);
month.lengthOfMonth();
month.atDay(1);
month.atEndOfMonth();
month : 2026-09
length : 30
first/last : 2026-09-01 to 2026-09-30
non-Sundays: 26
YearMonth is exactly right for a billing cycle and is often overlooked — people
store a LocalDate set to the first of the month and then have to remember that
the day part is meaningless.
Check your work
Which types should you never use in new code? java.util.Date, Calendar
and SimpleDateFormat.
When do you use Instant rather than LocalDateTime? For a moment —
something that happened, a record's timestamp. LocalDateTime has no zone, so
it does not identify a moment at all.
What does LocalDate.of(2026, 1, 31).plusMonths(1) give, and why?
2026-02-28. There is no 31 February, so the result is clamped to the end of the
month — and the operation is not reversible.
Is ChronoUnit.DAYS.between(sep1, sep30) 29 or 30? 29. Both Period and
ChronoUnit are exclusive of the end date.
What is the difference between mm and MM in a pattern? mm is minutes,
MM is months. And YYYY is week-based year, which differs from yyyy at the
end of December.
Why ZoneId.of("Asia/Kolkata") rather than +05:30? A region ID carries
daylight-saving history, so conversions for past and future dates are correct.
How do you make code using now() testable? Pass a Clock. Every now()
method has an overload taking one; use Clock.fixed(...) in tests.
Why was SimpleDateFormat dangerous? It was mutable and not thread-safe.
DateTimeFormatter is both immutable and safe to share.
Practice 3, the month-end. plusMonths(1) from 31 January gives 28 February,
and from 31 March gives 30 April. Subtracting a month from the result does not
return you to the 31st. For a subscription that renews on the 31st, the two
defensible rules are "last day of the month" —
date.with(TemporalAdjusters.lastDayOfMonth()) — or "the 28th always". Pick one
in code rather than inheriting the clamp by accident.
Practice 5, the billing period. ChronoUnit.DAYS.between(sep1, sep30) is 29,
so a billing period of 1 to 30 September is 30 days and needs the + 1.
Charging for 29 days is the off-by-one that reaches a customer's invoice, which
is why this one is worth doing deliberately.
Practice
-
Build one of each. A
LocalDate, aLocalTime, aLocalDateTime, aZonedDateTimeinAsia/Kolkata, and theInstantbehind it. Print all five and note which of them identify a moment. -
Hit the immutability trap. Call
plusDays(30)without assigning, print, then assign and print again. -
Clamp a month end. Add one month to 31 January and to 31 March. Then subtract one month from each result. Decide what a subscription renewing on the 31st should do, and implement it.
-
Format for India and parse it back. Format a date as
dd/MM/yyyy, then parse it back with the same formatter. Then try to parse it withLocalDate.parsealone and read theDateTimeParseException. -
Count a billing period. How many days from 1 September to 30 September? Print
ChronoUnit.DAYS.betweenand decide whether your invoice needs+ 1. Then count the non-Sundays in the month. -
Harder — a testable scheduler. Write
List<LocalDate> deliveryDates(YearMonth month, Set<DayOfWeek> restDays)returning every delivery date in the month. Then writeString describeToday(Clock clock)that says whether today is a delivery day. Test it with three differentClock.fixedvalues — a delivery day, a rest day, and the last day of the month — without your tests ever depending on the real date.
Next: JSON with Jackson, the format every API on earth speaks.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship