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:
--strings:ssofor improved string processing efficiency.--ic:onfor parallel, incremental rebuilds.--mm:yrc, a new cycle collection algorithm that supports cycles spanning multiple threads.- More convenient sum types.
- 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 (
memcpyout of a string,memmem, scanning): replaceaddr s[i]/unsafeAddr s[i]withreadRawData(s, i). - Writing bytes in bulk (
memcpy/freadinto a string): replacesetLen+addr s[oldLen]withbeginStore(s, newLen, start)followed byendStore(s).beginStorealready sets the length and does the copy-on-write / unique-ownership dance, so an explicitprepareMutationis 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] = cstill works unchanged. The compiler lowers it tonimStrPutV3, which handles COW internally. You no longer needprepareMutation(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 → cstringstill 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
stringalive and derive the pointer from it (viareadRawDataStable/ the normalcstringconversion) rather than caching the pointer past a move.
Migration checklist
- Grep your code for
addr s[,unsafeAddr s[,addr(...data[, and any cached pointer/cstringinto a string. - Replace read accesses with
readRawData(orreadRawDataStableif the pointer escapes the current scope / must survive a move). - Replace
setLen+ raw write withbeginStore/endStore. - Drop now-redundant
prepareMutationcalls before indexed writes andbeginStore. - Don’t assume
addr s[0]is stable acrossadd, reassignment, or passing the string by value. - Build once with
--strings:ssoand 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.