# Where Kotlin/Native and the JVM actually differ

> 129 probes run on both runtimes: what agrees exactly, the seventeen rows that do not, why none of those seventeen has ever caused an outage, and why the ones that did are harder to test for.

Page: https://kotlin.website/blog/jvm-and-native-differences · Published: 2026-09-12

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](https://github.com/youndie/sborka/tree/main/docs/research/parity-probe): 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.

```bash
./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 `ﬁ` ligature, dotted `İ`, the `ǅ` 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("é")` returns `null` on JDK 25 and **`é` on JDK 17, which is what
  Kotlin/Native returns**. This is JDK-19 behaviour (JDK-8264160 aligned `\b` with the ASCII `\w` it
  is defined against), not a Kotlin/Native difference at all.
- `1e23` prints `9.999999999999999E22` on JDK 17 and `1.0E23` on JDK 25 — the shortest-representation
  fix from the same release. Native prints `1.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-cio` has no TLS on Kotlin/Native** — `IllegalStateException: TLS sessions are not
  supported`. Outbound HTTPS silently never left the service. Fixed with `ktor-client-curl` on
  native and CIO on the JVM through `expect`/`actual`.
- **`ktor-server-compression` is published for the JVM only**, so compression moves into the image
  build as pre-made `.gz` files.
- **`Dispatchers.IO` on Kotlin/Native is an extension property** in package `kotlinx.coroutines` and
  needs `import kotlinx.coroutines.IO` of 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 for `linuxX64` and
  `macosArm64` and `withContext(Dispatchers.IO)` runs.

- **One library can be two libraries.** sqlx4k's SQLite is the Rust driver on Kotlin/Native and
  Xerial's `sqlite-jdbc` on 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:

```text
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.
