RizTech Academy logo
RizTech Academy
Files, Dates and DataLesson 3 of 430 min

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

  1. Build one of each. A LocalDate, a LocalTime, a LocalDateTime, a ZonedDateTime in Asia/Kolkata, and the Instant behind it. Print all five and note which of them identify a moment.

  2. Hit the immutability trap. Call plusDays(30) without assigning, print, then assign and print again.

  3. 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.

  4. 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 with LocalDate.parse alone and read the DateTimeParseException.

  5. Count a billing period. How many days from 1 September to 30 September? Print ChronoUnit.DAYS.between and decide whether your invoice needs + 1. Then count the non-Sundays in the month.

  6. Harder — a testable scheduler. Write List<LocalDate> deliveryDates(YearMonth month, Set<DayOfWeek> restDays) returning every delivery date in the month. Then write String describeToday(Clock clock) that says whether today is a delivery day. Test it with three different Clock.fixed values — 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