Dates & Times

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). Two modules of the standard distribution cover the topic. groovy-datetime extends the java.time API (JSR 310, "the JSR 310 types") with operators, ranges, formatting and parsing helpers. groovy-dateutil extends the legacy java.util.Date and Calendar classes and holds the duration DSL (TimeCategory). New code should use java.time; the legacy part is for the APIs that still hand out Date. The Java side of the same material is on Dates & Times in the Java section, and operator mapping in general is on Operator Overloading.

Examples that use Date set TimeZone.default to UTC first so the printed output does not depend on the machine.

Operators on JSR 310 types

The temporal types have plus and minus methods, so ` and `-` work, and Groovy adds overloads that take an integer. The unit of that integer depends on the type: days for `LocalDate` (every `ChronoLocalDate`), months for `YearMonth`, years for `Year`, and seconds for everything else, such as `LocalTime`. Increment and decrement (`+, --) call next() and previous() with the same unit and, because the types are immutable, assign a new value to the variable. The comparison operators work because the types are Comparable.

import java.time.*

def sowing = LocalDate.of(2026, 3, 15)
println sowing + 10                  // LocalDate: integers are days
println sowing - 15
def d = sowing
d++                                  // same as d = d.next()
println d
d--
println d == sowing
println([sowing.next(), sowing.previous()])
println sowing < LocalDate.of(2026, 4, 1)
println sowing <=> LocalDate.of(2026, 3, 15)
println LocalTime.of(6, 45) + 90     // LocalTime: integers are seconds
println YearMonth.of(2026, 11) + 3   // YearMonth: integers are months
println Year.of(2026) - 1            // Year: integers are years
println sowing + Period.ofWeeks(2)   // any TemporalAmount works too
println LocalDate.of(2026, 1, 31) + Period.ofMonths(1)   // clamped to the end of February
println([sowing.year, sowing.month, sowing.monthValue, sowing.dayOfMonth, sowing.dayOfWeek])
2026-03-25
2026-02-28
2026-03-16
true
[2026-03-16, 2026-03-14]
true
0
06:46:30
2027-02
2025
2026-03-29
2026-02-28
[2026, MARCH, 3, 15, SUNDAY]

Ranges, upto and downto

JSR 310 values can be range endpoints, and the range steps by the same unit as + does, so a LocalDate range has one element per day and both ends are included. upto and downto iterate the same way and accept a ChronoUnit as the iteration unit; the current value never goes beyond the end value, as the one-month iteration over a two-day window shows. A range of dates can be spread, collected, tested with contains, stepped with step, or written half-open with ..<.

import java.time.*
import java.time.temporal.ChronoUnit

def start = LocalDate.of(2026, 3, 1)
def end = LocalDate.of(2026, 3, 5)
def days = start..end                // a range of LocalDate, both ends inclusive
println days
println days*.dayOfMonth
println days.collect { it.dayOfWeek.toString()[0..2] }
println days.size()
println days.contains(LocalDate.of(2026, 3, 3))
println start..<end                  // half-open range
println((start..LocalDate.of(2026, 3, 20)).step(7).collect { it.toString() })
start.upto(end) { print "$it " }
println()
end.downto(start) { print "${it.dayOfMonth} " }
println()
def n = 0
start.upto(start + 1, ChronoUnit.MONTHS) { n++ }   // never goes beyond the end value
println n
println YearMonth.of(2026, 1)..YearMonth.of(2026, 4)
println end - start                  // a Long: the number of days between
2026-03-01..2026-03-05
[1, 2, 3, 4, 5]
[SUN, MON, TUE, WED, THU]
5
true
2026-03-01..2026-03-04
[2026-03-01, 2026-03-08, 2026-03-15]
2026-03-01 2026-03-02 2026-03-03 2026-03-04 2026-03-05
5 4 3 2 1
1
2026-01..2026-04
4

Periods and durations

A Period counts years, months and days (calendar amounts); a Duration counts seconds and nanoseconds (exact amounts). The right shift operator >> yields the amount between two values: a Period for dates, YearMonth and Year, and a Duration for the types that carry a time, positive when the right side is later and negative when it is earlier. * multiplies a Period or Duration by an integer, / divides a Duration, and unary minus negates either. The property notation period[ChronoUnit.MONTHS] is get(unit), and date[ChronoField.DAY_OF_YEAR] is getLong(field).

import java.time.*
import java.time.temporal.ChronoUnit

def sowing = LocalDate.of(2026, 3, 15)
def harvest = LocalDate.of(2026, 6, 20)
println sowing >> harvest                        // Period between two dates
println((sowing >> harvest).days)
println LocalTime.of(6, 0) >> LocalTime.of(8, 30)    // Duration between two times
println Year.of(2010) >> Year.of(2000)           // negative: the left side is later
println ChronoUnit.DAYS.between(sowing, harvest)
println Period.between(sowing, harvest)
println Duration.between(LocalTime.of(6, 0), LocalTime.of(8, 30))

println Duration.ofMinutes(90) * 2
println Duration.ofSeconds(10) / 5
println Duration.ofMinutes(90) + Duration.ofMinutes(30)
println(-Duration.ofMinutes(5))
println(-Period.ofDays(3))
println Period.ofMonths(1) * 2
println Period.ofYears(2).withMonths(4).withDays(6)[ChronoUnit.MONTHS]   // property notation
println sowing[java.time.temporal.ChronoField.DAY_OF_YEAR]
println Duration.ofHours(3).toMinutes()
P3M5D
5
PT2H30M
P-10Y
97
P3M5D
PT2H30M
PT3H
PT2S
PT2H
PT-5M
P-3D
P2M
4
74
180

P3M5D is the ISO-8601 notation for three months and five days, and PT2H30M for two hours and thirty minutes. Note that P3M5D is not 97 days: a Period is calendar based, and the day count comes from ChronoUnit.DAYS.between.

Formatting and parsing

Groovy adds format(String pattern) to the temporal types, and parse(text, pattern) to the static side of the types: note that the argument order is text first, pattern second, which is the opposite of Date.parse(pattern, text). The helpers dateString, timeString and dateTimeString give the ISO forms (and append the zone’s short name to a ZonedDateTime). format(FormatStyle) uses the localized styles of the JDK. The usual caution about patterns applies: textual parts such as AM, MMMM or EEEE depend on the locale. Parsing '2026/03/15 8:30AM Pacific Standard Time' with 'yyyy/MM/dd h:mma zzzz' failed on this machine, whose default locale is not English; pass a Locale through a DateTimeFormatter for such text.

import java.time.*
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException

def when = LocalDateTime.of(2026, 3, 15, 8, 30)
println when.format('yyyy/MM/dd HH:mm')             // pattern string
println when.format(DateTimeFormatter.ISO_DATE)
println when.dateString
println when.timeString
println when.dateTimeString
println LocalDate.parse('15/03/2026', 'dd/MM/yyyy')        // note the argument order: text, pattern
println LocalDateTime.parse('2026-03-15 08:30', 'yyyy-MM-dd HH:mm')
println OffsetDateTime.parse('2026-03-15 08:30 +0200', 'yyyy-MM-dd HH:mm Z')
println LocalTime.parse('06:45')
println ZonedDateTime.of(when, ZoneId.of('Europe/Madrid')).dateTimeString
println LocalDate.of(2026, 3, 15) << LocalTime.of(8, 30)    // date << time = LocalDateTime
println LocalTime.of(8, 30) << LocalDate.of(2026, 3, 15)    // reflexive: same result
println Year.of(2026) << Month.MARCH
try { LocalDate.parse('2026-02-30') } catch (DateTimeParseException e) { println e.class.simpleName }
2026/03/15 08:30
2026-03-15
2026-03-15
08:30:00
2026-03-15T08:30:00
2026-03-15
2026-03-15T08:30
2026-03-15T08:30+02:00
06:45
2026-03-15T08:30:00CET
2026-03-15T08:30
2026-03-15T08:30
2026-03
DateTimeParseException

The left shift operator << combines two JSR 310 values into the aggregate type, in either order: a date and a time give a LocalDateTime, a Year and a Month give a YearMonth. The last line shows that the strict java.time parser rejects a date that does not exist, which the legacy Date.parse does not (see below).

Zones and instants

An Instant is a point on the time line, a ZonedDateTime is an instant with a time zone and its rules, an OffsetDateTime has only a fixed offset. Adding a Duration to a zoned value counts exact time, so the spring-forward gap is respected: in the example below, 01:30 plus one hour in Madrid on the night the clocks change is 03:30, because 02:30 does not exist that day.

import java.time.*

def madrid = ZoneId.of('Europe/Madrid')
def before = ZonedDateTime.of(2026, 3, 29, 1, 30, 0, 0, madrid)   // night the clocks go forward
println before
println before + Duration.ofHours(1)         // 02:30 does not exist: the result is 03:30
println before.withZoneSameInstant(ZoneId.of('UTC'))
println before.toInstant()
def inst = Instant.parse('2026-03-15T08:30:00Z')
println inst + Duration.ofMinutes(45)
println inst.atZone(ZoneId.of('Asia/Tokyo')).toLocalDateTime()
println OffsetDateTime.of(2026, 3, 15, 8, 30, 0, 0, ZoneOffset.ofHours(2)).toInstant()
println before.format('yyyy-MM-dd HH:mm z')
println LocalDate.of(2026, 3, 15).atStartOfDay()
println LocalDate.of(2026, 3, 15).atTime(8, 30)
println YearMonth.of(2026, 2).lengthOfMonth()
println Year.of(2024).isLeap()
println Month.MARCH + 11
println DayOfWeek.SUNDAY + 1
2026-03-29T01:30+01:00[Europe/Madrid]
2026-03-29T03:30+02:00[Europe/Madrid]
2026-03-29T00:30Z[UTC]
2026-03-29T00:30:00Z
2026-03-15T09:15:00Z
2026-03-15T17:30
2026-03-15T06:30:00Z
2026-03-29 01:30 CET
2026-03-15T00:00
2026-03-15T08:30
28
true
FEBRUARY
MONDAY

Month and DayOfWeek accept + with a number as well, and wrap around the year or week.

The duration DSL

groovy.time.TimeCategory (module groovy-dateutil) adds properties to numbers inside a use block, so 2.hours, 30.minutes and 3.days read like English. Adding one to a Date moves it, subtracting two dates gives a TimeDuration, and 1.month is a DatumDependentDuration whose length depends on the date it is applied to. The category works with the legacy Date and Calendar, not with the java.time types, which have their own Duration and Period. A category is active only inside its use block and only on the current thread.

import groovy.time.TimeCategory
import groovy.time.TimeDuration

TimeZone.default = TimeZone.getTimeZone('UTC')
def fixed = Date.parse('yyyy-MM-dd HH:mm', '2026-03-15 08:30')
use(TimeCategory) {
    println(fixed + 2.hours)
    println(fixed - 3.days)
    println(2.hours + 30.minutes)
    println((fixed + 1.week).format('yyyy-MM-dd'))
    println((fixed + 1.month).format('yyyy-MM-dd'))
    def gap = (fixed + 36.hours) - fixed
    println gap
    println gap.class.simpleName
    println 1.month.class.simpleName
    println 90.minutes.toMilliseconds()
    println(5.minutes.ago.before(new Date()))
    println(3.days.from.now.after(new Date()))
}
Sun Mar 15 10:30:00 UTC 2026
Thu Mar 12 08:30:00 UTC 2026
2 hours, 30 minutes
2026-03-22
2026-04-15
1 days, 12 hours
TimeDuration
DatumDependentDuration
5400000
true
true

Legacy Date and Calendar

The groovy-dateutil module adds arithmetic, formatting and properties to Date and Calendar. + and - with an integer move a Date by days, subtracting two dates gives the whole days between them, and upto/downto iterate by day. Indexing with a Calendar constant (date[YEAR], cal[DAY_OF_WEEK]) reads a field, and assigning to it on a Calendar writes it. Date.parse(pattern, text) and date.format(pattern[, timeZone]) use SimpleDateFormat patterns. copyWith(map) on a Date or Calendar creates an adjusted copy, and Calendar.updated(map) does the same.

import static java.util.Calendar.*
TimeZone.default = TimeZone.getTimeZone('UTC')   // make the output machine independent

def d = Date.parse('yyyy-MM-dd HH:mm', '2026-03-15 08:30')
println d
println d.format('yyyy-MM-dd')
println d.format('HH:mm', TimeZone.getTimeZone('Asia/Tokyo'))
println(d + 1)                       // days
println d - 10
println((d + 3) - d)                 // whole days between
println d[YEAR]
println d[MONTH]                     // zero based!
println d.copyWith(year: 2027, month: JANUARY, dayOfMonth: 1)
int count = 0
(d - 1).upto(d + 1) { count++ }
println count

def copy = new Date(d.time)
copy.clearTime()                     // clearTime() MUTATES the Date
println copy
println d

def cal = Calendar.instance
cal.clear()
cal[YEAR] = 2026
cal[MONTH] = MARCH
cal[DAY_OF_MONTH] = 15
println cal[DAY_OF_WEEK] == SUNDAY
cal.set(year: 2026, month: APRIL, date: 1)
println cal.format('yyyy-MM-dd')
def other = cal.updated(year: 2025)  // updated() returns a modified copy
println other.format('yyyy-MM-dd') + ' ' + cal.format('yyyy-MM-dd')
cal++
println cal.format('yyyy-MM-dd')
println Date.parse('yyyy-MM-dd', '2026-02-30').format('yyyy-MM-dd')   // lenient parsing rolls over
Sun Mar 15 08:30:00 UTC 2026
2026-03-15
17:30
Mon Mar 16 08:30:00 UTC 2026
Thu Mar 05 08:30:00 UTC 2026
3
2026
2
Fri Jan 01 08:30:00 UTC 2027
3
Sun Mar 15 00:00:00 UTC 2026
Sun Mar 15 08:30:00 UTC 2026
true
2026-04-01
2025-04-01 2026-04-01
2026-04-02
2026-03-02

The legacy classes have the traps that motivated java.time. Months are zero based (d[MONTH] is 2 for March). clearTime() changes the date it is called on and returns it, so the example works on a copy. Date.parse is lenient, so February 30th became March 2nd instead of failing. Date has no time zone of its own, and SimpleDateFormat is not thread safe.

Converting between legacy and JSR 310 types

The GDK gives Date, Calendar and the JSR 310 types conversion methods in both directions: toLocalDate(), toLocalDateTime(), toLocalTime(), toZonedDateTime(), toOffsetDateTime(), toInstant() and zoneId from a Date or Calendar, and toDate() and toCalendar() from the JSR 310 types, plus toTimeZone() on ZoneId and ZoneOffset. The JDK’s Timestamp.toLocalDateTime() and java.sql.Date.toLocalDate() cover the JDBC types.

Three rules govern the conversion to a legacy type. The "local" types (LocalDate, LocalTime, LocalDateTime) use the system default time zone. A date alone becomes midnight, and a time alone is placed on the current date. Nanoseconds are truncated to milliseconds, as the last line of the example shows.

import java.time.*
TimeZone.default = TimeZone.getTimeZone('UTC')   // local types convert using the default zone

def legacy = Date.parse('yyyy-MM-dd HH:mm', '2026-03-15 08:30')
println legacy.toLocalDate()
println legacy.toLocalDateTime()
println legacy.toZonedDateTime()
println legacy.toOffsetDateTime()
println legacy.toInstant()
println legacy.toLocalTime()
println legacy.zoneId
println legacy.toCalendar().toZonedDateTime().toLocalDate()

def date = LocalDate.of(2026, 3, 15)
println date.toDate()                                    // midnight in the default zone
println date.atTime(8, 30).toDate()
println date.atTime(8, 30).toDate() == legacy
println Instant.parse('2026-03-15T08:30:00Z').toDate()
println ZonedDateTime.of(2026, 3, 15, 8, 30, 0, 0, ZoneId.of('Europe/Madrid')).toDate()
println date.atTime(8, 30).toCalendar().class.simpleName
println ZoneId.of('Asia/Tokyo').toTimeZone().ID
println TimeZone.getTimeZone('Europe/Madrid').toZoneId()
println new java.sql.Timestamp(legacy.time).toLocalDateTime()
println java.sql.Date.valueOf(date).toLocalDate()
println LocalTime.of(12, 0, 0, 999_999_999).toDate().format('HH:mm:ss.SSS')   // nanoseconds are truncated
2026-03-15
2026-03-15T08:30
2026-03-15T08:30Z[UTC]
2026-03-15T08:30Z
2026-03-15T08:30:00Z
08:30
UTC
2026-03-15
Sun Mar 15 00:00:00 UTC 2026
Sun Mar 15 08:30:00 UTC 2026
true
Sun Mar 15 08:30:00 UTC 2026
Sun Mar 15 07:30:00 UTC 2026
GregorianCalendar
Asia/Tokyo
Europe/Madrid
2026-03-15T08:30
2026-03-15
12:00:00.999

Greenhouse readings and testable clocks

Date arithmetic and the collection methods of Working with Collections combine naturally. Clock.fixed makes code that asks for "now" deterministic; pass a Clock into the code that needs it instead of calling LocalDate.now() inside.

import java.time.*

def clock = Clock.fixed(Instant.parse('2026-03-15T08:30:00Z'), ZoneId.of('UTC'))
println LocalDate.now(clock)
println LocalDateTime.now(clock) + Duration.ofHours(2)
println Duration.between(LocalDateTime.of(2026, 3, 15, 6, 0), LocalDateTime.now(clock))

def readings = [
    [at: LocalDateTime.of(2026, 3, 15, 6, 0),  bed: 'bed1', temp: 17.5],
    [at: LocalDateTime.of(2026, 3, 15, 12, 0), bed: 'bed1', temp: 24.0],
    [at: LocalDateTime.of(2026, 3, 16, 6, 0),  bed: 'bed1', temp: 18.0]]
println readings.groupBy { it.at.toLocalDate() }.collectEntries { day, rs -> [day.toString(), rs*.temp.max()] }
println readings.max { it.at }.at
println readings.sort(false) { a, b -> b.at <=> a.at }*.temp
println readings.count { it.at.hour < 12 }
2026-03-15
2026-03-15T10:30
PT2H30M
[2026-03-15:24.0, 2026-03-16:18.0]
2026-03-16T06:00
[18.0, 24.0, 17.5]
2

Pitfalls

  • Prefer java.time. Date is mutable and zone-less, Calendar months are zero based, and SimpleDateFormat is not thread safe.

  • The integer unit of + depends on the type: days for LocalDate, seconds for LocalTime and LocalDateTime. Write the unit (plusDays, Duration.ofMinutes) when the code would otherwise be ambiguous to a reader.

  • Period and Duration are different: adding Period.ofDays(1) across a daylight-saving change gives the same wall-clock time, while adding Duration.ofHours(24) does not.

  • parse(text, pattern) on java.time types has the text first; Date.parse(pattern, text) has the pattern first.

  • LocalDate.now() reads the system clock and time zone, which makes tests flaky; inject a Clock.

  • The TimeCategory DSL is for Date and Calendar. For java.time, use Duration.ofHours(2) and friends.

Where the book is outdated

Books from the Groovy 1.x and 2.x era predate java.time, so their date chapters describe only Date, Calendar, SimpleDateFormat and TimeCategory. The groovy-datetime extensions for the JSR 310 types are later than those books; what they say about the legacy classes remains correct and is covered above.