Nim version 2.4.0 released
28 October 2026 The Nim Team
The Nim Team is happy to announce version 2.4.0, our new stable release.
After a way-too-long period of development, Nim 2.4 has finally become a reality. This is a major update shipping major new features:
1. `--strings:sso` for improved string processing efficiency. 2. `--ic:on` for parallel, incremental rebuilds. 3. `--mm:yrc`, a new cycle collection algorithm that supports cycles spanning multiple threads. 4. More convenient sum types. 5. Cyclic module dependencies: still not encouraged, but fair game once you declare them with `{.cyclic.}`.
If you’re still on Nim 1.6, take a look at the version 2.0 release article to see all the features you’re missing. If you’ve been using Nim 2.0, the version 2.2 release article shows the improvements available in Nim 2.2. If you want to stay on Nim 2.2 for now, we also released version 2.2.14 today, a patch release with many bugfixes and security fixes.
Installing Nim 2.4.0
Check out if the package manager of your OS already ships version 2.4.0 or install it as described here.
If you have installed a previous version of Nim using `choosenim`, getting Nim 2.4.0 is as easy as:
``` $ choosenim update self $ choosenim update stable ```
**NOTE:** We recommend that you install and use the latest version of `choosenim`, v0.8.16, available in our choosenim repo.
Alternatively, you can download Nim 2.4.0 from our nightlies builds.
Release highlights
Strings based on the small-string optimization (SSO)
SSO-based strings come with a new low-level strings API that you have to use in order to use this switch effectively.
Nim’s new strings API is compatible with Nimony’s, so porting is always beneficial, even if `--strings:sso` might not improve the performance of your code. See it as a worthwhile, future-proof refactoring in its own right.
The general idea behind this API is to deprecate the operation `addr myString[offset]` and to replace it with operations that clarify the _intent_. Reading is distinguished from writing.
Here is our porting guide:
| Purpose | Use | Notes | | --- | --- | --- | | Read-only raw pointer | `readRawData(s, start = 0)` | valid while `s` is alive & untouched | | Raw pointer that survives moves/copies | `readRawDataStable(s, start = 0)` | takes `var string`; promotes small strings to heap first | | Bulk write into a string | `beginStore(s, newLen, start = 0)` … `endStore(s)` | sets length, ensures unique ownership, returns writable ptr |
Rules of thumb:
- **Reading bytes** (`memcpy` out of a string, `memmem`, scanning): replace `addr s[i]` / `unsafeAddr s[i]` with `readRawData(s, i)`.
- **Writing bytes in bulk** (`memcpy`/`fread` into a string): replace `setLen` + `addr s[oldLen]` with `beginStore(s, newLen, start)` followed by `endStore(s)`. `beginStore` already sets the length and does the copy-on-write / unique-ownership dance, so an explicit `prepareMutation` is no longer needed.
- **A pointer that must outlive the current scope** (e.g. a cursor stored next to its buffer): use `readRawDataStable`, which forces the string onto the heap so the payload address is stable.
- **Single-element write**`s[i] = c` still works unchanged. The compiler lowers it to `nimStrPutV3`, which handles COW internally. You no longer need `prepareMutation(s)` before an indexed assignment.
#### Before / after examples
Bulk copy _into_ a string:
```
before
result.setLen newLen copyMem(addr result[oldLen], buf, buflen)
after
copyMem(beginStore(result, newLen, oldLen), buf, buflen) endStore(result) ```
Reading bytes _out of_ a string:
```
before
copyMem(dest, addr(s.data[s.pos]), n)
after
copyMem(dest, readRawData(s.data, s.pos), n) ```
Read into a slice of a string:
```
before
prepareMutation(buffer) # COW literal guard under ARC result = s.readData(addr buffer[slice.a], count)
after
result = s.readData(beginStore(buffer, slice.b + 1 - slice.a, slice.a), count) endStore(buffer) ```
#### `cstring` conversions
- Converting `string → cstring` still works, but the C string is only valid as long as the source string is alive and unmodified.
- If you need a stable, NUL-terminated buffer to hand to C, keep the owning `string` alive and derive the pointer from it (via `readRawDataStable` / the normal `cstring` conversion) rather than caching the pointer past a move.
#### Migration checklist
1. Grep your code for `addr s[`, `unsafeAddr s[`, `addr(...data[`, and any cached pointer/`cstring` into a string. 2. Replace read accesses with `readRawData` (or `readRawDataStable` if the pointer escapes the current scope / must survive a move). 3. Replace `setLen` + raw write with `beginStore` / `endStore`. 4. Drop now-redundant `prepareMutation` calls before indexed writes and `beginStore`. 5. Don’t assume `addr s[0]` is stable across `add`, reassignment, or passing the string by value. 6. Build once with `--strings:sso` and once without to confirm the code compiles and behaves identically under both.
Because the helper API has identical signatures on every string backend, code written this way keeps working under refc/ARC/ORC without SSO; you can port incrementally and flip `--strings:sso` on when ready.
#### Once again, in order to avoid confusion:
**Updating your code is not required, but beneficial. The new API works for all `mm` switches and is also offered by Nimony/Nim 3.**
Parallel and incremental builds (IC)
IC is finally shipping! Use it with the switch `--ic:on`. Parallel builds are based on multi-processing, not multi-threading. Hence macros that make use of global `.compileTime` variables might need to get patched before you can enjoy the benefits of much faster rebuilds.
For an example of the required porting effort, see my PR here that allows PolyWorld to be built with `--ic:on`!
Depending on the shape of your import graphs, even **cold** builds might be faster with IC turned on, as the build exploits more parallelism. For example, compiling the Nim compiler itself takes 31s with `--ic:off` on a machine with 16 physical cores, and 25s with `--ic:on`.
YRC – the missing piece of Nim’s MM story
YRC (“ürk”) is the thread-safe variant of Nim’s ORC. Use `--mm:yrc` when you need shared refs and cycles across threads. It offers the same API as ORC. Where it fits:
| mm-switch | Thread-safe | Can handle cycles | | --- | --- | --- | | **ARC** | No | No | | **Atomic ARC** | Yes | No | | **ORC** | No | Yes | | **YRC** | **Yes** | **Yes** |
ORC can only handle cycles that stay inside one thread. YRC lifts that: cycles can span threads. For acyclic data it uses atomic reference counting; for cyclic data it uses a write barrier that only runs at pointer assignments. There is no stop-the-world phase and no arbitrary safepoints. Any thread can run the cycle collector when needed (there is no dedicated GC thread), which helps locality and suits embedded systems where every thread counts.
Nothing is free. In our orcbench, YRC is about 2.0× slower than ORC and it uses twice as much memory, too. Still, we think that’s a good trade: you no longer have to worry about cycles crossing thread boundaries. The cost is a constant factor of two; the payoff grows with every core you put to work on shared data.
Sum types
Nim always offered Ada-inspired “discriminated unions”, for example:
``` type NodeKind = enum # the different node types IntLit, # a leaf with an integer value FloatLit, # a leaf with a float value StringLit, # a leaf with a string value AddOp, # an addition SubOp, # a subtraction IfStmt # an if statement Node = ref object case kind: NodeKind of IntLit: intVal: int of FloatLit: floatVal: float of StringLit: strVal: string of AddOp, SubOp: leftOp, rightOp: Node of IfStmt: condition, thenPart, elsePart: Node ```
While this works, people complained about the lack of concise notation for years. So version 2.4 finally offers something better:
``` type Node = ref object case of IntLit: intVal: int of FloatLit: floatVal: float of StringLit: strVal: string of AddOp, SubOp: leftOp, rightOp: Node of IfStmt: condition, thenPart, elsePart: Node ```
There is no `NodeKind` enum anymore and no `kind` field: the branch names are introduced by the type declaration itself. Under the hood a sum type is still a case object with a hidden discriminator, so the memory layout and the performance are the same as before. (However, in the future the pattern matching allows for the runtime checks to be eliminated!)
A branch name constructs the object, with named arguments for the fields of its branch:
``` let n = AddOp(leftOp: IntLit(intVal: 40), rightOp: IntLit(intVal: 2)) ```
Generic sum types work too. The instance is inferred from the field values or from the expected type:
``` type Opt[T] = object case of None: discard of Some: val: T
let a = Some(val: "abc") # Opt[string] let b: Opt[int] = None() # the expected type selects the instance ```
The fields can only be accessed via pattern matching, so accessing the field of the wrong branch, the classic `FieldDefect`, cannot happen anymore. The fields are bound to the names that follow the branch name:
``` proc eval(n: Node): int = case n of IntLit(v): v of AddOp(a, b): eval(a) + eval(b) of SubOp(a, b): eval(a) - eval(b) else: raiseAssert "not an arithmetic expression" ```
`_` skips a field, `{AddOp, SubOp}(a, b)` matches several branches that share their fields, and the `case` has to cover all branches or have an `else`. If the matched expression is a mutable location, the bindings are views into the object and can be assigned to:
``` type Shape = object case of Circle: radius: float of Rect: w, h: float
var shapes = @[Circle(radius: 1.0), Rect(w: 1.0, h: 2.0)] for i in 0 ..< shapes.len: case shapes[i] of Circle(r): r = r * 2 # updates shapes[i].radius of Rect(w, _): w = 3.0 ```
Otherwise the fields are bound by value. If you really need to access a field outside of a `case`, there is an escape hatch: `{.cast(uncheckedAccess).}: echo n.intVal`.
Classic case objects continue to work and are not deprecated.
`owned` type modifier
This feature has to be enabled with `--experimental:ownedRefs` and I can promise you that we will break your code again if you use the feature.
`owned ref T` (and `owned` closures) mark the location that holds the unique _owning_ edge to an object. This is checked statically: an owned location cannot be copied, only moved, and only fresh values like object constructions can initialize it. Any number of ordinary references to the same object can exist and keep it alive; the runtime representation is the same as that of `ref T`, so code with and without the feature interoperates.
``` {.experimental: "ownedRefs".} type Node = ref object next: owned Node data: int ```
The point of the exercise is cycle collection: owned edges form a forest, so a type whose references are all `owned` or `.cursor` can never be part of a cycle and stays out of the cycle collector under ORC and YRC. See the experimental manual for the details.
Cyclic module dependencies
Nim always allowed modules to import each other, but in a cycle a module only saw the declarations of the other module that came before the `import`. In practice that meant juggling the order of imports and declarations, or moving all types into a `types.nim` module.
Import cycles are still a design smell as they turn the import DAG into a general graph, but sometimes they are the natural structure, and then the compiler should not stand in your way.
You can annotate the imports of a cycle with the `cyclic` pragma:
```
module a
import b {.cyclic.}
type A* = object b*: B # a type of `b`, even though `b` imports `a`
proc isEven*(n: int): bool = n == 0 or isOdd(n - 1) ```
```
module b
import a {.cyclic.}
type B* = object x*: int a*: ref A
proc isOdd*(n: int): bool = n != 0 and isEven(n - 1) ```
The modules connected by such imports form a _cycle group_ that is checked phase by phase: first the imports of all modules, then all type sections, then the routines (their bodies come later), then the rest of each module. So types can refer to each other across modules, and the procs of the modules can call each other, regardless of the order of the declarations.
It is good practice to annotate the imports on both sides of a cycle. Import cycles without the annotation still work as before, but are deprecated; compile with `--warning:ImplicitCyclicImport:on` to find them. The precise rules are described in the manual.
A complete list of changes is available here.