LocalDate is the workhorse of the java.time package for representing a calendar day: a year, a month, and a day of the month, with no clock time and no time zone attached. In an e-commerce app, that's the right type for an order date on a printed invoice, the day a sale starts, or the deadline by which a return must be received. This lesson covers how to build a LocalDate, how to read fields from it, how to do arithmetic with days, weeks, months, and years, and the edge cases (like month-end) that complicate hand-rolled date math.
A LocalDate is an immutable value object that stores three numbers: a year, a month (1 to 12), and a day of the month (1 to 28, 29, 30, or 31 depending on the month). It uses the ISO-8601 calendar system, the standard proleptic Gregorian calendar that most software and most of the world runs on. There is no hour, no minute, no second, and no time zone. For "what date is the next Black Friday sale," LocalDate is the right type. For "at exactly what instant did the sale start in New York," ZonedDateTime or Instant is more appropriate.
The class lives in java.time.LocalDate and was added in Java 8 as part of the JSR-310 redesign. Every method that looks like it changes a LocalDate actually returns a new LocalDate, because instances are immutable. This point comes up several times in this lesson.
The diagram is intentionally simple: three numbers go in, one LocalDate comes out. That's the conceptual model. Everything else in this lesson is about constructing that object, reading fields from it, and deriving new dates from it.
There are three common ways to get a LocalDate: ask for today, build one from explicit year/month/day values, or parse one from an ISO-formatted string.
LocalDate.now() reads the system clock and returns today's date in the JVM's default time zone. This is the version for stamping an order with the day it was placed, or recording the day a product was released.
The exact output depends on the day the code runs. The string form is ISO-8601 (YYYY-MM-DD), which is what toString() returns by default. For now, the ISO format is fine.
LocalDate.now() reads the system clock and the default time zone every call. That's cheap, but it's also non-deterministic, which makes tests flaky. Production code that needs to be testable usually accepts a Clock and calls LocalDate.now(clock) so tests can pin "today" to a known value.
For a known exact date, build it with LocalDate.of. There are two overloads: one that takes three int values and one that takes a Month enum constant in the middle. Both behave the same. The Month form reads a little better in code review.
A note on month numbering. LocalDate.of(2026, 11, 27) is November 27, 2026. Months in java.time are 1-based, matching the numbering humans use. The older java.util.Calendar used 0-based months (January was 0), and it caused so many bugs over the years that the java.time designers fixed it.
Passing an invalid combination causes LocalDate.of to throw a DateTimeException rather than silently rolling over to a "close" date. February 30 isn't a date, so the call fails:
That's a feature, not a bug. The old java.util.Date would happily accept February 30 and silently convert it to March 2. A wrong return-window deadline typed into an admin tool would never have surfaced.
When the date arrives as a string in ISO format (YYYY-MM-DD), LocalDate.parse handles it directly without any formatter setup.
The single-argument form of parse only understands the ISO format. For "08/01/2026" or "Aug 1, 2026" or any other format, a custom DateTimeFormatter is required, which is the topic of lesson 08 in this section.
A LocalDate has a getter for every field of interest. The names are predictable; the return types vary.
| Method | Returns | Range |
|---|---|---|
getYear() | int | Any 4-digit year (and beyond) |
getMonth() | Month enum | JANUARY to DECEMBER |
getMonthValue() | int | 1 to 12 |
getDayOfMonth() | int | 1 to 31 |
getDayOfWeek() | DayOfWeek | MONDAY to SUNDAY |
getDayOfYear() | int | 1 to 365 (or 366 in a leap year) |
isLeapYear() | boolean | true if divisible-by-4 leap rule |
lengthOfMonth() | int | 28, 29, 30, or 31 |
lengthOfYear() | int | 365 or 366 |
A working example pulls a handful of them together. Consider a product release date displayed on a "release info" admin page:
Two suspicious things here. LocalDate.of(2026, Month.FEBRUARY, 29) should fail, because 2026 isn't a leap year. And the printed Release date is 2026-02-29, which would mean the date succeeded. That's a contrived bug to highlight: as shown earlier LocalDate.of(2026, 2, 30) throws, and the same is true for 2026, 2, 29. The code above throws a DateTimeException before reaching the print statements. Run it to confirm. The teachable point is that LocalDate doesn't accept invalid dates.
The same example with a real leap year so the code actually runs:
A few details. getDayOfWeek() returns a DayOfWeek enum, not a string or a number. That's intentional: enums prevent off-by-one bugs and allow switch without magic numbers. getDayOfYear() is the 1-based count of the day in the year, useful for "days since New Year" calculations like "how long until the holiday sale starts." And lengthOfMonth() knows about February's leap-year quirk.
Date math by hand is error-prone: months have different lengths, leap years exist, and "30 days from January 31" doesn't have an obvious answer. LocalDate has methods that handle all of this correctly.
The four plusX methods cover the common cases. Each takes a long count and returns a new LocalDate. The originals are never modified.
Suppose an order is placed today, and the shipping policy is "delivers in 5 business days, but we promise 7 calendar days." The promise date is computed as:
plusWeeks(n) is a shortcut for plusDays(n * 7). The two are equivalent. Use whichever reads better in context:
plusMonths and plusYears work the same way, but they have a wrinkle around month-end, covered below. First, a subscription renewal example to show the basic case:
Every plusX has a minusX counterpart. They subtract instead of add. The signatures and behavior mirror the plusX methods, including the month-end quirk.
Date arithmetic methods allocate a new LocalDate every call. For a one-off orderDate.plusDays(7), that's negligible. If you chain twenty calls in a tight loop processing millions of rows, the allocations add up, though garbage collection of small short-lived objects is cheap on the JVM. The shape of the API is "return a new immutable value," not "mutate in place."
"What is one month after January 31, 2026?" There's no February 31, so the answer can't be "31st of next month." LocalDate handles this by clamping the day of month to the last valid day of the target month:
January 31 plus one month becomes February 28, the last day of February (2026 isn't a leap year, so it's 28, not 29). Adding two months from the original January 31 lands on March 31 again. The clamping only happens when the target month is shorter. This matters for any monthly billing or renewal logic where a customer signed up on the 31st. A subscription that renews "on the same day of the month" sometimes has no matching day in a target month.
A leap-year version makes the difference clearer:
2024 is a leap year, so February has 29 days. 2025 isn't, so February stops at 28. plusMonths knows the rule.
Adding a combination like "1 year, 2 months, and 10 days" in a single step uses Period. Period packages a year/month/day offset into one value that goes to plus. This section shows the shape of the call.
There's also a more general plus(long amount, TemporalUnit unit) that takes a count and a unit (ChronoUnit.DAYS, ChronoUnit.WEEKS, ChronoUnit.MONTHS, ChronoUnit.YEARS, etc.). It's useful when the unit is chosen dynamically:
In most code, plusDays, plusMonths, and friends read better. plus(amount, unit) is useful when the unit is itself a parameter, such as a configurable promotion length stored in a database.
The withX methods return a copy of the date with one field replaced. They're useful for anchoring to a specific year, month, or day without throwing away the rest.
withMonth also has to handle the month-end case. March 31 with withMonth(2) would request February 31, which doesn't exist. The method clamps the day, same as plusMonths:
Same rule as before. Invalid combinations get clamped to the closest valid date in the target month rather than throwing.
Checking whether one date is before, after, or equal to another is a common operation. A typical case: deciding whether a return request landed inside the return window.
isBefore, isAfter, and isEqual each return a boolean and take another LocalDate as the argument. They compare by calendar position. There's also compareTo, which returns a negative number, zero, or a positive number, matching the Comparable interface so LocalDate instances sort naturally.
One detail: equals and isEqual behave the same way for LocalDate (both check that the year, month, and day match), but they differ on types higher up the hierarchy where chronology can matter. For LocalDate, either works, and equals makes a LocalDate a correct HashMap key.
compareTo returned -3 here, which happens to be the day difference for these two dates, but the result should be treated as just "negative means before, zero means equal, positive means after." The exact magnitude is an implementation detail, not part of the contract.
Some date queries don't fit cleanly into "add N days" or "set the day to X." Examples from an e-commerce context: "the first day of next month for monthly billing," "the next Saturday for weekend delivery," "the last day of the current month for an end-of-month report." The TemporalAdjusters class (note the plural) has static methods that produce these adjusters, passed to LocalDate.with.
The pattern is always the same. Build an adjuster, hand it to with, get a new LocalDate back. The original date is never modified.
A few details. next(DayOfWeek.MONDAY) always returns a date strictly after today, even if today is already Monday. nextOrSame(DayOfWeek.X) returns today if today is already that day of the week, otherwise it advances to the next occurrence. firstInMonth(DayOfWeek.FRIDAY) finds the first Friday in the current month, a common pattern for "first Friday is payday" or "first Friday of the month is the early-access sale."
A more realistic use case: a monthly billing job needs to know the next billing date for each customer.
That's a one-liner that took ten lines of buggy java.util.Calendar code before Java 8. The adjuster handles year rollover automatically: when today is in December, firstDayOfNextMonth() returns January 1 of the next year. No special case to remember.
TemporalAdjusters.next(X) walks day by day, up to 7 days. That's fine for any normal call but means it's not a constant-time lookup; it's bounded by 7 iterations. For one-off queries this never matters.
Every method covered so far returns a new LocalDate. The original is untouched. Writing orderDate.plusDays(7) and ignoring the return value leaves orderDate unchanged.
The plusDays(7) call returned a new LocalDate representing May 21, but nothing captured it, so the result was discarded. orderDate still points to May 14. To use the new date, assign it: orderDate = orderDate.plusDays(7);, or store it in a new variable.
The same property makes chaining safe and easy to read. Each method returns a LocalDate, so another method can be called on the result:
Each step in the chain produces an intermediate LocalDate that the next step consumes. Because everything is immutable, there's no chance of one step corrupting another, and the original orderDate is unchanged at the end.
A long chain of plusX / with calls allocates one intermediate LocalDate per call. They're small (three primitive fields plus the object header), and the JVM collects them quickly, so the cost is real but usually invisible.
Two enums show up alongside LocalDate often enough to deserve their own section: DayOfWeek and Month. Both are real Java enums, not constants, so they can be switched on, stored in collections, and called as methods.
DayOfWeek.getValue() follows the ISO numbering: Monday is 1, Sunday is 7. That's different from some older APIs where Sunday was 1. Month.getValue() is 1 to 12, with January being 1. Month.length(boolean leapYear) returns 28 or 29 for February depending on the flag, and a constant for every other month.
A clean weekend-check using switch:
Switching on DayOfWeek constants is more readable than checking magic numbers like day == 6 || day == 7. The enums also work well with EnumSet for representing "delivery days" or "blackout days" as a set.
A bug that shows up in real code: a developer wants to advance a date in a loop and writes:
What's wrong with this code?
The same date prints five times because plusDays(1) returns a new LocalDate and the code throws it away. saleDay is never reassigned.
Fix:
The fix is one character: an = sign. Reassigning the return value of plusDays(1) back to saleDay advances it on each iteration. The same issue exists for String, BigDecimal, and every other immutable type.
10 quizzes