ISO8601FormatStyle produces unexpected results when including fractional seconds
While working on https://github.com/apple/swift-openapi-generator/issues/637, I noticed ISO8601FormatStyle formattes dates with fractional seconds incorreclty. The fractional seconds field is off by one.
Also, when parsing a formatted date with fractional seconds, the result is not equal to the initial formatted date.
You can reproduce the issue with the following code snippet.
import Foundation
let initialDate = Date(timeIntervalSince1970: 1_674_036_251.123)
let formatStyle = Date.ISO8601FormatStyle.iso8601
.year()
.month()
.day()
.time(includingFractionalSeconds: true)
let encoded = initialDate.formatted(formatStyle)
print(encoded) // prints 2023-01-18T10:04:11.122 instead of 2023-01-18T10:04:11.123
print(initialDate == (try! formatStyle.parse(encoded)) // prints false
Hi, could not replicate, copy/pasting your snippet... on what versions are you? does the issue still exist?
$ swift --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0
$ uname -a
Darwin JPXNGP6J95 23.6.0 Darwin Kernel Version 23.6.0: Wed Jul 31 20:48:52 PDT 2024; root:xnu-10063.141.1.700.5~1/RELEASE_ARM64_T6020 arm64
Hey! Thank you for checking! The issue still exists, I'm on the following:
$ swift --version
swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Target: arm64-apple-macosx15.0
$ uname -a
Darwin arthurs-mbp.home 24.0.0 Darwin Kernel Version 24.0.0: Mon Aug 12 20:51:54 PDT 2024; root:xnu-11215.1.10~2/RELEASE_ARM64_T6000 arm64
I had the same issue in my own project (not swift-openapi) when I updated my own iPhone, I first opened a thread on the Swift forums: https://forums.swift.org/t/rounding-error-in-milliseconds-using-iso8601formatstyle-instead-of-iso8601dateformatter/75206
I also opened this case on the feedback assistant: FB15418195 (Rounding error in milliseconds using ISO8601FormatStyle on iOS 18.0)
import Testing
import UIKit
struct DateFormatterTests {
@Test func testDateEncodingDecoding() async throws {
let originalIsoString = "2024-10-05T17:08:34.650Z"
let iso8601DateFormatter = ISO8601DateFormatter()
iso8601DateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let testDate = iso8601DateFormatter.date(from: originalIsoString)!
let oldFormatterString = iso8601DateFormatter.string(from: testDate)
#expect(oldFormatterString == originalIsoString)
let isoDateFormat: Date.ISO8601FormatStyle = .iso8601
.year()
.month()
.day()
.timeZone(separator: .omitted)
.time(includingFractionalSeconds: true)
.timeSeparator(.colon)
let date = try isoDateFormat.parse(originalIsoString)
let newFormatterString = isoDateFormat.format(date)
#expect(newFormatterString == originalIsoString)
}
}
It happens when using iOS 18. I have no idea how the implementation of this works, is it baked inside iOS? Could it be due to this change perhaps: https://github.com/swiftlang/swift-foundation/pull/453/commits/337a22e08704c1024aa8d32c4608e83fedd41174 ?
seems to be a problem of 6.0 vs 5.10 which matches the date of the change.. ill have a look over the weekend
@LordBurtz isn't this baked in the iOS version? How could I test this myself?
@parkera is this something you could help with when you get a chance? 🙇🏻
@stephentyrone was looking into this
We encountered the exact same issue. In some cases it's converted correctly, in others it's off by one.
Any indication when this will be fixed? It causes several issues in operations that require accuracy, especially when the value needs to be serialized and sent to a backend.
After the last comment I thought stephentyrone was taking care of this but i'll investigate it this weekend probably
In other parts of swift, we have control over both the precision and the rounding. Perhaps the default should be standard rounding, but then also allow for control over the rounding with options. My use case is simple though. If I had milliseconds of .127 in the date, and asked for .secondFraction(.fractional(2)) in the formatting so that times are only shown to the hundredths of a second, I'd be satisfied with simple rounding to .13 in the output string. As far as I know I don't need all of the other rounding rules.
let formatter = ISOISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let timestamp = formatter.date(from: "2025-01-21T12:34:45.127Z") let dateTimeFormat = Date.FormatStyle() .hour() .minute() .second() .secondFraction(.fractional(2)) let timestampString = timestamp.formatted(dateTimeFormat) // wrong 7:34:45.12, when it should be 7:34:45.13
@stephentyrone and @parkera can speak more to this, but my understanding is that it is inherently an issue with how Date is stored and what Foundation promises to do with Date -- We use DateComponents throughout the API and implementation, which promises to support up to nanoseconds, but Date is stored as a Double (a diff to timeIntervalSinceReferenceDate), and it is simply impossible to store all values to the nanosecond accuracy.
ISO8601 only guarantees up to milliseconds though, so it is unfortunate.
The behavior was introduced in an attempt to avoid something like this:
Time: 12:59:59.9999 Format: 3 digits of ms, rounding nearest Result: 12:59:59.000
Now, we could say that it should round the next field up instead. But of course that would be 12:60, which is nonsense, so actually it's 13:00. But doing this requires consulting with the time zone again to see if that's a valid time (e.g., on a boundary). So I think we may need to plumb some kind of rounding rule into the calculation of the date components themselves at the beginning of the formatting.