If you develop on the JVM and ship a Kotlin/Native binary, you are relying on the two behaving the same. That reliance is usually backed by a checklist someone wrote from memory — hash order, string formatting, regex, dates — and not by a measurement. This is the measurement. The probe is in sborka: one commonTest source set, 129 observations, each printing one row of a transcript, run on jvm, macosArm64 and linuxX64.
The short answer is that the standard library agrees far more than the checklists suggest, that the JDK version matters as often as the target does, and that the differences which have actually cost production time are not in the standard library at all.
What was measured
Kotlin 2.4.10, kotlinx-serialization 1.11.0, kotlinx-datetime 0.8.0, JDK 25.0.2 and 17.0.20.1. Nothing is asserted in the probe: an assertion would encode which target is right, which is the question rather than the setup. compare.py prints both values and labels neither a reference.
./run.sh jvm
./run.sh macosArm64 # linuxX64 on a Linux host
./compare.py results/<a> results/<b>
What agrees, exactly
Forty-nine rows, zero differences, on every runtime and JDK tried:
| Class | Rows | Includes |
|---|---|---|
| Number formatting | 17 | 0.1 + 0.2, 1e23, 1e-5, -0.0, Double.MIN_VALUE, 1f/3f, toString(radix) |
| Strings and Unicode | 19 | "straße".uppercase(), the fi ligature, dotted İ, the Dž digraph, surrogate pairs, sorted(), trim() on NBSP |
| Hashing | 10 | String.hashCode including non-BMP text, data classes, lists, maps, Double, Long, Char |
| Seeded randomness | 3 | Random(42) sequence, nextDouble, shuffled |
Twenty of the 22 regex probes agree too, including the constructs usually named as risks: \w on accented text, \d on Arabic-Indic digits, \p{L}, \p{IsCyrillic}, [[:alpha:]], fixed and variable look-behind, named groups, back-references, possessive quantifiers and atomic groups.
What differs
Seventeen rows of 129, on JDK 25:
| Probe | JVM | Kotlin/Native |
|---|---|---|
HashMap iteration order |
epsilon,zeta,eta,alpha,delta,theta,beta,gamma |
alpha,beta,gamma,delta,epsilon,zeta,eta,theta |
| the same map through kotlinx-serialization | {"a":1,"b":3,"z":0,"m":2} |
{"z":0,"a":1,"m":2,"b":3} |
1 / 0 |
ArithmeticException: / by zero |
ArithmeticException: null |
"abc"[7] |
StringIndexOutOfBoundsException |
ArrayIndexOutOfBoundsException |
"2147483648".toInt() |
NumberFormatException: For input string: … |
NumberFormatException: null |
TimeZone.availableZoneIds.size |
604 | 597 on macOS, 496 on Linux |
Six of them are exception messages, and one of those changes the exception's class — both are IndexOutOfBoundsException subclasses, so a catch on the base type is unaffected, but a when (e) chain in common code takes a different branch.
Hash order is the only row with a silent consequence. Everything else surfaces as text a human reads. This one surfaces as different bytes in a payload, which matters the moment a golden file, a payload hash or an idempotency key touches it. Sorting at the boundary fixes it, and sorting is safe precisely because all nineteen string comparison probes agree.
The timezone row is not a language difference. kotlinx-datetime reads the host's zone database on Kotlin/Native, and the JDK ships its own. In a container that makes the zone table a property of the base image. Every zone offset probed — Moscow in 1990 and 2011, the Kiritimati date-line skip, a Berlin DST gap — agreed on every target.
The JDK is a variable, as often as the target is
Run the same probe on JDK 17 and five rows move, two of which change what you would otherwise conclude:
Regex("\bé").find("é")returnsnullon JDK 25 andéon JDK 17, which is what Kotlin/Native returns. This is JDK-19 behaviour (JDK-8264160 aligned\bwith the ASCII\wit is defined against), not a Kotlin/Native difference at all.1e23prints9.999999999999999E22on JDK 17 and1.0E23on JDK 25 — the shortest-representation fix from the same release. Native prints1.0E23, so on JDK 17 there are two double-formatting differences that do not exist on JDK 25.
So the count is 17 on JDK 25 and 18 on JDK 17, and they are not the same eighteen. A page that names Kotlin and its libraries but not the JDK is wrong about those rows in a way no reader can detect.
Where the differences that cost something actually were
None of the seventeen has caused an outage. The ones that did are platform APIs:
-
ktor-client-ciohas no TLS on Kotlin/Native —IllegalStateException: TLS sessions are not supported. Outbound HTTPS silently never left the service. Fixed withktor-client-curlon native and CIO on the JVM throughexpect/actual. -
ktor-server-compressionis published for the JVM only, so compression moves into the image build as pre-made.gzfiles. -
Dispatchers.IOon Kotlin/Native is an extension property in packagekotlinx.coroutinesand needsimport kotlinx.coroutines.IOof its own. Without that import the compiler resolves the internal member of the same name and reports "it is internal" — which is accurate, and reads exactly like a platform limitation. It is not one: with the import it compiles forlinuxX64andmacosArm64andwithContext(Dispatchers.IO)runs. -
One library can be two libraries. sqlx4k's SQLite is the Rust driver on Kotlin/Native and Xerial's
sqlite-jdbcon the JVM, behind one API. They disagree about in-memory databases: the JVM half refuses a connection pool larger than one — rightly, since a second connection to:memory:is a second, empty database — and pinning the pool to one then deadlocks. A test suite that ran on the native target alone never met either half.
And one that was true and no longer is: InetSocketAddress(host, port) used not to resolve a hostname on Kotlin/Native, which left agents reporting through a Kubernetes service name silent from their first day. Re-probed at Ktor 3.5.2 through ktor-network's own InetSocketAddress, a TCP connect to a hostname succeeds on macosArm64 and linuxX64 both. Which is the argument for measuring rather than listing: a hand-kept list of platform differences only ever grows, because nothing in it reports the day an entry stops being true.
Testing for the platform layer is harder than testing for the stdlib
The stdlib rows above are a transcript: run the same source twice, diff. The platform layer resists that, and it is worth knowing why before building a check for it.
An assertion has to go through the API the program uses, not the one underneath it. The hostname failure was in ktor's InetSocketAddress, so a probe calling getaddrinfo would have been green while a fleet of agents was silent.
And some of it cannot be asserted hermetically at all. "Does this engine support TLS" looks checkable: make an HTTPS request to a socket that hangs up, and see whether the failure is a refusal. On Kotlin/Native with the curl engine it is not, because curl reports its ordinary handshake failure as IllegalStateException — the same type ktor uses to refuse TLS outright:
IllegalStateException: Connection failed … Reason: SSL connect error (CURLE_SSL_CONNECT_ERROR)
No exception type separates reached the TLS layer and failed from has no TLS at all. Only the message text does, and a check resting on a sentence passes the day the sentence is reworded. What settles it is a service with a certificate to talk to — which is a stand, not a unit test.
Limits
macosArm64 and linuxX64 differ in one row of the 129 — the zone table — so the numbers above describe the runtimes rather than a host, with that one exception. The probe covers the standard library, the regex engine, kotlinx-serialization and kotlinx-datetime; it does not cover coroutines scheduling, file-system behaviour, or anything that needs a socket. The re-probed InetSocketAddress result is a TCP connect, and UDP takes a different path to the same address type. Every number is a property of the versions named at the top and of nothing else, which is why the transcripts are committed beside the probe rather than quoted only here.