Skip to content

Round ISO8601 fractional seconds to the nearest millisecond#1997

Open
adityasingh2400 wants to merge 3 commits into
swiftlang:mainfrom
adityasingh2400:iso8601-fractional-seconds-rounding
Open

Round ISO8601 fractional seconds to the nearest millisecond#1997
adityasingh2400 wants to merge 3 commits into
swiftlang:mainfrom
adityasingh2400:iso8601-fractional-seconds-rounding

Conversation

@adityasingh2400

Copy link
Copy Markdown
Contributor

Date.ISO8601FormatStyle with includingFractionalSeconds reports the milliseconds field one millisecond too low for many values. For example:

let date = Date(timeIntervalSince1970: 1_674_036_251.123)
let style = Date.ISO8601FormatStyle.iso8601.year().month().day().time(includingFractionalSeconds: true)
style.format(date) // "2023-01-18T10:04:11.122", expected "...11.123"

The same off-by-one drops the last millisecond for a large fraction of inputs at present-day magnitudes, e.g. .001 formats as .000 and .011 formats as .010.

Root cause: the milliseconds field is derived from the nanosecond component by truncating toward zero.

let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero))

A Date built from a fractional TimeInterval at a current-date magnitude cannot represent the value exactly, and the nanosecond the calendar extracts lands just below the intended value. For the example above the nanosecond component is 122999906 rather than 123000000, so truncating gives 122. This only shows up at large magnitudes: the existing 665076946.011 test case happens to land on a nanosecond at or above 11000000, so truncation still produced .011 there, which is why it was not caught.

The fix rounds to the nearest millisecond instead of truncating, and clamps the result to 999 so a value that would round up to 1000 does not overflow the three-digit field. That clamp keeps the behavior already asserted by the existing rounding() test, where 15:35:45.9999 formats as 15:35:45.999.

Parsing is unaffected: it already maps .123 back to 123000000 ns exactly, so the formatted output now matches what the parser expects for the displayable resolution.

Verification: added a regression test that fails before this change and passes after. It checks the reported .123 case, asserts that every millisecond 0..<1000 from a present-day base round-trips through the formatted string, and checks the 1000 overflow clamp. The full ISO8601FormatStyle, Date.FormatStyle, and HTTP format style suites pass (the only failure in the wider FoundationEssentialsTests run is fileExtendedAttributes, which also fails on a clean checkout and is unrelated).

Fixes #963.

Happy to retarget this at an active release branch if that fits the merge-forward strategy better.

When formatting with includingFractionalSeconds, the milliseconds field
was computed from the nanosecond component by truncating toward zero:

    let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero))

A Date created from a fractional TimeInterval at a present-day magnitude
cannot represent the value exactly. For Date(timeIntervalSince1970:
1674036251.123) the nanosecond component the calendar extracts is
122999906 rather than 123000000, so truncation yields ".122" instead of
".123". The same off-by-one drops the last millisecond for many values,
for example ".001" becomes ".000" and ".011" becomes ".010".

Round to the nearest millisecond instead. The result is clamped to 999
so a value that rounds up to 1000 does not overflow the three-digit
field, matching the existing behavior already covered by the rounding()
test for ".9999".

Fixes swiftlang#963.
@adityasingh2400 adityasingh2400 requested a review from a team as a code owner May 25, 2026 03:03
@itingliu

Copy link
Copy Markdown
Contributor

Does this work for the scenario that @parkera mentioned in his comment in the issue?

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

Can we add a unit test to see what this change would do to this input?

Cover the scenario from issue swiftlang#963: a time at HH:MM:59 with a sub-millisecond
remainder that rounds up to 1000 ms must clamp to .999 and keep the second at
59, never reading .000 or carrying into the seconds field.
@adityasingh2400

Copy link
Copy Markdown
Contributor Author

Yes, it handles that case. The min(..., 999) clamp is exactly the guard for it: when the sub-millisecond remainder rounds up to 1000 ms, it is capped at .999 rather than wrapping the rounded value to .000 or carrying into the seconds field. So a time like 12:59:59.9999 formats as 12:59:59.999, never 12:59:59.000. I added a test at the :59 boundary in 7208ae6 (1674036299.0 + 0.9996 is 2023-01-18T10:04:59) asserting the output stays at .999. The existing per-millisecond loop and the round-up-to-1000 test cover the rest.

@itingliu itingliu requested a review from parkera May 28, 2026 00:14
// (e.g. 122999906 instead of 123000000). Truncating that toward zero produces an
// off-by-one in the milliseconds field (".122" instead of ".123"). Clamp to 999 so
// a value that rounds up to 1000 does not overflow the three-digit field.
let ms = min(Int((Double(ns) / 1_000_000.0).rounded()), 999)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something, but doesn't this patch basically preserve the "wrong" rounding behavior for 999xyz? In order to fix that last part we'd have to do the recalculation of checking the time zone as I mentioned in the bug, right?

@adityasingh2400

Copy link
Copy Markdown
Contributor Author

You are right, and thanks for pushing on this. I traced the boundary case: a time of 10:04:59.9996 has second=59 and nanosecond about 999_600_000. The rounding gives 1000 ms, and min(..., 999) caps it to .999, so it formats as 10:04:59.999. The correct round-to-nearest result is 10:05:00.000, which is actually closer to the true value. So the clamp does preserve the wrong value at the boundary instead of carrying, exactly as you describe. My added boundary test asserted that wrong output as if it were correct, which is a mistake.

The clamp also cannot fix this on its own, because this shared formatter only receives loose DateComponents and has no anchoring Date or time zone to carry across the second, minute, hour, and day fields, and across a possible DST boundary.

The clean place to round is the Date entry point in Date+ISO8601FormatStyle.swift, where we still hold the actual Date. Rounding the time interval to the nearest millisecond before we extract the components lets the calendar perform every carry correctly, including the time-zone recalculation you mentioned in the bug. The DateComponents-only entry point has no Date to anchor that carry, so I think it needs separate handling or an explicit limitation.

Does rounding the Date upfront in the Date path match what you had in mind for plumbing the rounding rule into the component calculation? I want to confirm the direction before I rework this, especially given the parse round-trip expectations in the issue.

@parkera

parkera commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

I think that makes sense. That is also the point where we know what options we are formatting with. For example, if we include fractional seconds at all in the output.

        let secondsFromGMT: Int?
        let components = componentsFormatStyle._calendar._dateComponents(whichComponents, from: value)

Of course this brings up another question... if we do not include other fields, do we also round? Why are milliseconds special to round up to the next second, but presumably we do not round 2026-06-11 13:00:00 to 2026-06-12 if we do not include time?

@itingliu

Copy link
Copy Markdown
Contributor

I think that makes sense. That is also the point where we know what options we are formatting with. For example, if we include fractional seconds at all in the output.

        let secondsFromGMT: Int?
        let components = componentsFormatStyle._calendar._dateComponents(whichComponents, from: value)

Of course this brings up another question... if we do not include other fields, do we also round? Why are milliseconds special to round up to the next second, but presumably we do not round 2026-06-11 13:00:00 to 2026-06-12 if we do not include time?

We definitely should not round 2026-06-11 13:00:00 to 2026-06-12
I think it's reasonable to treat time fields differently from date fields. With date boundaries, we never round, but always truncate. With time fields, we do often allow specifying the preferred rounding strategy when you format

@parkera

parkera commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

We definitely should not round 2026-06-11 13:00:00 to 2026-06-12 I think it's reasonable to treat time fields differently from date fields. With date boundaries, we never round, but always truncate. With time fields, we do often allow specifying the preferred rounding strategy when you format

I agree -- I'd just like to make sure we're clear somewhere about expected behavior.

I guess we don't support omitting minutes, so really this only applies to the milliseconds field (and how that propagates up to the other units as we discussed). Rounding at the very top before we calculate components does make sense, I think.

The earlier fix rounded the milliseconds field inside the shared
DateComponents formatter and clamped to 999. As parkera pointed out, the
clamp preserves a wrong value at the second boundary: a time like
10:04:59.9996 formatted as 10:04:59.999 instead of carrying to
10:05:00.000.

Round at the Date entry point instead. Before extracting components,
when fractional seconds are included, round the date to the nearest
millisecond. The calendar then carries any overflow correctly across
seconds, minutes, hours, days, and the time zone boundary. The extracted
nanosecond is snapped onto the nearest millisecond to undo the Double
fraction error, so the shared formatter can go back to truncating.

This scopes the rounding to the case it actually fixes. Rounding happens
only when fractional seconds are in the output, at the finest included
unit. Coarser fields keep truncating, so date-only and whole-second
formatting are unchanged. The bare DateComponents path, which has no
anchoring Date and cannot carry, keeps the original truncating behavior
with no clamp.

Tests cover the second-boundary carry, a DST spring-forward carry, a
per-millisecond round-trip sweep, and a parse-then-format round-trip. The
existing rounding test is updated to assert the correct carry.
@adityasingh2400

Copy link
Copy Markdown
Contributor Author

Good question, and I think it points at the right rule. The principle is to round at the finest unit we actually include, applied to the remainder we are about to drop. With fractional seconds at millisecond precision, the sub-millisecond part is that remainder, so rounding to the nearest millisecond is rounding the included precision. By the same logic whole seconds would round the fractional-second remainder.

The date-to-next-day case is the same rule taken to the coarse end: if we only format a date, the time of day is the dropped remainder, and rounding would push 13:00:00 to the next day. That is almost certainly not what anyone wants from ISO8601, which tells me rounding coarser fields is not desirable as a general behavior.

So I scoped it that way: I moved the rounding to the Date entry point and it now rounds only when fractional seconds are included in the output, letting the calendar carry across seconds and the time zone correctly. Coarser fields keep truncating exactly as they do today, so date-only and whole-second formatting are unchanged. I also fixed my earlier boundary test, which had asserted the wrong .999 value. If you would rather have one consistent rounding rule across all precisions instead, I am happy to switch, this just keeps the change to the case it actually fixes.

@itingliu

Copy link
Copy Markdown
Contributor

I'm not sure if the problem is rounding itself. The confusion stems from the fact that you start by initiating a time as .123 seconds, which you think it's .123000, but it's in fact represented as .122999. In this case truncated would indeed be wrong, and rounding gives the expected behavior ".123"

But in the case of 59.9996... seconds, in my opinion truncating is right, which would mean it'd be "59.999" instead of carrying it over to the next minute

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ISO8601FormatStyle produces unexpected results when including fractional seconds

3 participants