# Xo 0.38 release notes


**Downloads:** [Xo 0.38.1 on GitHub](https://github.com/xo-lang/xo/releases/tag/v0.38.1),
with archives for macOS (Apple silicon and Intel, signed and
notarized), Linux (arm64 and x86-64), and Windows (x86-64, Authenticode
signed), and a `SHA256SUMS` file.
[Getting started](/docs/getting-started/#install) shows how to install
one. 0.37.0 and 0.38.0 were internal bootstrap compilers with no public
archives, so these notes cover everything since 0.36.0.

Xo 0.38.1 brings WebAssembly programs to WASI 0.3: a `wasi:http` component that streams its bodies serves 1.017x the requests a second of Rust's wasip3 build, and wasip2 programs gain files, TCP, UDP, TLS, HTTP/2 and DNS. JSON gets tagged enums, variant wire names and a type's own wire form. The standard library adds JWT, a SQL row cursor and COPY, zip archives read and written entry by entry, `ByteBuffer`, a directory walk without a stat per entry, Unicode normalization and IDNA.

Two changes break source: `netip.Addr` no longer has an `ip` field, and a SQL driver must add `copy_from`. A fault inside `Mutex.with` now poisons the mutex.

## Breaking changes

- **`netip.Addr` is two words** (decision 0485): a program that read `a.ip` reads `a.bytes()` (4 bytes for IPv4, 16 for IPv6); `==`, `<` and the display are unchanged, and an address no longer allocates.
- **`DriverConn` gains a required `copy_from` method**: a driver without COPY returns `Driver("...")`.
- **A fault inside `Mutex.with` poisons the mutex** (decision 0482): later `with` and `with_read` fault with `XO-F010` until `m.reset(v)`; an `Err` or a cancellation still leaves the value unchanged.

## WebAssembly

- **WASI 0.3 `wasi:http` components** (`--targets=wasip3/wasm`, decision 0447 stage 5): hello serves 114,828 req/s (0.943x Rust's wasip3 hello); bodies stream with their trailers, a streamed 100,000-byte request body serves 57,056 req/s (1.017x Rust's wasip3, 2.60x the same program at 0.2), and `http.Client` works through `wasi:http/client@0.3.0` (17,587 req/s, 0.989x Rust) on linux/arm64.
- **wasip2 commands** (`--targets=wasip2/wasm`, a `wasi:cli/command` 0.2 component): files, TCP, UDP, DNS, TLS, HTTP/1.1 and HTTP/2 servers and clients with pooled connections, full duplex bodies and two phase shutdown; 428 of the 445 wasm goldens run under `wasmtime run`.

## JSON (decision 0478)

- **Tagged enums**: `derive Json(tag: "type")` writes `{"type":"say","room":"r"}`, `content: "data"` puts the payload under its own key, and decoding finds the tag anywhere in the object.
- **Variant wire names**: `derive Json(case: snake)` on an enum, and `Chat as "chat.message"(...)` for one variant.
- **A type's own JSON form**: `derive Json(via: Str, to: money_text, from: parse_money)` on a struct, enum or newtype, with `from`'s error in the decode error's message.
- Measured: `via: Str` runs at 0.923 of the Str field workaround's wall time with 0.68 of its peak memory; against Go's MarshalJSON and UnmarshalJSON Xo takes 0.21 of Go's wall time, and 0.11 to 0.24 for tagged decodes.

## Standard library

- **New modules:** std/jwt (JWS with HS*, RS*, PS*, ES* and EdDSA, golang-jwt v5 claims, byte for byte golang-jwt tokens; decision 0486), std/norm (NFC, NFD, NFKC, NFKD), std/idna (x/net/idna's profiles), std/cookiejar, std/bufio, std/charset, std/heap, std/otp, std/cron, std/crc64, std/adler32, std/bcrypt, std/cbor, std/png, std/jpeg.
- **SQL:** `Rows`, a row cursor (1,000,000 rows in 3.9 MB max RSS against `query`'s 320 MB on the Mac), and `copy_from` with COPY FROM STDIN (1,000,000 rows in 3.4 s and 3.8 MB max RSS).
- **Zip by entry:** `zip_reader`, `zip_open` (from a File, each entry read when asked) and `zip_writer`; `File.read_at`.
- **`ByteBuffer`** (decision 0480), Go's bytes.Buffer for parsers: 1.007 of Go's wall time on 1M small records and 0.208 on a 64 MiB record, at 0.18 to 0.25 of Go's peak memory (linux/arm64).
- **A directory walk without a stat per entry** (`DirEntry`, `walk_entries`; decision 0483): 1,620 entries in 1597 ms against `walk`'s 2304 (Go 1514) on the Mac.
- `NetConn`, `Duration.round` and `truncate`, `Bytes.to_str_lossy`, a streaming `csv.reader` and `xml.reader`.

## Tools and runtime

- **`XO_FREE_CHECK=1`** (decision 0484): freed heap blocks are poisoned and held from reuse, so a use after a free faults where it happens on native; off by default and free when off.
- `xo vet` warns (XO0916) when `time.format` or `time.parse` gets a constant layout it parses on every call.

## Speed and memory

- **DNS on the netpoller:** a one-query lookup no longer blocks its thread; bench/perf/dns mx is 0.928 of Go's twin, was 4.66.
- **HTTP client pooling with a body source:** 2000 keep-alive POSTs take 0.199 of the wall time (linux/arm64).
- SHA-1 and SHA-512 on the CPU's instructions, HMAC and SHA finishing faster, time parse 0.815 of before, cron's next by arithmetic.

## Fixes

Each names the first release that had the bug (scripts/shipped-in.sh).

- **Memory read after it was freed, closed as a class** (decision 0460): a value whose raw address is taken stays alive to the address's last use, the cause of the five std use-after-frees 0.36.0 fixed one by one; `xo vet --cost` keeps XO0915 for an address carried across statements.
- **Windows: a preempted task could resume with a wrong integer value** under load (0.36.0's known issue, 8 of 256 stress runs): preemption now saves and restores the thread's own registers, as Go's preemptM does. Since 0.25.0.
- **The LLVM backend failed with clang 16 to 18** (Ubuntu 24.04's clang 18). Since 0.25.0.
- **HTTP:** the client closed a connection after every request whose body came from a source instead of pooling it; the reverse proxy answered 502 for a GET whose pooled connection the target had closed, instead of retrying it.
- **Native runtime:** three ThreadSanitizer races on the heap record list's head, now read and written only through an acquire load and a release store.
- **wasm:** reading standard input faulted on wasip1/wasm and in wasip2 commands, and input past about 4 KiB was corrupted; a large piped stdin under node stopped with an I/O error.
- **0.38.1, not 0.38.0:** v0.38.0 was tagged and then found to crash, instead of poisoning the mutex, on a fault in a `Mutex.with` closure with no calls (such as `fn(var c) { c.n += 1 }` overflowing) in programs built for x86-64 Linux and macOS (arm64 was fixed before that tag); it was never published, and 0.38.1 is it with that fixed.
- The full list is in CHANGELOG.md.

## Xo against Go

- **Run time:** Xo is faster than Go on 19 of 20 workloads on the Mac and slower on 1 (llvm nbody, 1.071 times Go's wall time; 1.056 in 0.36.0).
- **Peak memory:** lower than Go's on all 20 (0.353 to 0.864 of Go's); coll push's peak fell from 1.032 of Go's in 0.36.0 to 0.459.
- **Binary size and builds:** 1.29 to 1.44 MB against Go's 2.39 to 2.43 MB; a cold build 1.05 to 1.24 s against Go's 1.45 to 1.59 s; an edit of one function rebuilds in 30 to 40 ms (coll 100 ms) against Go's 100 to 110 ms.
- **HTTP server** (linux/arm64): Xo serves 1.291 and 1.825 times Go's requests a second on /hello at 32 and 256 connections, and 6.008 and 3.147 times on /alloc.

## Known issues

- **Shutdown hang (native backends, 0.36.0 to 0.40.0; found 2026-11-05):** a native HTTP server with idle keep-alive connections can hang on shutdown (SIGTERM, or a stop request) when a connection answered a request in the 20 ms before the stop; `serve` then waits until the grace period ends, or for ever without one. Fixed in 0.41.0.
- **A large list or map in a `Mutex`:** each `with` that writes the value costs time in proportion to its size, because the value is shared again element by element when the `with` hands it back: a `Mutex[List[Str]]` of 100,000 strings takes about 2.6 s for 20,000 one-element writes on the Mac (0.36.0: about 8.5 s), against 0.02 s at 1,000 strings. The fix (decision 0488, accepted 2026-11-04) writes the value in place and is planned for the next release.
- **/alloc in a 4 KiB page VM** (from 0.36.0, still open): bench/perf/jitter's /alloc on a Linux VM on Apple silicon (4 KiB guest pages) ran at 0.966 and 0.953 of 0.35.0's requests a second at 32 and 256 connections; nothing in this release changes it.
- Closed since 0.36.0: list pushes on macOS (coll push wall 0.863 of v0.36.0) and map-heavy peak memory (coll putget max RSS 0.761 on darwin), and Windows preemption under load (fixed above).

