Java has had a Date class from the very beginning and Groovy supports using it and several related classes like Calendar. Throughout this blog post we refer to those classes as the legacy date classes.
Groovy enhances the experience of using the legacy date classes with simpler mechanisms for formatting, parsing and extracting fields from the related classes.

Since Java 8, the JDK has included the JSR-310 Date Time API. We refer to these classes as the new date classes. The new date classes remove many limitations of the legacy date classes and bring some greatly appreciated additional consistency. Groovy provides similar enhancements for the new date classes too.

Groovy's enhancements for the legacy date classes are in the groovy-dateutil module (prior to Groovy 2.5, this functionality was built in to the core module). The groovy-datetime module has the enhancements for the new date classes. You can include a dependency to this module in your build file or reference the groovy-all pom dependency. Both modules are part of a standard Groovy install.

The next few sections illustrate common date and time tasks and the code to perform them using the new and legacy classes with Groovy enhancements in numerous places.

Please note: that some of the formatting commands are Locale dependent and the output may vary slightly if you run these examples yourself.

Representing the current date/time

The legacy date classes have an abstraction which includes date and time. If you are only interested in one of those two aspects, you simply ignore the other aspect. The new date classes allow you to have date-only, time-only and date-time representations.

The examples create instances representing the current date and/or time. Various information is extracted from the instances and they are printed in various ways. Some of the examples use the SV macro which prints the name and string value of one or more variables.

task java.time legacy

current date and time

println LocalDateTime.now()      
println Instant.now()
2022-10-24T12:40:02.218130200
2022-10-24T02:40:02.223131Z
println new Date()               
println Calendar.instance.time
Mon Oct 24 12:40:02 AEST 2022
Mon Oct 24 12:40:02 AEST 2022
day of current year &
day of current month
println LocalDateTime.now().dayOfYear
println LocalDateTime.now().dayOfMonth
297
24
println Calendar.instance[DAY_OF_YEAR]
println Calendar.instance[DAY_OF_MONTH]
297
24
extract today's
year, month & day
var now = LocalDate.now()     // or LocalDateTime

println SV(now.year, now.monthValue, now.dayOfMonth)

(Y, M, D) = now[YEAR, MONTH_OF_YEAR, DAY_OF_MONTH]
println "Today is $Y $M $D"
now.year=2022, now.monthValue=10, now.dayOfMonth=24
Today is 2022 10 24
var now = Calendar.instance
(_E, Y, M, _WY, _WM, D) = now
println "Today is $Y ${M+1} $D"

(Y, M, D) = now[YEAR, MONTH, DAY_OF_MONTH]
println "Today is $Y ${M+1} $D"
Today is 2022 10 24
Today is 2022 10 24
alternatives to print today
println now.format("'Today is 'YYYY-MM-dd")
printf 'Today is %1$tY-%1$tm-%1$te%n', now
Today is 2022-10-24
Today is 2022-10-24
println now.format("'Today is 'YYYY-MM-dd")
printf 'Today is %1$tY-%1$tm-%1$te%n', now
Today is 2022-10-24
Today is 2022-10-24

extract parts of current time

now = LocalTime.now() // or LocalDateTime
println SV(now.hour, now.minute, now.second)
(H, M, S) = now[HOUR_OF_DAY, MINUTE_OF_HOUR,
SECOND_OF_MINUTE]
printf 'The time is %02d:%02d:%02d\n', H, M, S
now.hour=12, now.minute=40, now.second=2
The time is 12:40:02
(H, M, S) = now[HOUR_OF_DAY, MINUTE, SECOND]

println SV(H, M, S)
printf 'The time is %02d:%02d:%02d%n', H, M, S
H=12, M=40, S=2
The time is 12:40:02
alternatives to print time
println now.format("'The time is 'HH:mm:ss")
printf 'The time is %1$tH:%1$tM:%1$tS%n', now
The time is 12:40:02
The time is 12:40:02
println now.format("'The time is 'HH:mm:ss")
printf 'The time is %1$tH:%1$tM:%1$tS%n', now
The time is 12:40:02
The time is 12:40:02

Processing times

The new date classes have a LocalTime class specifically for representing time-only quantities. The legacy date classes don't have such a purpose-built abstraction; you essentially just ignore the day, month, and year parts of a date. The java.sql.Time class could be used as an alterative but rarely is. The Java documentation comparing the new date classes to their legacy equivalents, talks about
using GregorianCalendar with the date set to the epoch value of 1970-01-01
as an approximation of the LocalTime class. We'll follow that approach here to provide a comparison but we strongly recommend upgrading to the
new classes if you need to represent time-only values or use the Joda-Time library on JDK versions prior to 8.

The examples look at representing a minute before and after midnight, and some times at which you might eat your meals. For the meals, as well as printing various values, we might be interested in calculating new times in terms of existing times, e.g. lunch and dinner are 7 hours apart.

task java.time legacy
one min after midnight
LocalTime.of(0, 1).with {
println format('HH:mm')
println format('hh:mm a')
println format('K:mm a')
}
00:01
12:01 am
0:01 am
Calendar.instance.with {
clear()
set(MINUTE, 1)
println format('HH:mm')
println format('hh:mm a')
println format('K:mm a')
}
00:01
12:01 am
0:01 am
one min before midnight
LocalTime.of(23, 59).with {
println format('HH:mm')
println format('hh:mm a')
println format('K:mm a')
}
23:59
11:59 pm
11:59 pm
Calendar.instance.with {
clear()
set(hourOfDay: 23, minute: 59)
println format('HH:mm')
println format('hh:mm a')
println format('K:mm a')
}
23:59
11:59 pm
11:59 pm
meal times
var breakfast = LocalTime.of(7, 30)
var lunch = LocalTime.parse('12:30')
assert lunch == LocalTime.parse('12:30.00 pm', 'hh:mm.ss a')
lunch.with { assert hour == 12 && minute == 30 }
var dinner = lunch.plusHours(7)
assert dinner == lunch.plus(7, ChronoUnit.HOURS)
assert Duration.between(lunch, dinner).toHours() == 7
assert breakfast.isBefore(lunch) // Java API
assert lunch < dinner // Groovy shorthand
assert lunch in breakfast..dinner
assert dinner.format('hh:mm a') == '07:30 pm'
assert dinner.format('k:mm') == '19:30'
assert dinner.format(FormatStyle.MEDIUM) == '7:30:00 pm'
assert dinner.timeString == '19:30:00'
var breakfast = Date.parse('hh:mm', '07:30')
var lunch = Calendar.instance.tap {
clear()
set(hourOfDay: 12, minute: 30)
}
assert lunch[HOUR_OF_DAY, MINUTE] == [12, 30]
var dinner = lunch.clone().tap { it[HOUR_OF_DAY] += 7 }
assert dinner == lunch.copyWith(hourOfDay: 19)
assert dinner.format('hh:mm a') == '07:30 pm'
assert dinner.format('k:mm') == '19:30'
assert dinner.time.timeString == '7:30:00 pm'
assert breakfast.before(lunch.time) // Java API
assert lunch < dinner // Groovy shorthand

Processing dates

To represent date-only information with the legacy date classes, you set the time aspects to zero, or simply ignore them. Alternatively, you could consider the less commonly used java.sql.Date class. The new date classes have the special LocalDate class for this purpose which we highly recommend.

The examples create dates for Halloween and Melbourne Cup day (a public holiday in the Australia state of Victoria). We look at various properties of those two dates.

task java.time legacy
holidays
var halloween22 = LocalDate.of(2022, 10, 31)
var halloween23 = LocalDate.parse('2023-Oct-31', 'yyyy-LLL-dd')
assert halloween22 == halloween23 - 365
assert halloween23 == halloween22.plusYears(1)
var melbourneCup22 = LocalDate.of(2022, 11, 1)
assert halloween22 < melbourneCup22
assert melbourneCup22 - halloween22 == 1
assert Period.between(halloween22, melbourneCup22).days == 1
assert ChronoUnit.DAYS.between(halloween22, melbourneCup22) == 1L
var days = []
halloween22.upto(melbourneCup22) {days << "$it.dayOfWeek" }
assert days == ['MONDAY', 'TUESDAY']
var hols = halloween22..melbourneCup22
assert hols.size() == 2
var halloween21 = Date.parse('dd/MM/yyyy', '31/10/2021')
var halloween22 = Date.parse('yyyy-MMM-dd', '2022-Oct-31')
assert halloween21 + 365 == halloween22
var melbourneCup22 = new GregorianCalendar(2022, 10, 1).time
assert melbourneCup22.dateString == '1/11/22' // AU Locale
assert halloween22 < melbourneCup22
assert melbourneCup22 - halloween22 == 1
assert melbourneCup22 == halloween22.copyWith(month: 10, date: 1)
var days = []
halloween22.upto(melbourneCup22) { days << it.format('EEEEE') }
assert days == ['Monday', 'Tuesday']
var hols = halloween22..melbourneCup22
assert hols.size() == 2

Processing date and time combinations

The new date classes use LocalDateTime to represent a quantity with both date and time aspects. Many of the methods seen earlier will also be applicable here.

The examples show creating and printing a representation of lunch on Melbourne Cup day.

task java.time legacy
holidays
var melbourneCupLunch = LocalDateTime.of(2022, 11, 1, 12, 30)
assert melbourneCupLunch.timeString == '12:30:00'
assert melbourneCupLunch.dateString == '2022-11-01'
assert melbourneCupLunch.dateTimeString == '2022-11-01T12:30:00'
assert melbourneCupLunch.toLocalDate() == melbourneCup22
assert melbourneCupLunch.toLocalTime() == lunch
assert melbourneCupLunch == melbourneCup22 << lunch
var melbourneCupLunch = new GregorianCalendar(2022, 10, 1, 12, 30).time
assert melbourneCupLunch.timeString == '12:30:00 pm' // Locale specific
assert melbourneCupLunch.dateString == '1/11/22' // Locale specific
assert melbourneCupLunch.dateTimeString == '1/11/22, 12:30:00 pm' // Locale specific
assert melbourneCupLunch.clearTime() == melbourneCup22

Processing zoned date and times

The legacy date classes have the concept of a TimeZone, predominantly used by the Calendar class. The new date classes has a similar concept but uses the ZoneId, ZoneOffset, and ZonedDateTime classes (among others).

The examples show various properties of zones and show that during the Melbourne cup breakfast, it would still be the night before (Halloween) in Los Angeles. They also show that those two zones are 18 hours apart at that time of the year.

task java.time legacy
holidays
var aet = ZoneId.of('Australia/Sydney')
assert aet.fullName == 'Australian Eastern Time' && aet.shortName == 'AET'
assert aet.offset == ZoneOffset.of('+11:00')
var melbCupBreakfastInAU = ZonedDateTime.of(melbourneCup22, breakfast, aet)
var melbCupBreakfast = LocalDateTime.of(melbourneCup22, breakfast)
assert melbCupBreakfastInAU == melbCupBreakfast << aet
var pst = ZoneId.of('America/Los_Angeles')
assert pst.fullName == 'Pacific Time' && pst.shortName == 'GMT-08:00'
var meanwhileInLA = melbCupBreakfastInAU.withZoneSameInstant(pst)
assert halloween22 == meanwhileInLA.toLocalDate()
assert aet.offset.hours - pst.offset.hours == 18
var aet = TimeZone.getTimeZone('Australia/Sydney')
assert aet.displayName == 'Australian Eastern Standard Time'
assert aet.observesDaylightTime()
var melbourneCupBreakfast = new GregorianCalendar(aet).tap {
set(year: 2022, month: 10, date: 1, hourOfDay: 7, minute: 30)
}
var pst = TimeZone.getTimeZone('America/Los_Angeles')
assert pst.displayName == 'Pacific Standard Time'
var meanwhileInLA = new GregorianCalendar(pst).tap {
setTimeInMillis(melbourneCupBreakfast.timeInMillis)
}
assert meanwhileInLA.time.format('MMM dd', pst) == halloween22.format('MMM dd')
assert aet.rawOffset / 3600000 - pst.rawOffset / 3600000 == 18


Other useful classes

The new date classes offer a few more useful classes. Here are some of the common ones:

  • OffsetDateTime - like ZonedDateTime but with just an offset from UTC rather than a full time-zone
  • Instant - like OffsetDateTime but tied to UTC
  • YearMonth - like a LocalDate but with no day component
  • MonthDay - like a LocalDate but with no year component
  • Period - used to represent periods of time, e.g. Period.ofDays(14), Period.ofYears(2); see also the LocalDate example above.
  • Duration - a time-based amount of time, e.g. Duration.ofSeconds(30), Duration.ofHours(7); see also the LocalTime example above.

Conversions

It is useful to convert between the new and legacy classes. Some useful conversion methods are shown below with Groovy enhancements shown in blue.

From Conversion method/property
GregorianCalendar 
toInstant()
toZonedDateTime()
from(ZonedDateTime)
Calendar
toInstant()
toZonedDateTime()
toOffsetDateTime()
toLocalDateTime()
toLocalDate()
toLocalTime()
toOffsetTime()
toDayOfWeek()
toYear()
toYearMonth()
toMonth()
toMonthDay()
zoneOffset
zoneId
Date
toInstant()
from(Instant)
toZonedDateTime()
toOffsetDateTime()
toLocalDateTime()
toLocalDate()
toLocalTime()
toOffsetTime()
toDayOfWeek()
toYear()
toYearMonth()
toMonth()
toMonthDay()
zoneOffset
zoneId
ZonedDateTime
OffsetDateTime
LocalDateTime
LocalDate
LocalTime
toDate()
toCalendar()

SimpleDateFormat patterns

We saw several examples above using the format and parse methods. For the legacy date classes, numerous Groovy enhancements delegate to SimpleDateFormat.
This class represents date/time formats using pattern strings. These are special letters to represent some time or date component mixed with escaped literal strings. The special letters are often repeated to represent the minimum size field for number components and whether the full or an abbreviated form is used for other components.

As an example, for the U.S. locale and U.S. Pacific Time time zone, the following pattern:

yyyy.MM.dd G 'at' HH:mm:ss z

would apply to the following text:

2001.07.04 AD at 12:08:56 PDT

Letter  Description
G Era designator AD
y Year 1996; 96
Y Week year (similar to year but allotted by weeks; the first/last few days of a year might be allotted to finish/start the last/previous week)
M Month in year (context sensitive) July; Jul; 07
L Month in year (standalone form) July; Jul; 07
w Week in year 27
W Week in month 2
D Day in year 189
d Day in month 10
F Day of week in month 2
E Day name in week Tuesday; Tue
u Day number of week (1 = Monday, ..., 7 = Sunday)
a Am/pm marker PM
H Hour in day (0-23) 0
k Hour in day (1-24) 24
K Hour in am/pm (0-11) 0
h Hour in am/pm (1-12) 12
m Minute in hour 30
s Second in minute 55
S Millisecond 978
z Time zone Pacific Standard Time; PST; GMT-08:00
Z Time zone (RFC 822) -0800
X Time zone (ISO 8601) -08; -0800; -08:00
' to escape text put a single quote on either side
'' two single quotes for a literal single quote '


DateTimeFormatter patterns

Groovy's format and parse enhancements for the new date classes delegate to the DateTimeFormatter class. It's behavior is similar to what we saw for SimpleDateFormat but with slightly different conversion letters:

Conversion suffix  Description
G era AD
u year 2004; 04
y year-of-era 2004; 04
D day-of-year 189
M/L month-of-year 7; 07; Jul; July; J
d day-of-month 10
Q/q quarter-of-year 3; 03; Q3; 3rd quarter
Y week-based-year 1996; 96
w week-of-week-based-year 27
W week-of-month 4
E day-of-week Tue; Tuesday; T
e/c localized day-of-week 2; 02; Tue; Tuesday; T
F week-of-month 3
a am-pm-of-day PM
h clock-hour-of-am-pm (1-12) 12
K hour-of-am-pm (0-11) 0
k clock-hour-of-am-pm (1-24) 0
H hour-of-day (0-23) 0
m minute-of-hour 30
s second-of-minute 55
S fraction-of-second 978
A milli-of-day 1234
n nano-of-second 987654321
N nano-of-day 1234000000
V time-zone ID America/Los_Angeles; Z; -08:30
z time-zone name Pacific Standard Time; PST
O localized zone-offset GMT+8; GMT+08:00; UTC-08:00;
X zone-offset 'Z' for zero Z; -08; -0830; -08:30; -083015; -08:30:15;
x zone-offset +0000; -08; -0830; -08:30; -083015; -08:30:15;
Z zone-offset +0000; -0800; -08:00;
p pad next
' to escape text put a single quote on either side
'' two single quotes for a literal single quote '

Localized Patterns

JDK19 adds the ofLocalizedPattern(String requestedTemplate) method. The requested template is one or more regular expression pattern symbols ordered from the largest to the smallest unit, and
consisting of the following patterns:

     "G{0,5}" +        // Era
     "y*" +            // Year
     "Q{0,5}" +        // Quarter
     "M{0,5}" +        // Month
     "w*" +            // Week of Week Based Year
     "E{0,5}" +        // Day of Week
     "d{0,2}" +        // Day of Month
     "B{0,5}" +        // Period/AmPm of Day
     "[hHjC]{0,2}" +   // Hour of Day/AmPm (refer to LDML for 'j' and 'C')
     "m{0,2}" +        // Minute of Hour
     "s{0,2}" +        // Second of Minute
     "[vz]{0,4}"       // Zone

The requested template is mapped to the closest of available localized format as defined by the Unicode LDML specification. Here is an example of usage:

var now = ZonedDateTime.now()
var columns = '%7s | %10s | %10s | %10s | %14s%n'
printf columns, 'locale', 'GDK', 'custom', 'local', 'both'
[locale('en', 'US'),
locale('ro', 'RO'),
locale('vi', 'VN')].each { locale ->
Locale.default = locale
var gdk = now.format('y-MM-dd')
var custom = now.format(ofPattern('y-MM-dd'))
var local = now.format(ofLocalizedDate(SHORT))
var both = now.format(ofLocalizedPattern('yMM'))
printf columns, locale, gdk, custom, local, both
}

Which has this output:

locale |        GDK |     custom |      local |           both
en_US | 2022-12-18 | 2022-12-18 | 12/18/22 | 12/2022
ro_RO | 2022-12-18 | 2022-12-18 | 18.12.2022 | 12.2022
vi_VN | 2022-12-18 | 2022-12-18 | 18/12/2022 | tháng 12, 2022

Example credit: this example from Nicolai Parlog.

Formatter formats

The java.util.Formatter class is a base class in Java for various kinds of formatting. It can be used directly, via String.format, parse, printf, or Groovy's sprintf.
We saw several examples of using printf and parse formatting in the above examples.

The Formatter class has methods which take a format string as its first argument and zero or more additional arguments.
The format string typically has one or more format specifiers (starting with a percent character) which
indicate that a formatted version of one of the additional arguments should be placed into the string at that point.
The general form of a format specifier is:

%[argument_index$][flag][width][.precision]conversion

Most of the parts are optional. The argument_index part is only used when referencing
one of the additional arguments more than once (or out of order). The precision part
is only used for floating point numbers. The flag part is used to indicate always include sign(+),
zero-padding(0), locale-specific comma delimiters(,), and left justification(-).
The width indicates the minimum number of characters for the output.
The conversion indicates how the argument should be processed, e.g. as a numeric field, a date,
a special character, or some other special processing. Upper and lowercase variants exist for most conversions
which, for the uppercase variant, will call toUpperCase after the conversion is complete.

Conversion  Description
'b', 'B' Treat as a boolean or false if null
'h', 'H' Output the arguments hashcode as a hex string
's', 'S' Treat as a String
'c', 'C' Treat as a Unicode character
'd' Treat as a decimal integer
'o' Treat as an octal integer
'x', 'X' Treat as a hexadecimal integer
'e', 'E' Treat as a decimal number in scientific notation
'f' Treat as a floating point number
'g', 'G' Treat as a floating point in either decimal or scientific notation
'a', 'A' Treat as a hexadecimal floating-point number
't', 'T' Treat as the prefix for a date/time conversion
'%' A literal percent
'n' A line separator

When the date/time prefix is used, additional suffixes are applicable.

For formatting times:

Conversion suffix  Description
'H' Hour of the day for the 24-hour clock as two digits 00 - 23
'I' Hour for the 12-hour clock as two digits 01 - 12
'k' Hour of the day for the 24-hour clock 0 - 23
'l' Hour for the 12-hour clock 1 - 12
'M' Minute within the hour as two digits 00 - 59
'S' Seconds within the minute as two digits 00 - 60
("60" is used for leap seconds)
'L' Millisecond within the second as three digits 000 - 999
'N' Nanosecond within the second as nine digits 000000000 - 999999999
'p' Locale-specific morning or afternoon marker in lower case, am or pm
(The conversion prefix 'T' forces this output to upper case)
'z' RFC 822 style numeric time zone offset from GMT -0800
(Adjusted as needed for Daylight Saving Time)
'Z' Abbreviated time zone
's' Seconds since the beginning of the epoch starting at 1 January 1970 00:00:00 UTC
'Q' Milliseconds since the beginning of the epoch starting at 1 January 1970 00:00:00 UTC


For formatting dates:

Conversion suffix  Description
'B' Locale-specific full month name January
'b', 'h' Locale-specific abbreviated month name Jan
'A' Locale-specific full name of the day of the week Sunday
'a' Locale-specific short name of the day of the week Sun
'C' First two digits of four-digit year 00 - 99
'Y' Year as four digits 0092
'y' Last two digits of the year 00 - 99
'j' Day of year as three digits 001 - 366
'm' Month as two digits 01 - 13
'd' Day of month as two digits 01 - 31
'e' Day of month 1 - 31


For formatting date/time compositions:

Conversion suffix  Description
'R' Time formatted for the 24-hour clock as "%tH:%tM"
'T' Time formatted for the 24-hour clock as "%tH:%tM:%tS"
'r' Time formatted for the 12-hour clock as "%tI:%tM:%tS %Tp"
The location of the morning or afternoon marker ('%Tp') may be locale-dependent.
'D' Date formatted as "%tm/%td/%ty"
'F' ISO 8601 date formatted as "%tY-%tm-%td"
'c' Date and time formatted as "%ta %tb %td %tT %tZ %tY" Sun Jul 21 15:17:00 EDT 1973


Further information