ISO8601DateFormatter fails to parse a valid ISO-8601 date

Although, 2017-09-04T04:14:37.000Z is a valid ISO8601-formatted date, ISO8601DateFormatter cannot parse it. Seemingly, it happens when the string that represents an ISO8601-formatted date contains fractions of a second:

let formatter = ISO8601DateFormatter()

let ret1 = formatter.date(from: "2017-09-04T04:14:37.000Z"); // ret1 is null
let ret2 = formatter.date(from: "2017-09-04T04:14:37Z"); // ret2 is not null

I tracked down the issue and found that under the hood withInternetDateTime is used for formatOption and according to the documentation it doesn't support seconds fractions.

fileprivate var _iso8601Formatter: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = .withInternetDateTime
    return formatter
}()

Useful links:

ISO8601DateFormatter source code
NSDateFormatter milliseconds bug

5 Likes

There is another option .withFractionalSeconds which allows parsing when fractional seconds are included. (use [.withInternetDateTime, .withFractionalSeconds])

That being said, it is then not parsing the one without fractional seconds :frowning:

1 Like

True. As far as I know, the fractional seconds part must be optional.

I have experienced the same issue. ISO8601DateFormatter does not support full ISO08601 with regard to fractional seconds. The way I've worked around it is by using a .custom .dateDecodingStrategy on JSONDecoder that attempts to parse the date through a chain of formatters.

1 Like

That is a very inefficient way of doing this I guess.
If you know at least that certain properties have certain formats, you can use a custom strategy and switch on the current key.

1 Like

This is what I do too. I created this post to bring up the issue to see if there is any reasonable explanation for not supporting seconds fractions or not. Otherwise, we should fix it.

5 Likes

I don't think I could assume that certain properties would have guaranteed format. After all, they were all ISO08601, it just that sometimes fractional seconds were missing.

But I agree, switch would be more efficient if possible.

Is there any progress on this subject?
I am having this issue where I send an ISO8601 date which is not always parsed.
When looking at the ISO8601 definitions at Date and Time Formats ,
there are many different allowed formats:
The formats are as follows. Exactly the components shown here must be present, with exactly this punctuation. Note that the "T" appears literally in the string, to indicate the beginning of the time element, as specified in ISO 8601.

Year:
YYYY (eg 1997)
Year and month:
YYYY-MM (eg 1997-07)
Complete date:
YYYY-MM-DD (eg 1997-07-16)
Complete date plus hours and minutes:
YYYY-MM-DDThh:mmTZD (eg 1997-07-16T19:20+01:00)
Complete date plus hours, minutes and seconds:
YYYY-MM-DDThh:mm:ssTZD (eg 1997-07-16T19:20:30+01:00)
Complete date plus hours, minutes, seconds and a decimal fraction of a
second
YYYY-MM-DDThh:mm:ss.sTZD (eg 1997-07-16T19:20:30.45+01:00)
where:

 YYYY = four-digit year
 MM   = two-digit month (01=January, etc.)
 DD   = two-digit day of month (01 through 31)
 hh   = two digits of hour (00 through 23) (am/pm NOT allowed)
 mm   = two digits of minute (00 through 59)
 ss   = two digits of second (00 through 59)
 s    = one or more digits representing a decimal fraction of a second
 TZD  = time zone designator (Z or +hh:mm or -hh:mm)

in particular: "s = one or more digits representing a decimal fraction of a second"

So why does this work the way it does?

Kind regards,
Wouter Wessels

Yes, the lack of turning on the option for fractional seconds on ISO8601DateFormatter is a pain point for me when parsing JSON. You can make your own DateFormatter that supports fractional seconds. I've used SwiftDate's ISO Parser for this also.

I don't know why this option has been left off but it seems like a bug or bad decision to do it that way.

1 Like

I solved this problem by writing my own date formatted. To answer your questions:

Let's first look at the implementation of DateEncodingStrategy:

/// Encode the `Date` as an ISO-8601-formatted string (in RFC 3339 format).
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
case iso8601

You need these 3 pieces of information to understand what it means:

  1. What is the difference between ISO 8601 and RFC 3339?

RFC 3339 is listed as a profile of ISO 8601 link

  1. What does profile mean?

A Profile of ISO 8601 is a specification developed by a particular community which explains how ISO 8601 is to be used, to carry out a particular function or group of functions relevant to that community. link

So in Swift we are using an implementation of RFC3339.

  1. OK, but why doesn't JSONDecoder support fractions of a second?
    In section 5.6 of RFC3339, you can find the exact specification of a valid Internet Date/Time format and in this specification time-secfrac is optional:
partial-time    = time-hour ":" time-minute ":" time-second[time-secfrac]

This part of RFC may justify why they decided not to support this optional part:

5.3. Rarely Used Options
...
The format defined below includes only one rarely used option:
fractions of a second. It is expected that this will be used only by
applications which require strict ordering of date/time stamps or
which have an unusual precision requirement.

Though, based on my day-to-day experience fractions of a second is not rare at all.

1 Like

Is there a way to make the world a better place?
I mean: how can we improve the behaviour of this? Can we create a bug report/feature request, or do we have to keep writing our own duplicates of built in classes because the built in classes are sub-optimal?
Kind regards,
Wouter

AFAICS, the solution is simple. We just need to change one line of JSONEncoder.swift.
Where the _iso8601Formatter is being created:

private var _iso8601Formatter: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = .withInternetDateTime
    return formatter
}()

needs to be changed to:

private var _iso8601Formatter: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    return formatter
}()

But by this change 'fractions of second' becomes mandatory. A workaround could be adding a associated value to .iso8601 case where we can specify if withFractionalSeconds must be included in formatOptions or not. A less efficient solution is trying to decode data without .withFractionalSeconds first and if the result is nil, trying again but this time with .withFractionalSeconds. Also, we can add a new case to DateEncodingStrategy, .iso8601FractionalSeconds.

Any way, I guess the right direction is to pitch a proposal in this forum.

The Foundation API is an Apple product not subject to the Swift Evolution process except as it impacts the Swift compiler, standard library, or other components of the Swift project.

But the proposed change is in the standard library...

That's not true — although the base Encoder and Decoder protocol types are in the stdlib, JSONDecoder and its sibling PropertyListDecoder (and the encoder variants) live in Foundation and are subject to Foundation evolution, separately from stdlib evolution.

1 Like

When I worked on JSONDecoder, I filed a Radar away for adding .iso8601WithOptions(...) which would allow you to specify the ISO8601DateFormatter options you want. Although it's a tiny change, it's possibly too small of a change to spin up the whole API review process for. I had always planned on bundling that in with other JSONDecoder changes, but didn't get a chance to.

I would recommend filing Feedback to add visibility to this; although it will likely get duped to the original Radar, this is still a nice QoL change that I think is worth making.

4 Likes

When encoding iso8601 dates you might want careful control of the format. When decoding iso8601 dates I think you want a lenient parser, especially if you're writing a parser that will be the default for all users of JSONDecoder. It's frankly bizarre that JSONDecoder uses seconds as the default format for Date instead of the iso8601 format. Seconds might make sense for how to encode/decode Date in general but not for JSON.

Aside from the fractional seconds issue when decoding iso8601 dates I've also run into an issue with dash vs colon in the time zone specifier. SwiftDate's ISOParser is lenient and accepts all these variants so for now I use a custom formatter for decoding iso8601 dates in JSON that's based on SwiftDate.

Date parsing is one of the bigger pain points in using Decodable.

4 Likes

You can also try my library, JJLISO8601DateFormatter, that's a drop-in replacement for ISO8601DateFormatter and is ~10x faster. Changes like this get quickly merged, so feel free to file an issue.

3 Likes

Based on Date and Time Formats the desired format must have fractional seconds or not, therefore a parsers need not to handle both styles. So, assuming each API follows a different standard, the only thing you would need to do is to specify a different parsing strategy for each API.

The formats are as follows. Exactly the components shown here must be present, with exactly this punctuation.

s = one or more digits representing a decimal fraction of a second

An adopting standard that permits fractions of a second must specify both the minimum number of digits (a number greater than or equal to one)

Based on that it's understandable why ISO8601DateFormatter works the way it does. Is it strict? Yes, but so is the ISO8601 spec.

Using .withFullDate in combination of . withFractionalSeconds option seems that you're able to match with msec or without msec:

//let timestamp = "2021-06-16T08:21:56.000Z"  // option 1, with msec
let timestamp = "2021-06-16T08:21:56" // option 2, without msec

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd/MM/yyyy"

let inputFormatter = ISO8601DateFormatter()
inputFormatter.formatOptions = [
    .withFractionalSeconds,
    .withFullDate
]

if let dateString = inputFormatter.date(from: timestamp) {
    print( dateFormatter.string(from: dateString) )
    // 16/06/2021
}

hope that helps.