An extremely fast parser and formatter of ISO-8601 date-times. Handles RFC-3339 timestamps and W3C Date and Time Formats with ease, and a strict subset of ISO-8601 durations.
- Low ceremony, high productivity with a very easy to use API.
- Well-documented.
- Aims for 100% specification compliance.
- Parses any granularity from a year to nanoseconds, and remembers which one it got.
- Zero-allocation parsing from
char[]orbyte[]into a reusable buffer, and formatting into achar[]orbyte[]. - Parses Unix epoch seconds and milliseconds written as text through the same API.
- Handles leap seconds.
- Zero dependencies.
- Java 8 compatible.
- Apache 2 licensed.
Parsing is 21–67× faster than java.time, validation without exceptions 43× or more, and formatting
3.5–6× faster. Duration parsing is around 5× faster, while duration formatting is on par with the JDK.
Measured against java.time on the same JDK and inputs, lower ns/op being better:
| Operation | ITU | JDK java.time |
Speed-up |
|---|---|---|---|
parse |
18–28 ns/op | 542–577 ns/op | 21–30× |
parseLenient |
11–17 ns/op | 755–788 ns/op | 46–67× |
isValid |
2–13 ns/op | 536–2,397 ns/op | 43–669× |
formatSeconds |
16 ns/op | 100 ns/op | 6.3× |
formatMillis |
19 ns/op | 115 ns/op | 6.1× |
formatNanos |
33 ns/op | 115 ns/op | 3.5× |
parseDuration |
25–52 ns/op | 134–247 ns/op | 4.8–5.4× |
formatDuration |
14–27 ns/op | 10–31 ns/op | 0.7–1.2× |
Spans are the range across the inputs benchmarked for that operation. The full report has the per-input numbers, error bars and the environment they were measured in; the benchmarks themselves live in date-time-wars.
Add dependency
<dependency>
<groupId>com.ethlo.time</groupId>
<artifactId>itu</artifactId>
<version>1.17.0</version>
</dependency>The examples below are the tests in src/test/java/samples, extracted into this
file by the build, so they are always current. The
javadoc has the details.
The simplest and fastest way to parse an RFC-3339 timestamp. The input must be a complete date-time with an
offset; the result is a java.time.OffsetDateTime.
final String text = "2012-12-27T19:07:22.123456789-03:00";
final OffsetDateTime dateTime = ITU.parseDateTime(text);
assertThat(dateTime).hasToString(text);Lenient parsing accepts anything from a year to a timestamp with nanoseconds, with or without an offset, and
returns a DateTime that remembers the most granular field, fraction digit count and offset presence.
Formatting emits a canonical representation while preserving that granularity.
final String text = "2012-12-27T19:07:23.123";
final DateTime dateTime = ITU.parseLenient(text);
assertThat(dateTime.getMostGranularField()).isEqualTo(Field.NANO);
assertThat(dateTime.getFractionDigits()).isEqualTo(3);
assertThat(dateTime.getOffset()).isEmpty();
assertThat(dateTime).hasToString(text);The granularity is kept, so a partial date can be converted to the matching java.time type, and asking for a
field that was not in the input is an error rather than a silent default.
assertThat(ITU.parseLenient("2012").toYear().getValue()).isEqualTo(2012);
assertThat(ITU.parseLenient("2012-12").toYearMonth()).hasToString("2012-12");
assertThat(ITU.parseLenient("2012-12-27").toLocalDate()).hasToString("2012-12-27");
assertThat(ITU.parseLenient("2012-12-27T19:07").toLocalDatetime()).hasToString("2012-12-27T19:07");
assertThat(ITU.parseLenient("2012-12-27T19:07:22+01:00").toOffsetDatetime()).hasToString("2012-12-27T19:07:22+01:00");
final DateTime dateOnly = ITU.parseLenient("2012-12-27");
assertThat(dateOnly.includesGranularity(Field.HOUR)).isFalse();
assertThrows(DateTimeException.class, dateOnly::toOffsetDatetime);When a best-effort timestamp is all that is needed, a DateTime of any granularity converts to an Instant:
missing month and day default to 1, missing time fields to 0, and a missing offset to UTC.
final Instant instant = ITU.parseLenient("2017-12-06").toInstant();
assertThat(instant).hasToString("2017-12-06T00:00:00Z");Checking validity without parsing, and without exceptions. The single-argument form checks for a full RFC-3339 date-time; the varargs form accepts any of the listed granularities.
assertThat(ITU.isValid("2012-12-27T19:07:22Z")).isTrue();
assertThat(ITU.isValid("2012-12-27")).isFalse();
assertThat(ITU.isValid("2012-12-27", TemporalType.LOCAL_DATE, TemporalType.OFFSET_DATE_TIME)).isTrue();
assertThat(ITU.isValid("2012-13-27", TemporalType.LOCAL_DATE)).isFalse();Errors are java.time.format.DateTimeParseException with the input and the 0-based index of the offending
character, and the message names the 1-based position and what was expected.
final DateTimeParseException exc = assertThrows(DateTimeParseException.class, () -> ITU.parseDateTime("2012-12-27T19:07:22.123456789"));
assertThat(exc.getErrorIndex()).isEqualTo(29);
assertThat(exc.getMessage()).isEqualTo("No timezone information: 2012-12-27T19:07:22.123456789");ParseConfig widens what is accepted: the characters allowed between date and time and before the fraction,
and whether text after the date-time is an error. ParseConfig.DEFAULT accepts T, t and space as the
date-time separator; ParseConfig.STRICT only T.
final ParseConfig config = ParseConfig.DEFAULT
.withDateTimeSeparators('T', '|')
.withFractionSeparators('.', ',');
final DateTime result = ITU.parseLenient("1999-11-22|11:22:17,191", config);
assertThat(result).hasToString("1999-11-22T11:22:17.191");A ParsePosition starts the parse inside a larger text and reports where it stopped. Text after the
date-time is not an error in this mode, since the position tells you where to continue.
final ParsePosition pos = new ParsePosition(10);
final OffsetDateTime result = ITU.parseDateTime("some-data,1999-11-22T11:22:19+05:30,some-other-data", pos);
assertThat(result).hasToString("1999-11-22T11:22:19+05:30");
assertThat(pos.getIndex()).isEqualTo(35);When the text is already available as characters, parse into a reusable buffer: nothing is allocated, and the
fields are read straight off the buffer. This is the fastest way to parse. The window [offset, offset + length)
is the text, so trailing junk inside it is rejected and anything outside it is never read.
final char[] chars = "2012-12-27T19:07:22.123456789-03:00".toCharArray();
final MutableDateTimeBuffer buffer = new MutableDateTimeBuffer();
final int consumed = ITU.parseLenient(chars, 0, chars.length, buffer);
assertThat(consumed).isEqualTo(35);
assertThat(buffer.getYear()).isEqualTo(2012);
assertThat(buffer.getNano()).isEqualTo(123456789);
assertThat(buffer.getOffsetTotalSeconds()).isEqualTo(-3 * 3600);
assertThat(buffer.toEpochMilli()).isEqualTo(1356646042123L);When the text arrives as bytes - a file, a socket, a JSON parser's buffer - parse the bytes directly: the same
grammar, results and errors as the char[] path, at the same cost, with no decoding step. The bytes are read as
ISO-8859-1, which is the identity for the ASCII a date-time can contain.
final byte[] bytes = "{\"ts\":\"2012-12-27T19:07:22.123Z\"}".getBytes(StandardCharsets.UTF_8);
final MutableDateTimeBuffer buffer = new MutableDateTimeBuffer();
final int consumed = ITU.parseLenient(bytes, 7, 24, buffer);
assertThat(consumed).isEqualTo(24);
assertThat(buffer).hasToString("2012-12-27T19:07:22.123Z");
assertThat(ITU.parseEpochMilli("1695300000123".getBytes(StandardCharsets.UTF_8), 0, 13, buffer)).isEqualTo(13);
assertThat(buffer.toEpochMilli()).isEqualTo(1695300000123L);A Unix epoch count written as text, in seconds or milliseconds, parses to the same DateTime as an RFC-3339
string, so a field that may carry either can go through one code path. The char[] overloads into a
MutableDateTimeBuffer exist for these too.
assertThat(ITU.parseEpochSecond("1695300000")).hasToString("2023-09-21T12:40:00Z");
assertThat(ITU.parseEpochMilli("1695300000123")).hasToString("2023-09-21T12:40:00.123Z");
assertThat(ITU.parseEpochMilli("-1").toInstant().toEpochMilli()).isEqualTo(-1);To handle each granularity differently, a TemporalHandler receives the parsed value as the matching
java.time type. TemporalConsumer is the same idea without a return value.
final TemporalHandler<OffsetDateTime> handler = new TemporalHandler<OffsetDateTime>()
{
@Override
public OffsetDateTime handle(final LocalDate localDate)
{
return localDate.atTime(OffsetTime.of(LocalTime.of(0, 0), ZoneOffset.UTC));
}
@Override
public OffsetDateTime handle(final OffsetDateTime offsetDateTime)
{
return offsetDateTime;
}
};
assertThat(ITU.parse("2017-12-06", handler)).hasToString("2017-12-06T00:00Z");
assertThat(ITU.parse("2017-12-06T10:15:30+02:00", handler)).hasToString("2017-12-06T10:15:30+02:00");A format that is not RFC-3339 can be described as a sequence of tokens. The parser produced is a
DateTimeParser, the same interface the built-in ones implement.
final DateTimeParser parser = DateTimeParsers.of(
digits(DAY, 2),
separators('-'),
digits(MONTH, 2),
separators('-'),
digits(YEAR, 4),
separators(' '),
digits(HOUR, 2),
digits(MINUTE, 2),
digits(SECOND, 2),
separators(','),
fractions()
);
final String text = "31-12-2000 235937,123456";
final DateTime result = parser.parse(text);
assertThat(result).hasToString("2000-12-31T23:59:37.123456");The built-in formats are available as DateTimeParser instances too, for code that takes a parser as a
parameter: RFC-3339, local date and local time.
final DateTimeParser rfc3339 = DateTimeParsers.rfc3339();
assertThat(rfc3339.parse("2000-12-31T23:59:37.123456Z")).hasToString("2000-12-31T23:59:37.123456Z");
final DateTimeParser localDate = DateTimeParsers.localDate();
assertThat(localDate.parse("2013-12-24").toLocalDate()).isEqualTo(LocalDate.of(2013, 12, 24));
final DateTimeParser localTime = DateTimeParsers.localTime();
assertThat(localTime.parse("23:59:37.123456").toLocalTime()).isEqualTo(LocalTime.of(23, 59, 37, 123456000));The simplest and fastest way to format an RFC-3339 timestamp: in UTC, with the fraction digits chosen by the method name. Milliseconds is the most common choice for interchange.
final OffsetDateTime input = OffsetDateTime.of(2012, 12, 27, 19, 7, 22, 123456789, ZoneOffset.ofHoursMinutes(-3, 0));
assertThat(ITU.formatUtc(input)).isEqualTo("2012-12-27T22:07:22Z");
assertThat(ITU.formatUtcMilli(input)).isEqualTo("2012-12-27T22:07:22.123Z");
assertThat(ITU.formatUtcMicro(input)).isEqualTo("2012-12-27T22:07:22.123456Z");
assertThat(ITU.formatUtcNano(input)).isEqualTo("2012-12-27T22:07:22.123456789Z");format keeps the offset of the input instead of converting to UTC. Like formatUtc it defaults to whole
seconds; both take the number of fraction digits as a parameter, and formatUtc can also stop at a given field.
final OffsetDateTime input = OffsetDateTime.of(2012, 12, 27, 19, 7, 22, 123456789, ZoneOffset.ofHoursMinutes(-3, 0));
assertThat(ITU.format(input)).isEqualTo("2012-12-27T19:07:22-03:00");
assertThat(ITU.format(input, 9)).isEqualTo("2012-12-27T19:07:22.123456789-03:00");
assertThat(ITU.formatUtc(input, 6)).isEqualTo("2012-12-27T22:07:22.123456Z");
assertThat(ITU.formatUtc(input, Field.MINUTE)).isEqualTo("2012-12-27T22:07Z");When the text is going into a buffer anyway - a socket, a file, a JSON generator - format straight into it and
allocate nothing. The buffer needs room for ITU.MAX_FORMAT_LENGTH characters from the offset; the result is
the first length of them. There are char[] and byte[] variants, and the output is ASCII, so the bytes are
the text in UTF-8 or any other ASCII-compatible encoding.
final OffsetDateTime input = OffsetDateTime.of(2012, 12, 27, 19, 7, 22, 123456789, ZoneOffset.ofHoursMinutes(-3, 0));
final byte[] bytes = new byte[ITU.MAX_FORMAT_LENGTH];
final int length = ITU.formatUtc(input, 3, bytes, 0);
assertThat(new String(bytes, 0, length, StandardCharsets.US_ASCII)).isEqualTo("2012-12-27T22:07:22.123Z");
final char[] chars = new char[ITU.MAX_FORMAT_LENGTH];
assertThat(new String(chars, 0, ITU.format(input, 0, chars, 0))).isEqualTo("2012-12-27T19:07:22-03:00");A DateTime formats to the granularity it carries, or to any coarser one. With an offset the result is
RFC-3339; without one it is the local form.
final DateTime local = DateTime.of(2020, 11, 27, 12, 39, 19, null);
assertThat(local).hasToString("2020-11-27T12:39:19");
assertThat(local.toString(Field.MINUTE)).isEqualTo("2020-11-27T12:39");
assertThat(local.toString(Field.DAY)).isEqualTo("2020-11-27");
final DateTime withOffset = DateTime.of(2020, 11, 27, 12, 39, 19, 500_000_000, TimezoneOffset.ofHoursMinutes(1, 0), 1);
assertThat(withOffset).hasToString("2020-11-27T12:39:19.5+01:00");
assertThat(withOffset.toString(3)).isEqualTo("2020-11-27T12:39:19.500+01:00");java.time cannot represent second 60, so a leap second is reported as a LeapSecondException rather than
silently changed. The exception tells you whether the leap second is a real one (on the list of announced leap
seconds) and gives the nearest representable date-time, which is the next minute.
final LeapSecondException exc = assertThrows(LeapSecondException.class, () -> ITU.parseDateTime("1990-12-31T15:59:60-08:00"));
assertThat(exc.getSecondsInMinute()).isEqualTo(60);
assertThat(exc.isVerifiedValidLeapYearMonth()).isTrue();
assertThat(exc.getNearestDateTime()).isEqualTo(OffsetDateTime.of(1990, 12, 31, 16, 0, 0, 0, ZoneOffset.ofHours(-8)));A second of 60 on a date that never had a leap second is still a leap-second exception, since the syntax is
valid, but isVerifiedValidLeapYearMonth is false so you can choose to reject it.
final LeapSecondException exc = assertThrows(LeapSecondException.class, () -> ITU.parseDateTime("2020-06-30T23:59:60Z"));
assertThat(exc.isVerifiedValidLeapYearMonth()).isFalse();
assertThat(exc.getNearestDateTime()).hasToString("2020-07-01T00:00Z");ITU.parseDuration parses a strict subset of ISO-8601 durations into a Duration of long seconds and
int nanoseconds.
- Weeks (
W) - Days (
D) - Hours (
H) - Minutes (
M) - Seconds (
S), with a fraction of up to nine digits
Years (Y) and months (M in the date part) are rejected: their length depends on the calendar, so
a duration containing them is not a fixed amount of time.
A negative duration is prefixed -P, as ISO-8601 specifies. The sign is carried by the seconds and the
nanosecond part is never negative, as in java.time.Duration.
Duration.normalized() renders using the largest units possible, so 3,000,000 seconds and 117,392,763
nanoseconds becomes P4W6DT17H20M0.117392763S. A negative duration gets a single leading -P rather than a
sign on each component.
normalized(DurationUnit) caps the largest unit emitted; anything above the cap stays folded into it:
| Cap | Output |
|---|---|
DurationUnit.WEEKS (default) |
P4W6DT17H20M0.117392763S |
DurationUnit.DAYS |
P34DT17H20M0.117392763S |
DurationUnit.HOURS |
PT833H20M0.117392763S |
DurationUnit.MINUTES |
PT50000M0.117392763S |
DurationUnit.SECONDS |
PT3000000.117392763S |
java.time.Duration.parse does not accept the week designator, so it cannot read back the default rendering
once a duration reaches a full week. Days and every smaller unit are accepted, including the leading -P
form, so a cap of DurationUnit.DAYS or lower keeps the output readable by the JDK.
A duration parses to seconds and a nanosecond part, exact to the nanosecond.
final Duration duration = ITU.parseDuration("P2DT3H4M5.678901234S");
assertThat(duration.getSeconds()).isEqualTo(2 * 86_400 + 3 * 3_600 + 4 * 60 + 5);
assertThat(duration.getNanos()).isEqualTo(678_901_234);A negative duration is written with the sign in front of P. The sign lives in the seconds and the nanosecond
part is never negative, as in java.time.Duration, so -PT0.5S is -1 second plus 500 million nanoseconds.
final Duration duration = ITU.parseDuration("-PT0.5S");
assertThat(duration.getSeconds()).isEqualTo(-1);
assertThat(duration.getNanos()).isEqualTo(500_000_000);
assertThat(duration.normalized()).isEqualTo("-PT0.5S");normalized() renders with the largest units possible, so overflowing components are carried: 28 hours
become a day and 4 hours. Trailing zero fractions are dropped.
final Duration duration = ITU.parseDuration("P4W10DT28H122M1.123456S");
assertThat(duration.normalized()).isEqualTo("P5W4DT6H2M1.123456S");
assertThat(ITU.parseDuration("PT90M").normalized()).isEqualTo("PT1H30M");java.time.Duration.parse does not accept weeks, so cap the largest unit at days (or lower) when the output
has to be read back by the JDK.
final Duration duration = ITU.parseDuration("P5W4DT6H2M1.123456S");
assertThat(duration.normalized(DurationUnit.DAYS)).isEqualTo("P39DT6H2M1.123456S");
assertThat(duration.normalized(DurationUnit.HOURS)).isEqualTo("PT942H2M1.123456S");
assertThat(java.time.Duration.parse(duration.normalized(DurationUnit.DAYS)).getSeconds()).isEqualTo(duration.getSeconds());Durations can be built, added, subtracted and compared, and placed on the timeline from an Instant.
final Duration total = Duration.ofHours(2).add(ITU.parseDuration("PT30M")).subtract(Duration.ofSeconds(1));
assertThat(total.normalized()).isEqualTo("PT2H29M59S");
assertThat(total.negate().normalized()).isEqualTo("-PT2H29M59S");
assertThat(total).isLessThan(Duration.ofHours(3));
final Instant start = Instant.parse("2024-02-28T23:00:00Z");
assertThat(total.timeline(start)).hasToString("2024-02-29T01:29:59Z");normalized can also write into a char[] or byte[] at an offset, allocating nothing; the buffer needs
room for Duration.MAX_NORMALIZED_LENGTH characters from the offset.
final Duration duration = ITU.parseDuration("P5W4DT6H2M1.123456S");
final byte[] bytes = new byte[Duration.MAX_NORMALIZED_LENGTH];
final int length = duration.normalized(DurationUnit.DAYS, bytes, 0);
assertThat(new String(bytes, 0, length, StandardCharsets.US_ASCII)).isEqualTo("P39DT6H2M1.123456S");Years and months are rejected because their length depends on the calendar. As for date-times, the error
is a DateTimeParseException with the 0-based index of the offending character.
final DateTimeParseException exc = assertThrows(DateTimeParseException.class, () -> ITU.parseDuration("P1Y2M"));
assertThat(exc.getErrorIndex()).isEqualTo(2);
assertThat(exc.getMessage()).isEqualTo("Invalid unit: Y: P1Y2M");There is an endless amount of APIs with non-standard date/time exchange, and the goal of this project is to make it a breeze to do the right thing!
Some projects use epoch time-stamps for date-time exchange, and from a performance perspective this may make sense in some cases. With this project one can do-the-right-thing and maintain performance in date-time handling.
Importantly, this project is not a premature optimization. In real-life scenarios there are examples of date-time parsing hindering optimal performance. The samples include data ingestion into databases and search engines, to importing/exporting data on less powerful devices, like cheaper Android devices.
- It is not human-readable, so debugging and direct manipulation is harder
- Limited resolution and/or time-range available
- Unclear resolution and/or time-range
RFC-3339 is a subset/profile defined by W3C of the formats defined in ISO-8601, to simplify date and time exchange in modern Internet protocols.
Typical formats include:
2017-12-27T23:45:32Z- No fractional seconds, UTC/Zulu time2017-12-27T23:45:32.999Z- Millisecond fractions, UTC/Zulu time2017-12-27T23:45:32.999999Z- Microsecond fractions, UTC/Zulu time2017-12-27T23:45:32.999999999Z- Nanosecond fractions, UTC/Zulu time2017-12-27T18:45:32-05:00- No fractional seconds, EST time2017-12-27T18:45:32.999-05:00- Millisecond fractions, EST time2017-12-27T18:45:32.999999-05:00- Microsecond fractions, EST time2017-12-27T18:45:32.999999999-05:00- Nanosecond fractions, EST time
Date and Time Formats is a note, meaning it is not endorsed, but it still serves as a sane subset of ISO-8601, just like RFC-3339.
Typical formats include:
2017-12-27T23:45Z- Minute resolution, UTC/Zulu time2017-12-27- Date only, no timezone (like someone's birthday)2017-12- Year and month only. Like an expiry date.
For the sake of avoiding data integrity issues, this library will not allow offset of -00:00. Such offset is described
in RFC3339 section 4.3., named "Unknown Local Offset Convention". Such offset is explicitly prohibited in ISO-8601 as
well.
If the time in UTC is known, but the offset to local time is unknown, this can be represented with an offset of "-00:00". This differs semantically from an offset of "Z" or "+00:00", which imply that UTC is the preferred reference point for the specified time.
Since java.time cannot store a leap second, ITU throws a LeapSecondException when it parses one, so that the
caller decides what to do. The exception carries the second value (60), whether the date is a known leap second,
and the nearest representable OffsetDateTime, which is the start of the next minute. See the
leap-second example above.
