You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
convert's date-time conversions resolve zone-dependent steps against TimeZone.currentSystemDefault(). The same
operation on the same DataFrame produces different values depending on the machine it runs on, and for several
conversions there is no way to pin the zone.
58 entries of the converter table (impl/api/convert.kt:374–:800) read it. On the public side, 24 overloads in api/convert.kt accept zone: TimeZone = defaultTimeZone — but they cover only the receivers Long, Int, kotlin.time.Instant and kotlinx.datetime.Instant, and only the direction towards LocalDate/LocalTime/LocalDateTime. Everything else takes the ambient zone with no opt-out.
All values below are measured on the current master (JDK 21), not derived by hand.
Why it matters
Results are not reproducible across environments. A pipeline that converts an epoch column to LocalDateTime and then groups, filters, asserts, or persists produces different output on a developer machine
and on CI.
The escape hatch is incomplete and its availability depends on how the column was selected, not on the data
(case 3, case 5).
The library already promises the opposite elsewhere.docs/StardustDocs/topics/dataSources/ApacheArrow.md
and Parquet.md state that timestamp writing resolves against UTC and never against the default zone, while docs/StardustDocs/topics/convert.md does not mention time zones at all — the string TimeZone does not appear
in it.
A downstream module already had to work around it.dataframe-arrow reimplemented two of core's
converters privately (convertToInstantInUtc / convertToLocalDateTimeInUtc in dataframe-arrow/.../io/ArrowWriterImpl.kt) because the same DataFrame was writing different Arrow/Parquet
bytes on different machines (Support Parquet UTC timestamp vectors (TimeStampXXXTZVector) in Arrow reader #926).
How it works today
Three properties together mean the zone cannot reach the converter table at all:
internal typealias TypeConverter = (Any) -> Any?
impl/api/convert.kt:182 — a converter's only input is the cell value
impl/api/convert.kt:171 — the converter cache key has no zone, and ParserOptions carries none
defaultTimeZone is a get() property
impl/api/convert.kt:837 — re-read on every call, from inside the per-cell lambda
Dispatch is reflective on jvmErasure in createConverter (impl/api/convert.kt:199), keyed on the column's
runtime KType. Any zone-aware fix has to change TypeConverter's shape or thread a zone through convertToTypeImpl / getConverter / createConverter.
Cases
1. The same epoch value becomes a different wall-clock reading per machine
val millis =1_704_110_400_123L// 2024-01-01T12:00:00.123Z
columnOf(millis).convertToLocalDateTime()[0]
Default zone
Result
UTC
2024-01-01T12:00:00.123
Europe/Berlin
2024-01-01T13:00:00.123
Pacific/Kiritimati
2024-01-02T02:00:00.123
America/Los_Angeles
2024-01-01T04:00:00.123
Note what is not broken: Long → LocalDateTime → Long does round-trip to 1704110400123 in all four zones,
because both halves read the same ambient zone. The problem is the intermediate value — it is what gets grouped,
compared, asserted and written out — and the round trip only holds while both halves run in the same environment.
For contrast, Long → Instant is zone-free and gives 2024-01-01T12:00:00.123Z in all four zones
(impl/api/convert.kt:517, Instant.fromEpochMilliseconds), while Long → LocalDateTime at :511 is not. Two
adjacent rows of the same table, two different contracts.
2. LocalDate has two different meanings of "start of day", nine lines apart
val date =LocalDate(2024, 1, 1)
columnOf(date).convertTo<LocalDateTime>()[0] // zone-free: impl/api/convert.kt:758, atTime(0, 0)
columnOf(date).convertTo<Instant>()[0] // ambient: impl/api/convert.kt:760, atStartOfDayIn(defaultTimeZone)
columnOf(date).convertToLong()[0] // ambient: impl/api/convert.kt:767
Default zone
→ LocalDateTime
→ Instant
→ Long
UTC
2024-01-01T00:00
2024-01-01T00:00:00Z
1704067200000
Europe/Berlin
2024-01-01T00:00
2023-12-31T23:00:00Z
1704063600000
Pacific/Kiritimati
2024-01-01T00:00
2023-12-31T10:00:00Z
1704016800000
America/Los_Angeles
2024-01-01T00:00
2024-01-01T08:00:00Z
1704096000000
A LocalDate of January 1st becomes December 31st on two of the four machines. There is no zone parameter on
either of the two ambient conversions.
3. Whether you can pin the zone depends on how you selected the column
val df = dataFrameOf("ts")(1_704_110_400_123L)
df.convert("ts").toLocalDateTime() // Convert<T, Any?> -> api/convert.kt:2315, no zone
df.convert { ts }.toLocalDateTime(zone) // Convert<T, Long?> -> api/convert.kt:2165, zone honoured
convert(vararg columns: String) returns Convert<T, Any?> (api/convert.kt:295). Convert<T, out C> is
covariant, but Any? is not a subtype of Long?, so the zone-aware overload is inapplicable and resolution falls
to the zone-free Convert<T, *>.toLocalDateTime(). Measured under Europe/Berlin, the first line yields 2024-01-01T13:00:00.123.
So string-selected and typed-accessor users get different determinism guarantees for the identical column.
4. An erased column has no way out at all
For col: AnyCol (= DataColumn<*>), the only applicable overload is DataColumn<T?>.convertToLocalDateTime() (api/convert.kt:702), which takes no zone. There is no DataColumn<*>.convertToLocalDateTime(zone) anywhere. Generic code that receives a column cannot be made
deterministic without changing the JVM default zone around the call.
5. Conversions with no zone parameter anywhere in the public API
val inst =Instant.parse("2024-01-01T23:30:00Z")
columnOf(inst).convertToLocalDate()[0]
Default zone
→ LocalDate
→ LocalTime
UTC
2024-01-01
23:30
Europe/Berlin
2024-01-02
00:30
Pacific/Kiritimati
2024-01-02
13:30
America/Los_Angeles
2024-01-01
15:30
convertToLocalDate / convertToLocalTime have zone overloads only for Long and Int receivers — there is no DataColumn<Instant>.convertToLocalDate(zone), even though Instant → LocalDateTime does get one
(api/convert.kt:2008). The whole reverse direction is affected too: LocalDateTime/LocalDate → Instant/Long/java.time.Instant (impl/api/convert.kt:706, :713, :721, :760, :767, :773) has no
zone-aware entry point at all. Same for Byte/Short sources and for every java.time.* source.
For these, the only workaround is mutating java.util.TimeZone.setDefault around the call.
6. DST gaps break the value silently, and only on some machines
val gap =LocalDateTime(2024, 3, 31, 2, 30) // does not exist in Europe/Berlin
columnOf(gap).convertTo<Instant>()[0]
Default zone
→ Instant
back to LocalDateTime
round-trip equal
UTC
2024-03-31T02:30:00Z
2024-03-31T02:30
yes
Europe/Berlin
2024-03-31T01:30:00Z
2024-03-31T03:30
no
The local time 02:30 does not exist in Berlin on that date, so it is shifted forward by the gap and the round
trip returns 03:30. The same code on a UTC machine is lossless.
The overlap case (2024-10-27T02:30, which happens twice in Berlin) round-trips equal but silently picks the
earlier of the two valid instants (2024-10-27T00:30:00Z) — a one-hour ambiguity with no signal to the caller.
7. defaultTimeZone is read per value, not per column
Because defaultTimeZone is a get() property read inside the per-cell lambda, a cached converter does not
freeze the zone. A default-zone change during a long-running conversion would produce a column with mixed
interpretations rather than a consistently stale one. This is a property of the mechanism; I did not manage to
produce an observable failure from it, so it is listed as a robustness note rather than a reproducible bug.
Current test coverage
Worth stating plainly, because it cuts both ways: there is no value-level test pinning this behaviour in either
direction. The current behaviour is unprotected, and a change of the default would be invisible to CI.
core/src/test/kotlin/.../api/convert.kt — the test Instant to LocalDateTime calls df.convert { time }.toLocalDateTime() on a Clock.System.now() column and asserts nothing at all.
core/src/test/kotlin/.../io/ParserTests.kt, convert to date and time — the only test of the zone-taking
overloads. It passes TimeZone.UTC everywhere and never a second zone, so it would still pass if the zone
argument were ignored.
samples/src/test/kotlin/.../api/Convert.kt — the largest cluster of zone-dependent conversions; every
assertion checks type and nullability only, never a value.
TimeZone.setDefault appears nowhere under core/src/test. The only helper that runs a block under a fixed
default zone is a private withDefaultTimeZone in dataframe-arrow's ArrowTimestampTzTest; there is no
shared one in common-test-utils.
Proposed tests
House style is JUnit 4 + kotest matchers, backticked sentence names, one test per claim (TEST_GUIDELINES.md).
Natural home: core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/convert.kt (ConvertTests).
A shared helper is needed first — worth promoting into common-test-utils rather than copying a third time:
/** Runs [block] with the JVM default time zone set to [zoneId], restoring the previous one afterwards. */fun <R> withDefaultTimeZone(zoneId:String, block: () ->R): R {
val previous = java.util.TimeZone.getDefault()
java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(zoneId))
try {
return block()
} finally {
java.util.TimeZone.setDefault(previous)
}
}
Tests that would fail today only if the default became UTC (they specify the desired contract, they do not
reproduce a bug):
privateval shiftedZones =listOf("UTC", "Europe/Berlin", "Pacific/Kiritimati", "America/Los_Angeles")
@Test
fun`converting a Long to a LocalDateTime does not depend on the default time zone`() {
val millis =1_704_110_400_123L// 2024-01-01T12:00:00.123Z
shiftedZones.forEach { zone ->
zone.asClue {
withDefaultTimeZone(zone) {
columnOf(millis).convertToLocalDateTime()[0] shouldBe
LocalDateTime(2024, 1, 1, 12, 0, 0, 123_000_000)
}
}
}
}
@Test
fun`converting a LocalDate to an Instant does not depend on the default time zone`() {
shiftedZones.forEach { zone ->
zone.asClue {
withDefaultTimeZone(zone) {
columnOf(LocalDate(2024, 1, 1)).convertTo<Instant>()[0] shouldBe
Instant.parse("2024-01-01T00:00:00Z")
}
}
}
}
A test that can be added right now, independent of which direction is chosen — it closes the hole left by ParserTests and passes either way:
@Test
fun`the zone argument is honoured`() {
val col = columnOf(1_704_110_400_123L) // 2024-01-01T12:00:00.123Z
col.convertToLocalDateTime(TimeZone.UTC)[0] shouldBe LocalDateTime(2024, 1, 1, 12, 0, 0, 123_000_000)
col.convertToLocalDateTime(TimeZone.of("Europe/Berlin"))[0] shouldBe
LocalDateTime(2024, 1, 1, 13, 0, 0, 123_000_000)
}
Cases 3, 5 and 6 each deserve one test in the same shape.
Possible directions
Not mutually exclusive, and listed without a recommendation.
A. Resolve against UTC by default. Deterministic out of the box, and consistent with what ApacheArrow.md and Parquet.md already promise. It is a behavioural breaking change for anyone relying on the ambient zone — though
no existing value-level test asserts the current behaviour, so the change is currently unobservable in CI, which is
an argument both for the change being safe and for it needing new tests either way.
B. Keep the system default, close the gaps. Add zone: TimeZone wherever it is missing (case 5: Instant → LocalDate/LocalTime, the whole local → absolute direction, Byte/Short, java.time.* sources), make it
reachable from an erased receiver (case 4), and document the default in convert.md and in the KDoc of the
zone-free overloads. Breaks nothing, but leaves the default non-deterministic.
C. Require an explicit zone. Deprecate the zone-less overloads for zone-dependent pairs. Most correct, most
disruptive.
B is a subset of the work needed under any of the three, since the gaps in case 5 leave users with no workaround
except mutating the JVM default.
Orthogonal to all three: convert.md should say what the current rule is. It says nothing today.
Notes
The compiler plugin lives in the Kotlin repository
(github.com/JetBrains/kotlin/tree/master/plugins/kotlin-dataframe), not in this one — this repo's plugins/kotlin-dataframe is a disabled, out-of-date legacy copy (see plugins/AGENTS.md). Adding or removing a
parameter on any @Interpretable-annotated convert function therefore needs a matching interpreter change there; changing only the converter internals does not.
Both api/convert.kt and impl/api/convert.kt are shaded into dataframe-compiler-plugin-core, which the
compiler plugin (and IntelliJ) bundle to run compile-time interpreters — so a change here ships inside the
plugin, and PluginApiUsages.kt in that module exercises convert directly.
Minor, adjacent: the KDoc of the toLocalDateTime(zone) overloads says "interpret the … timestamp as a time"
— a copy-paste from toLocalTime (api/convert.kt:2158, :2180, :2202, :2224).
Summary
convert's date-time conversions resolve zone-dependent steps againstTimeZone.currentSystemDefault(). The sameoperation on the same
DataFrameproduces different values depending on the machine it runs on, and for severalconversions there is no way to pin the zone.
core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/api/convert.kt:837:58 entries of the converter table (
impl/api/convert.kt:374–:800) read it. On the public side, 24 overloads inapi/convert.ktacceptzone: TimeZone = defaultTimeZone— but they cover only the receiversLong,Int,kotlin.time.Instantandkotlinx.datetime.Instant, and only the direction towardsLocalDate/LocalTime/LocalDateTime. Everything else takes the ambient zone with no opt-out.All values below are measured on the current
master(JDK 21), not derived by hand.Why it matters
LocalDateTimeand then groups, filters, asserts, or persists produces different output on a developer machineand on CI.
(case 3, case 5).
docs/StardustDocs/topics/dataSources/ApacheArrow.mdand
Parquet.mdstate that timestamp writing resolves against UTC and never against the default zone, whiledocs/StardustDocs/topics/convert.mddoes not mention time zones at all — the stringTimeZonedoes not appearin it.
dataframe-arrowreimplemented two ofcore'sconverters privately (
convertToInstantInUtc/convertToLocalDateTimeInUtcindataframe-arrow/.../io/ArrowWriterImpl.kt) because the sameDataFramewas writing different Arrow/Parquetbytes on different machines (Support Parquet UTC timestamp vectors (
TimeStampXXXTZVector) in Arrow reader #926).How it works today
Three properties together mean the zone cannot reach the converter table at all:
internal typealias TypeConverter = (Any) -> Any?impl/api/convert.kt:182— a converter's only input is the cell valuemutableMapOf<Triple<KType, KType, ParserOptions?>, TypeConverter?>impl/api/convert.kt:171— the converter cache key has no zone, andParserOptionscarries nonedefaultTimeZoneis aget()propertyimpl/api/convert.kt:837— re-read on every call, from inside the per-cell lambdaDispatch is reflective on
jvmErasureincreateConverter(impl/api/convert.kt:199), keyed on the column'sruntime
KType. Any zone-aware fix has to changeTypeConverter's shape or thread a zone throughconvertToTypeImpl/getConverter/createConverter.Cases
1. The same epoch value becomes a different wall-clock reading per machine
UTC2024-01-01T12:00:00.123Europe/Berlin2024-01-01T13:00:00.123Pacific/Kiritimati2024-01-02T02:00:00.123America/Los_Angeles2024-01-01T04:00:00.123Note what is not broken:
Long → LocalDateTime → Longdoes round-trip to1704110400123in all four zones,because both halves read the same ambient zone. The problem is the intermediate value — it is what gets grouped,
compared, asserted and written out — and the round trip only holds while both halves run in the same environment.
For contrast,
Long → Instantis zone-free and gives2024-01-01T12:00:00.123Zin all four zones(
impl/api/convert.kt:517,Instant.fromEpochMilliseconds), whileLong → LocalDateTimeat:511is not. Twoadjacent rows of the same table, two different contracts.
2.
LocalDatehas two different meanings of "start of day", nine lines apart→ LocalDateTime→ Instant→ LongUTC2024-01-01T00:002024-01-01T00:00:00Z1704067200000Europe/Berlin2024-01-01T00:002023-12-31T23:00:00Z1704063600000Pacific/Kiritimati2024-01-01T00:002023-12-31T10:00:00Z1704016800000America/Los_Angeles2024-01-01T00:002024-01-01T08:00:00Z1704096000000A
LocalDateof January 1st becomes December 31st on two of the four machines. There is no zone parameter oneither of the two ambient conversions.
3. Whether you can pin the zone depends on how you selected the column
convert(vararg columns: String)returnsConvert<T, Any?>(api/convert.kt:295).Convert<T, out C>iscovariant, but
Any?is not a subtype ofLong?, so the zone-aware overload is inapplicable and resolution fallsto the zone-free
Convert<T, *>.toLocalDateTime(). Measured underEurope/Berlin, the first line yields2024-01-01T13:00:00.123.So string-selected and typed-accessor users get different determinism guarantees for the identical column.
4. An erased column has no way out at all
For
col: AnyCol(=DataColumn<*>), the only applicable overload isDataColumn<T?>.convertToLocalDateTime()(api/convert.kt:702), which takes no zone. There is noDataColumn<*>.convertToLocalDateTime(zone)anywhere. Generic code that receives a column cannot be madedeterministic without changing the JVM default zone around the call.
5. Conversions with no
zoneparameter anywhere in the public API→ LocalDate→ LocalTimeUTC2024-01-0123:30Europe/Berlin2024-01-0200:30Pacific/Kiritimati2024-01-0213:30America/Los_Angeles2024-01-0115:30convertToLocalDate/convertToLocalTimehave zone overloads only forLongandIntreceivers — there is noDataColumn<Instant>.convertToLocalDate(zone), even thoughInstant → LocalDateTimedoes get one(
api/convert.kt:2008). The whole reverse direction is affected too:LocalDateTime/LocalDate→Instant/Long/java.time.Instant(impl/api/convert.kt:706,:713,:721,:760,:767,:773) has nozone-aware entry point at all. Same for
Byte/Shortsources and for everyjava.time.*source.For these, the only workaround is mutating
java.util.TimeZone.setDefaultaround the call.6. DST gaps break the value silently, and only on some machines
→ InstantLocalDateTimeUTC2024-03-31T02:30:00Z2024-03-31T02:30Europe/Berlin2024-03-31T01:30:00Z2024-03-31T03:30The local time
02:30does not exist in Berlin on that date, so it is shifted forward by the gap and the roundtrip returns
03:30. The same code on a UTC machine is lossless.The overlap case (
2024-10-27T02:30, which happens twice in Berlin) round-trips equal but silently picks theearlier of the two valid instants (
2024-10-27T00:30:00Z) — a one-hour ambiguity with no signal to the caller.7.
defaultTimeZoneis read per value, not per columnBecause
defaultTimeZoneis aget()property read inside the per-cell lambda, a cached converter does notfreeze the zone. A default-zone change during a long-running conversion would produce a column with mixed
interpretations rather than a consistently stale one. This is a property of the mechanism; I did not manage to
produce an observable failure from it, so it is listed as a robustness note rather than a reproducible bug.
Current test coverage
Worth stating plainly, because it cuts both ways: there is no value-level test pinning this behaviour in either
direction. The current behaviour is unprotected, and a change of the default would be invisible to CI.
core/src/test/kotlin/.../api/convert.kt— the testInstant to LocalDateTimecallsdf.convert { time }.toLocalDateTime()on aClock.System.now()column and asserts nothing at all.core/src/test/kotlin/.../io/ParserTests.kt,convert to date and time— the only test of the zone-takingoverloads. It passes
TimeZone.UTCeverywhere and never a second zone, so it would still pass if thezoneargument were ignored.
samples/src/test/kotlin/.../api/Convert.kt— the largest cluster of zone-dependent conversions; everyassertion checks type and nullability only, never a value.
TimeZone.setDefaultappears nowhere undercore/src/test. The only helper that runs a block under a fixeddefault zone is a private
withDefaultTimeZoneindataframe-arrow'sArrowTimestampTzTest; there is noshared one in
common-test-utils.Proposed tests
House style is JUnit 4 + kotest matchers, backticked sentence names, one test per claim (
TEST_GUIDELINES.md).Natural home:
core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/convert.kt(ConvertTests).A shared helper is needed first — worth promoting into
common-test-utilsrather than copying a third time:Tests that would fail today only if the default became UTC (they specify the desired contract, they do not
reproduce a bug):
A test that can be added right now, independent of which direction is chosen — it closes the hole left by
ParserTestsand passes either way:Cases 3, 5 and 6 each deserve one test in the same shape.
Possible directions
Not mutually exclusive, and listed without a recommendation.
A. Resolve against UTC by default. Deterministic out of the box, and consistent with what
ApacheArrow.mdandParquet.mdalready promise. It is a behavioural breaking change for anyone relying on the ambient zone — thoughno existing value-level test asserts the current behaviour, so the change is currently unobservable in CI, which is
an argument both for the change being safe and for it needing new tests either way.
B. Keep the system default, close the gaps. Add
zone: TimeZonewherever it is missing (case 5:Instant → LocalDate/LocalTime, the whole local → absolute direction,Byte/Short,java.time.*sources), make itreachable from an erased receiver (case 4), and document the default in
convert.mdand in the KDoc of thezone-free overloads. Breaks nothing, but leaves the default non-deterministic.
C. Require an explicit zone. Deprecate the zone-less overloads for zone-dependent pairs. Most correct, most
disruptive.
B is a subset of the work needed under any of the three, since the gaps in case 5 leave users with no workaround
except mutating the JVM default.
Orthogonal to all three:
convert.mdshould say what the current rule is. It says nothing today.Notes
(
github.com/JetBrains/kotlin/tree/master/plugins/kotlin-dataframe), not in this one — this repo'splugins/kotlin-dataframeis a disabled, out-of-date legacy copy (seeplugins/AGENTS.md). Adding or removing aparameter on any
@Interpretable-annotatedconvertfunction therefore needs a matching interpreter changethere; changing only the converter internals does not.
api/convert.ktandimpl/api/convert.ktare shaded intodataframe-compiler-plugin-core, which thecompiler plugin (and IntelliJ) bundle to run compile-time interpreters — so a change here ships inside the
plugin, and
PluginApiUsages.ktin that module exercisesconvertdirectly.TimeStampXXXTZVector) in Arrow reader #926 (Arrow/Parquet timestamps with a time zone — the downstream half of this),Handle deprecation of kotlinx datetime
Instant(and Clock) #1350 (kotlinx.datetime.Instant→kotlin.time.Instantmigration, which duplicated many of these tableentries).
toLocalDateTime(zone)overloads says "interpret the … timestamp as a time"— a copy-paste from
toLocalTime(api/convert.kt:2158,:2180,:2202,:2224).