Skip to content
ethloPublic

About

An extremely fast parser and formatter of standardized date and date-times supporting RFC-3339 (ISO-8601 profile) and more.

Topics

Resources

Code of conduct

Security policy

Stars

41 stars

Watchers

2 watching

Forks

Repository files navigation

Internet Time Utility

Maven Central javadoc Hex.pm Codacy Badge codecov

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.

Features

  • 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[] or byte[] into a reusable buffer, and formatting into a char[] or byte[].
  • Parses Unix epoch seconds and milliseconds written as text through the same API.
  • Handles leap seconds.
  • Zero dependencies.
  • Java 8 compatible.
  • Apache 2 licensed.

Performance

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.

Benchmark summary

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.

Usage

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.

Parsing

parseRfc3339

source »

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);

parseLenient

source »

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);

parseLenientGranularity

source »

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);

lenientTimestamp

source »

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");

isValid

source »

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();

parseError

source »

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");

parseLenientWithCustomSeparators

source »

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");

parsePosition

source »

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);

parseIntoBuffer

source »

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);

parseBytes

source »

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);

parseEpoch

source »

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);

handleByGranularity

source »

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");

parseCustomFormat

source »

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");

parseUsingInterface

source »

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));

Formatting

formatRfc3339WithUTC

source »

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");

formatWithOffsetAndPrecision

source »

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");

formatIntoBuffer

source »

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");

formatWithDateTime

source »

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");

Leap seconds

parseLeapSecond

source »

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)));

parseUnknownLeapSecond

source »

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");

Durations

ITU.parseDuration parses a strict subset of ISO-8601 durations into a Duration of long seconds and int nanoseconds.

Supported units

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

Negative durations

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.

Normalized output

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.

Examples

parseDuration

source »

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);

parseNegativeDuration

source »

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

source »

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");

normalizedWithMaximumUnit

source »

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());

arithmetic

source »

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");

normalizedIntoBuffer

source »

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");

parseDurationError

source »

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");

Q & A

Why this little project?

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!

Why the performance focus?

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.

What is wrong with epoch timestamps?

  • 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

What is RFC-3339?

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 time
  • 2017-12-27T23:45:32.999Z - Millisecond fractions, UTC/Zulu time
  • 2017-12-27T23:45:32.999999Z - Microsecond fractions, UTC/Zulu time
  • 2017-12-27T23:45:32.999999999Z - Nanosecond fractions, UTC/Zulu time
  • 2017-12-27T18:45:32-05:00 - No fractional seconds, EST time
  • 2017-12-27T18:45:32.999-05:00 - Millisecond fractions, EST time
  • 2017-12-27T18:45:32.999999-05:00 - Microsecond fractions, EST time
  • 2017-12-27T18:45:32.999999999-05:00 - Nanosecond fractions, EST time

What is W3C - Date and Time Formats

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 time
  • 2017-12-27 - Date only, no timezone (like someone's birthday)
  • 2017-12 - Year and month only. Like an expiry date.

Limitations

Local offset

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.

Leap second parsing

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.

About

An extremely fast parser and formatter of standardized date and date-times supporting RFC-3339 (ISO-8601 profile) and more.

Topics

Resources

Code of conduct

Security policy

Stars

41 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages