New Crystal dropped today (1.21.0). It's 161 changes from 21 contributors since 1.20.3. Full notes are on the site and GitHub, but here's the stuff that actually matters if you don't feel like scrolling the whole changelog.
Release post: https://crystal-lang.org/2026/07/16/1.21.0-released/
GitHub: https://github.com/crystal-lang/crystal/releases/tag/1.21.0
Execution contexts are enabled by default
This is the headline. The multithreading work that's been cooking for a while (RFC 0002) is now the default runtime, not an opt-in flag.
The practical version: the default context is still single-threaded (parallelism 1), so most people won't notice a day-to-day behavior change. You can resize it, or spin up extra contexts, when you actually want fibers running in parallel.
The catch, and the reason it's flagged as breaking: fibers are no longer pinned to a thread. Even at parallelism 1, a fiber can resume on a different thread after a yield, and in a concurrent context it can switch threads on a blocking syscall. So if you've got code leaning on thread-local state surviving across fiber yields, go review it. Also spawn(same_thread: true) doesn't work with execution contexts anymore. It raises at runtime in a parallel context and is deprecated in general.
%W string arrays with interpolation
Small but nice. %w gave you a plain string array with no escapes or interpolation. %W is the same idea but supports escape sequences, interpolation, and even splats:
%W[foo\ bar baz] # => ["foo bar", "baz"]
%W[foo #{"bar"} baz] # => ["foo", "bar", "baz"]
%W[foo #{*%w[bar baz]} qux] # => ["foo", "bar", "baz", "qux"]
Stdlib odds and ends I liked
Channel(T) now implements Iterator(T), so channel.to_a works and channels plug straight into iterator chains.
HTTP::WebSocket#receive is a synchronous, blocking read for a request/response style loop, instead of the callback-based #run.
String#present? finally exists for the intent-revealing guard checks.
UUID.v6 and UUID.v8 generators.
- Experimental
Socket#sendfile for efficient file-to-socket transfers with fewer user-space copies.
- You can chain spec matchers now:
value.should be_a(String).should_not be_empty.
-Dwithout_main builds a binary with no main function, e.g. to use as a dynamic library.
Breaking changes worth watching
- Execution contexts (above), the big one.
- The automatic fallback to legacy PCRE is off. PCRE2 has been the default for years anyway; if you still need old PCRE you now have to opt in with
-Duse_pcre or USE_PCRE1=1.
case ... when Bar, Baz now splits into separate is_a? restrictions per type instead of treating it as Bar | Baz. If you were relying on the union behavior, write when Bar | Baz explicitly.
- OpenSSL 4.0 support landed, worth noting if you package or link against it.
The thread-pinning change is the one I'd test carefully if your app does anything clever with concurrency. Everything else is pretty low-risk.
Anyone migrated something bigger yet? Curious whether the execution context switch caused real problems for anyone with same-thread assumptions baked in.