languages/nim/common_pitfalls.md

Nim Common Pitfalls & Anti-Patterns

The canonical, exhaustive catalogue of Nim semantics that cause confident-but-wrong LLM-generated code: partial case/underscore-insensitive identifiers, the Nim 1.x-to-2.0 ARC/ORC memory-management default change, ref-vs-value aliasing bugs, off-by-one ranges, nil vs Option[T], the raises effect system, and hallucinated standard-library names.

Introduction / Overview

Nim is underrepresented in the training data of every large language model, and the material that does exist is disproportionately pre-2.0 (before August 2023), when the language's default memory-management model was fundamentally different from today's. Combined with Nim's syntactic closeness to Python - which invites false pattern-matching from a much larger and more familiar corpus - this makes Nim one of the languages where an LLM is most likely to produce code that compiles, looks idiomatic, and is subtly or catastrophically wrong.

This document exists to be checked against, not skimmed once. It catalogues the specific, verified semantics that most commonly produce plausible-but-wrong Nim code: a distinctive identifier-equality rule with no equivalent in any other mainstream language, a memory-management default that inverted between Nim 1.x and 2.0, value/reference semantics that differ from both Python's "everything is a reference" model and C's "everything is a flat value" model, and a standard library whose actual names frequently do not match what an LLM trained on Python/Rust/Go conventions would guess. Every pitfall below follows the same structure: the plausible-but-wrong code a model is likely to generate, the precise rule (quoted from the official Nim manual or verified via the Nim blog/changelog where the manual is silent) that makes it wrong, and the corrected version.

For idiomatic day-to-day patterns not covered here, see bestpractices.md; for the ecosystem and installation model, see README.md. This document assumes that context and focuses exclusively on failure modes.

When to use this guidance: Before trusting, committing, or shipping any generated Nim code - especially code that "looks right" at a glance, was translated from Python/Rust/Go/C, or relies on memory-management or exception-handling behavior recalled from pre-2023 material.

Core Concepts

  • Style-insensitivity, not case-insensitivity: Nim's identifier rule is neither fully case-sensitive (like Python/Rust) nor fully case-insensitive (like some older BASIC dialects) - it is a specific hybrid documented precisely in the manual, and getting the exact rule wrong is the single most common source of "invisible" naming bugs.
  • The 2.0 memory-management inversion: Nim did not just add a new GC option in 2.0 - it swapped the default, from a deferred reference-counting tracing collector (refc) to a deterministic, hook-based ARC/ORC model. Any reasoning that assumes the old default without checking is reasoning about a different language runtime.
  • Two distinct wrong mental models for ref: an LLM defaulting to Python semantics over-assumes reference/aliasing behavior everywhere; an LLM defaulting to C semantics under-assumes it for seq/string, which do own a heap buffer despite having value-type assignment semantics.
  • Opt-in strictness, not implicit strictness: Nim's exception effect tracking ({.raises.}), purity tracking (func), and even nil-safety are all optional, declared guarantees layered on top of a permissive-by-default language - unlike Java's checked exceptions or Rust's Result, nothing forces you to opt in, and generated code frequently assumes a strictness that isn't actually there by default.
  • A standard library with its own naming conventions: Nim's stdlib module and proc names frequently do not match the nearest Python/Go/Rust equivalent, and an LLM will confidently invent a plausible name when it doesn't recall the real one.

Best Practices

The best practices for avoiding these pitfalls are procedural, not stylistic - apply them to every piece of generated Nim code before trusting it:

  1. Compile before trusting. nim check or nim c on every generated snippet catches the majority of the syntactic and naming pitfalls below immediately; Nim's compiler errors for an unknown identifier or a redeclared one are specific and actionable.
  2. Grep the actual stdlib source or docs for a proc/module name before using it, rather than trusting a plausible-sounding guess - see Pitfall 9.
  3. Treat any memory-management explanation, tutorial, or Stack Overflow answer without an explicit Nim version as suspect and verify it against the current manual or mm.html - see Pitfall 2.
  4. Never introduce a "new" variable name to resolve a naming conflict without checking it against the identifier-equality rule first - see Pitfall 1.
  5. Prefer the narrowest correct type for absence and failure (Option[T], typed exceptions with {.raises.}) over habits carried in from other languages - see Pitfalls 7 and 8.

Common Pitfalls & Anti-Patterns

Pitfall 1: Partial Case- and Underscore-Insensitive Identifier Equality

This is the single most distinctive - and most dangerous for code generation - semantic in Nim. The official manual defines identifier equality precisely as the result of this algorithm:

proc sameIdentifier(a, b: string): bool =
  a[0] == b[0] and
    a.replace("_", "").toLowerAscii == b.replace("_", "").toLowerAscii

In prose: the first character of an identifier is compared case-sensitively and literally; every character after the first is compared case-insensitively (ASCII), and all underscores anywhere in the identifier are removed before that comparison. The manual calls this "partial case-insensitivity" and explicitly justifies the first-character exception: it is what allows var foo: Foo to parse unambiguously, since foo and Foo must remain distinct identifiers for that idiom to work at all. The same rule applies to keywords (notin, notIn, and not_in are the same keyword).

The plausible-but-wrong pattern: an LLM asked to "rename a variable to avoid a naming conflict," or translating a codebase from a language with full case sensitivity, invents a name that differs only in case or underscore placement from an existing one - and still collides.

# Wrong: the LLM "fixes" a redeclaration error by tweaking case/underscores
var fooBar = 1
# ... later, in an attempt to introduce a distinct second variable:
var foo_bar = 2      # compile error: redeclaration - this IS `fooBar`, not a new identifier

Worked collision table (given the exact rule above):

Identifier AIdentifier BSame identifier?Why
fooBarfoo_barYes - collidesFirst chars f==f; stripped+lowercased both become foobar
fooBarfoobarYes - collidesSame reasoning; underscore presence/absence is irrelevant here anyway
fooBarFooBarNo - distinctFirst chars f vs F differ, and first-char comparison is case-sensitive
foo_barFoo_BarNo - distinctFirst chars f vs F differ
FooBarFOOBARYes - collidesFirst chars F==F; stripped+lowercased both become foobar
_fooBarfooBarN/ANim identifiers cannot begin with an underscore at all - this is a separate lexical rule, not a case of the equality algorithm

Corrected approach: never disambiguate two identifiers using only a case or underscore variation. Use a semantically distinct name (fooBarPrimary vs fooBarSecondary, or better, names that describe different roles entirely), and when reviewing generated code that introduces a "new" name to fix a conflict, manually run it through the algorithm above rather than trusting that it looks different.

# Correct: genuinely distinct identifiers
var primaryTotal = 1
var secondaryTotal = 2

This rule also silently affects overload resolution, import name clashes, and object field names - treat it as a parser-level equality test everywhere an identifier appears, not a lint-only style preference.

Pitfall 2: Assuming the Pre-2.0 (refc) Memory-Management Default

Nim 2.0 (released August 1, 2023) changed the default memory management mode from refc - a deferred, thread-local reference-counting garbage collector with a mark-and-sweep backup for cycles - to ORC, ARC (deterministic, compile-time-inserted reference counting) augmented with a trial-deletion cycle collector. Because the overwhelming majority of Nim tutorials, blog posts, and forum answers in any model's training data predate mid-2023, an LLM's default mental model of Nim memory management is very likely to be the old one.

What breaks in an LLM's mental model that still assumes refc as the default:

  • Destructor/move hooks: under ARC/ORC, the compiler synthesizes and calls =destroy, =copy, =sink, and =trace hooks at well-defined points (scope exit, assignment, parameter passing) to manage lifetime deterministically. Code and explanations written for the refc era do not model this at all - they describe eventual collection by a background collector, not RAII-style, scope-exit-timed destruction.
  • Value vs move semantics for seq/string/object: under ARC/ORC, passing a large seq into a sink parameter is compiled as a move (a cheap pointer/length transfer, with the source left "empty" and not meant to be read again) whenever the compiler can prove the source isn't reused. Pre-2.0 material has no concept of this and may lead a model to assume implicit deep copies or implicit shared references where a move actually occurs.
  • Manual GCref/GCunref calls: these APIs are refc-specific and are meaningless (or simply absent) noise under the ARC/ORC default; generated code that calls them assuming they're still load-bearing is solving a problem that no longer exists in the current default runtime.
  • --mm:orc/--mm:arc/--mm:refc flags: pre-2.0 material may not mention --mm at all (the flag was --gc: and the values/defaults differed). Explicitly selecting --mm:refc still works for legacy compatibility, but it is no longer the implicit behavior of a bare nim c.
# Wrong: reasoning and code that assume pre-2.0 refc-era defaults
type Node = ref object
  value: int
  next: Node

proc makeNode(v: int): Node =
  new(result)              # fine syntactically, but comments/reasoning assuming
  result.value = v          # a background tracing GC eventually reclaims cycles
  # GC_ref(result)           # meaningless boilerplate carried over from refc-era code
# Correct: reason about ARC/ORC's deterministic, hook-based model (the 2.0+ default)
type Node = ref object
  value: int
  next: Node

proc makeNode(v: int): Node =
  new(result)
  result.value = v
  # No manual refcounting calls needed: ORC's cycle collector reclaims
  # `next`-pointer cycles automatically; ARC alone (--mm:arc) would not.

Always verify memory-management claims against the current manual or mm.html rather than training-data recall, and check whether a piece of reference material even states which Nim version it targets.

Pitfall 3: ref vs Value-Type Semantics - Wrong in Both Directions

Nim's default aggregate types (object, array, tuple) are value types: assignment and parameter passing copy (or, under ARC/ORC, move) the value, not a reference to it. ref object is the explicit, opt-in mechanism for heap-allocated, shared, potentially cyclic data. This is neither Python's model (where user-defined objects are always reference semantics) nor C's (where nothing implicitly heap-allocates), and an LLM tends to default to one of those two incorrect models depending on which language it is pattern-matching from.

Wrong direction 1 - assuming Python-style implicit aliasing everywhere:

type Point = object
  x, y: int

var a = Point(x: 1, y: 2)
var b = a          # this is a COPY, not an alias, unlike Python's `b = a` for an object
b.x = 99
echo a.x            # prints 1, not 99 - surprising if you expected shared reference semantics

Wrong direction 2 - assuming C-style "no hidden allocation" for seq/string:

seq[T] and string are value types with copy-assignment semantics, but they are not flat, fixed-size, non-allocating values like a C array or struct - internally, they own a heap-allocated, growable buffer. Assigning one seq/string to another is a value copy of the seq itself, which under ARC/ORC will deep-copy the buffer unless the compiler can prove a move is safe (e.g., passing to a sink parameter, or the source going out of scope immediately after).

# Wrong: assuming a seq assignment is a cheap, non-allocating, C-style value copy
var original = newSeq[int](1_000_000)
var alias = original      # NOT a pointer-copy: this is a full seq value copy
                            # (a real heap-buffer duplication unless the compiler moves it)
alias[0] = 42
echo original[0]           # prints 0, unchanged - `alias` was an independent copy, not an alias

Corrected mental model: use plain object (or seq/string) when each value has a single clear owner and copying (or moving) on assignment is the desired semantic - this is the common case and requires no ref at all. Reach for ref object specifically when the data must be genuinely shared across multiple owners, must have a stable heap address, or is recursive/cyclic (a tree with parent pointers, a linked list, a graph) - shapes plain value types cannot express.

type
  Point = object            # value type: copy semantics, no ref needed for simple data
    x, y: int

  Node = ref object          # ref: explicit, opt-in shared/heap/cyclic semantics
    value: int
    next: Node               # a plain `object` could not self-reference like this

var shared = Node(value: 1)
var sameNode = shared        # this IS an alias - both point to the one heap-allocated Node
sameNode.value = 99
echo shared.value             # prints 99 - `ref` genuinely aliases, unlike plain `object`

Pitfall 4: == on object vs ref object - Identity vs Structural Equality

Nim's standard library provides a generic, compiler-recognized == for plain tuple/object types that performs recursive, field-wise structural comparison automatically - no manual implementation needed. For ref types, however, the built-in == compares by reference identity: whether both operands are the same underlying heap allocation (or both nil), not whether the objects they point to have equal field values. An LLM that has just internalized "objects compare structurally in Nim" (Pitfall 3's antidote) frequently over-generalizes that guarantee to ref object as well.

type
  Point = object              # value type
    x, y: int

  PointRef = ref object        # reference type
    x, y: int

let p1 = Point(x: 1, y: 2)
let p2 = Point(x: 1, y: 2)
echo p1 == p2                   # true: auto-generated structural (field-wise) equality

let r1 = PointRef(x: 1, y: 2)
let r2 = PointRef(x: 1, y: 2)
echo r1 == r2                   # Wrong assumption: this is false - different allocations,
                                 # even though every field matches

Corrected approach: for ref object types where value equality is actually wanted, define an explicit == overload that dereferences and compares fields (or delegates to the underlying object's structural equality), and do not rely on the default:

type PointRef = ref object
  x, y: int

proc `==`(a, b: PointRef): bool =
  (a.isNil and b.isNil) or
    (not a.isNil and not b.isNil and a.x == b.x and a.y == b.y)

let r1 = PointRef(x: 1, y: 2)
let r2 = PointRef(x: 1, y: 2)
echo r1 == r2                    # true, now that structural equality is explicit

Pitfall 5: Off-by-One Errors - 0..n (Inclusive) vs 0..<n (Exclusive)

Nim's .. range/slice operator is inclusive on both ends. 0..n produces n + 1 values (0 through n inclusive). This is the opposite convention from Python's range(n) (exclusive) and differs subtly from Rust's 0..n (exclusive) - code translated from either frequently keeps the inclusive .. where an exclusive bound was intended, silently processing one extra element and, for indexing, causing an out-of-bounds access.

# Wrong: translated from Python's `for i in range(items.len)` / Rust's `for i in 0..items.len`
let items = @["a", "b", "c"]
for i in 0..items.len:          # inclusive: iterates i = 0, 1, 2, 3 - one too many
  echo items[i]                  # IndexDefect on i == 3 (items.len == 3, valid indices 0..2)

Corrected approach: use 0..<n (the exclusive-upper-bound operator, read "up to but not including") whenever the upper bound must be excluded - overwhelmingly the common case when iterating a 0-indexed collection by its .len:

let items = @["a", "b", "c"]
for i in 0..<items.len:           # exclusive: iterates i = 0, 1, 2 - matches valid indices
  echo items[i]

for item in items:                 # idiomatic: avoid manual indexing entirely where possible
  echo item

For stepped iteration, Nim provides countup(first, last, step) (ascending, inclusive of last if reachable by the step) and countdown(first, last, step) (descending); for i in countup(0, 10, 2) is the idiomatic equivalent of a C-style for (i = 0; i <= 10; i += 2), and there is no implicit direction inference from range syntax the way some other languages provide - 10..0 does not count downward and instead produces an empty range, a related and equally common mistranslation:

# Wrong: expecting `..` to auto-detect descending order
for i in 10..0:
  echo i                            # never executes: `10..0` is an empty range, not "10 down to 0"

# Correct: explicit descending iteration
for i in countdown(10, 0):
  echo i                            # 10, 9, 8, ..., 0

Pitfall 6: echo Is Not print, and Python f-Strings Are Not Valid Nim Syntax

Nim has no built-in function named print; the standard output primitive is echo, which space-separates its arguments (stringifying each via the $ operator, which has broad compiler- and library-generated overloads for built-in and user types) and appends a newline. An LLM trained overwhelmingly on Python readily invents a print(...) call, or invents Python-style f-string syntax (f"{expr}"), neither of which is valid Nim.

# Wrong: Python habits leaking through
print("Total: ", total)            # compile error: `print` is not declared
let msg = f"Total: {total}"        # compile error: Nim has no f-string prefix syntax

Nim's actual interpolation options, both real and verified against the standard library:

import std/strformat

let total = 42

echo "Total: ", total                    # plain echo: comma-separated arguments, space-joined

let viaAmp = &"Total: {total}"           # std/strformat's `&` operator: interpolated string literal
let viaFmt = fmt"Total: {total}"         # std/strformat's `fmt` prefix macro: equivalent interpolation

echo viaAmp
echo fmt"Right-aligned: {total:>6}"       # std/strformat also supports alignment/precision specifiers

std/strutils additionally provides the older % operator for positional/named template-style formatting, still common in existing codebases and worth recognizing even if strformat is preferred for new code:

import std/strutils

echo "$1 eats $2." % ["The cat", "fish"]   # -> "The cat eats fish."

Neither &"..." nor fmt"..." is a drop-in Python f-string: both require import std/strformat, and & is a genuine operator (not string-literal syntax the parser recognizes on its own), so it must be applied to an actual string literal or expression, not assumed to work implicitly.

Pitfall 7: nil for ref/ptr Types vs Option[T]

Unlike languages with no null reference at all, Nim's ref and ptr types genuinely have a valid nil value - the manual states plainly that "nil is the default value for all ref and ptr types" and that dereferencing a nil reference "is an unrecoverable fatal runtime error," not a catchable exception. An LLM carrying over Python's None/Java's null-with-NullPointerException model tends to make two separate mistakes: inventing a nonexistent null or None keyword, and assuming a nil dereference can be caught with try/except like a normal exception.

# Wrong: inventing keywords that don't exist in Nim
var maybeNode: Node = null          # compile error: `null` is not a Nim keyword - it's `nil`
var other: Node = None               # compile error: `None` doesn't exist in Nim either

# Wrong: assuming a nil dereference is a catchable exception
try:
  echo maybeNode.value                # if maybeNode is nil, this is a fatal runtime error -
except:                                # NOT something `except` (without --panics:off tuning) catches
  echo "handled the null pointer"      # this handler will not run; the process has already died
# Correct: nil is the right keyword, and must be checked, not caught
var maybeNode: Node = nil
if maybeNode != nil:
  echo maybeNode.value
else:
  echo "no node"

For API design - "this value may legitimately be absent" as opposed to "this pointer happens to be null right now" - modern, idiomatic Nim increasingly prefers std/options' Option[T] over a bare nilable ref, making absence part of the type rather than a runtime hazard callers must remember to check:

import std/options

proc findUser(id: int): Option[string] =
  if id == 1: some("alice") else: none(string)

let user = findUser(2)
if user.isSome:
  echo user.get()
else:
  echo "no such user"           # forced, type-level handling of the absent case

Prefer Option[T] for new API surfaces where absence is an expected, frequently handled outcome; reserve bare nilable ref for cases genuinely modeling optional heap-allocated identity (e.g., a linked structure's terminal nil).

Pitfall 8: Exception Effect Tracking ({.raises.}) - Neither Ignored Nor Java-Style Checked

Nim's default error-handling mechanism is ordinary, unchecked exceptions: raise/try/except/finally, with no requirement to declare what a routine might raise, unlike Java's checked exceptions. Effect tracking is a separate, opt-in layer: the {.raises: [...].} pragma lets the compiler verify - at the declaration site, not by inspecting every call site - that a routine raises only the listed exception types (or nothing at all, with {.raises: [].}). Two opposite mistakes are common: assuming all Nim code must declare its exceptions like Java (it does not, unless {.raises.} is used), and generating library code with no effect annotations at all where the audience (other library consumers) specifically needs the compiler-checked guarantee.

# Wrong assumption 1: treating Nim as if every call site must handle exceptions explicitly
# (there is no such requirement by default - this compiles and runs fine without any try/except)
proc parsePositive(s: string): int =
  result = parseInt(s)              # may raise ValueError; no annotation required, no compile error

# Wrong assumption 2: a public library proc with no raises contract at all,
# leaving callers unable to get a compile-time guarantee about failure modes
proc riskyLibraryCall*(s: string): int =
  result = parseInt(s) * externalLookup(s)   # what can this raise? nothing forces documenting it
# Correct: declare the effect explicitly on library-boundary procs
proc parsePositive(s: string): int {.raises: [ValueError].} =
  result = parseInt(s)               # compiler verifies nothing else can escape this proc
  if result <= 0:
    raise newException(ValueError, "must be positive")

proc pureAdd(a, b: int): int {.raises: [].} =
  a + b                               # compiler-verified: this genuinely cannot raise

A related, verified nuance: Nim's Defect hierarchy (IndexDefect, OverflowDefect, AssertionDefect, and similar) represents unrecoverable programming errors, not ordinary recoverable failures, and their catchability is itself a compiler-flag-dependent setting. The manual states this plainly for runtime errors: "The current implementation allows switching between these different behaviors via --panics:on|off. When panics are turned on, the program dies with a panic, if they are turned off the runtime errors are turned into exceptions." Generated code that wraps routine array indexing or arithmetic in try/except "to be safe," expecting it to always behave like Python's catchable IndexError/OverflowError, is relying on a project-configuration detail (--panics setting) rather than a language guarantee - do not assume Defects are always catchable, and do not treat catching them as a substitute for fixing the underlying logic error.

Pitfall 9: Hallucinated Standard Library Modules and Procs

Generating plausible-sounding but nonexistent Nim standard library names is a systemic risk: Nim has real modules and procs for nearly everything a Python/Go/Rust-trained model expects, but under different, verified names. Treat any stdlib reference in generated code as unverified until checked against nim-lang.org/docs. Five concrete, verified examples:

TaskPlausible wrong guessActual, verified Nim stdlib
Split a stringstd/stringutils, .split() with no importimport std/strutils - strutils.split(s, sep)
Parse JSONstd/jsonparser, json.loads(s) (Python-shaped)import std/json - json.parseJson(s)
Make an HTTP requestimport std/http, http.get(url)import std/httpclient - newHttpClient().getContent(url)
Read a whole filestd/io, io.readFile(path)readFile(path) - historically in system, relocated to std/syncio as of Nim 2.0 (still reachable unqualified during the transition, but import std/syncio is the explicit, future-proof form once -d:nimPreviewSlimSystem is enabled)
String interpolationA nonexistent std/fstrings moduleimport std/strformat - &"{expr}" or fmt"{expr}" (see Pitfall 6)
# Wrong: plausible-sounding but nonexistent module/proc names
import std/stringutils          # compile error: no such module
import std/http                  # compile error: the module is `std/httpclient`

let parts = "a,b,c".splitString(",")   # compile error: no such proc

let data = json.loads(body)             # compile error: Python-shaped name; Nim's is `parseJson`
# Correct: verified current stdlib names
import std/strutils
import std/httpclient
import std/json

let parts = "a,b,c".split(",")
let client = newHttpClient()
let body = client.getContent("https://example.com/api")
let data = parseJson(body)

When in doubt, nim check and read the actual compiler error - "undeclared identifier" or "cannot open file" for an import is the compiler telling you the guessed name does not exist, not a configuration problem to work around.

Pitfall 10: Indentation and Command-Syntax Ambiguities

Nim's grammar is indentation-sensitive like Python's, but the specific rules around statement continuation and "command syntax" (calling a routine without parentheses) create parse ambiguities that have no Python equivalent and can produce confusing errors in generated code.

Whitespace changes meaning around parentheses. A space immediately before ( changes whether the parenthesized content is parsed as call arguments or a tuple literal:

echo(1, 2)     # calls echo with two arguments: 1 and 2
echo (1, 2)    # constructs the tuple (1, 2) and passes it as ONE argument to echo

Command-invocation syntax (calling without parentheses) does not compose well with certain expressions. echo "value:", x + 1 behaves as expected, but chaining multiple no-parenthesis calls or mixing command syntax with operators in the wrong position is a common source of "expression expected" or unexpected-precedence errors that an LLM used to Python's simpler call syntax will not anticipate:

# Wrong: ambiguous mixture of command syntax and infix operators
result = if x > 0: "positive" else: "non-positive" & suffix
# parses `else` branch as `"non-positive" & suffix`, likely not the intended grouping
# if the intent was to apply `& suffix` to the whole if-expression's result

# Correct: parenthesize the expression whose scope is ambiguous
result = (if x > 0: "positive" else: "non-positive") & suffix

Multi-line statement continuation requires either increased indentation or a trailing operator/opening bracket - a bare line break in the middle of what looks like one logical expression is not automatically continued the way it might be in a language with explicit statement terminators:

# Wrong: naive line-break in the middle of an expression, no indentation or operator signal
let total = subtotal +
tax                          # compile error: `tax` is not indented further than `let`, breaks the continuation

# Correct: continuation must be indented deeper than the statement's start
let total = subtotal +
  tax

When generated code produces a confusing "invalid indentation" or "expression expected" error at a line that looks syntactically fine in isolation, check whether it is a command-syntax or continuation ambiguity like the ones above before assuming it is a typo.

Pitfall 11: Generic Constraints and concepts - Not Rust Traits or C++ Templates

Nim's generic constraint syntax looks superficially similar to both Rust trait bounds and C++ template/concept constraints, but the semantics differ enough that pattern-matching from either produces subtly wrong code.

Basic constrained generics use a colon inside the bracket list, constraining a type parameter to a built-in or user-defined type class:

proc sum[T: SomeInteger](xs: openArray[T]): T =
  for x in xs: result += x

SomeInteger, SomeFloat, SomeOrdinal, and SomeNumber are standard-library type classes (not "traits" in the Rust sense - there is no method-set contract being satisfied, just a type-membership check).

concepts are Nim's structural/duck-typed constraint mechanism, closer in spirit to C++20 concepts than to Rust traits (there is no explicit impl Trait for Type - any type whose usable expressions satisfy the concept body matches implicitly):

type Comparable = concept x, y
  (x < y) is bool

proc largest[T: Comparable](a, b: T): T =
  if a < b: b else: a

Verified current status: concepts are documented in manualexperimental.html, not the stable manual.html - a signal, confirmed by open compiler issues and community discussion, that concepts remain less mature and less stable than core generics. Reported problems include overly permissive matching (a concept can accept types that clearly should not satisfy it) and outright compiler crashes on some concept bodies. Treat concepts as a "verify on the current compiler version before relying on it in production" feature, not a settled, Rust-trait-equivalent constraint mechanism - for most constraint needs, a SomeX type-class bound or a simple [T: SomeInteger]-style constraint is both sufficient and considerably more stable.

# Risky: leaning on `concept` where a simpler, more stable constraint would do
type Addable = concept x, y
  (x + y) is typeof(x)

proc total[T: Addable](xs: openArray[T]): T = ...
# More stable: a plain type-class constraint covers the common numeric case directly
proc total[T: SomeNumber](xs: openArray[T]): T =
  for x in xs: result += x

Pitfall 12: var / let / const and Compile-Time (static) Evaluation Confusions

Nim has three binding keywords with genuinely different guarantees, plus a separate static[T] mechanism for compile-time-known generic values - and they are not interchangeable the way "just use let/const for anything that doesn't change" reasoning (carried over from JavaScript's let/const) would suggest.

  • var: a mutable, runtime binding.
  • let: a single-assignment, runtime binding - the value is computed once at runtime and cannot be reassigned, but it is not necessarily known at compile time.
  • const: a compile-time constant - the initializer must be evaluable by the compiler (a literal, another const, or a call to a proc the compiler can execute via CTFE); it is baked into the binary, not computed at program startup.
# Wrong: assuming `let` gives compile-time-constant guarantees (JS `const`-style thinking)
proc currentLimit(): int = 100      # a runtime proc call, even though it always returns 100

let limit = currentLimit()           # fine as `let` - but NOT usable where a compile-time
                                       # constant is required, e.g. as an array size

var buffer: array[limit, int]        # compile error: array size must be a compile-time constant,
                                       # and `limit` is a `let`, not a `const`
# Correct: use `const` (and a compile-time-evaluable initializer) where compile-time-known
# values are actually required, `let` for ordinary immutable runtime values otherwise
const MaxLimit = 100                  # genuinely compile-time constant

var buffer: array[MaxLimit, int]      # fine: array size is a `const`

let userLimit = readLimitFromConfig() # fine as a runtime `let`, but cannot size an `array`

static[T] generic parameters take this further: they force an argument to a generic routine to be known at compile time, letting the routine's body branch on it with when (see metaprogramming.md) rather than a runtime if. Do not conflate "immutable at runtime" (let) with "known at compile time" (const/static[T]) - only the latter two can participate in contexts the compiler must resolve before code generation (array sizes, when conditions, other const initializers).

Pitfall 13: nimble vs atlas Confusion and Dependency Pinning

Nim ships with Nimble as its bundled, default package manager (a .nimble project file, a central packages.json name index, nimble install/build/test). Atlas is a newer, actively developed, Git-native alternative from the Nim project that clones dependencies by Git tag/commit into an isolated workspace and manages a project's nim.cfg, while remaining compatible with the existing .nimble file format rather than replacing it. As of 2026, both are maintained, but Nimble remains the default and the tool assumed by the overwhelming majority of third-party tutorials, CI examples, and generated code - an LLM should not assume Atlas-specific commands or workflows unless a project explicitly demonstrates Atlas usage (an atlas.lock file, atlas.config, or an explicit README instruction).

# orders.nimble - Nimble's own dependency-declaration syntax
requires "nim >= 2.0.0"
requires "results >= 0.5.0"      # a version *floor*, not an exact pin
nimble install results   # resolves to whatever version satisfies the floor above, at install time
nimble lock               # generates/updates nimble.lock for reproducible installs across environments

The pinning gotcha: a bare requires "results >= 0.5.0" is a version floor, not a pin - without a committed nimble.lock, two installs at different times can legitimately resolve to different concrete dependency versions, exactly the kind of non-reproducibility that surprises engineers coming from ecosystems where a lockfile is generated and committed by default (Cargo.lock, package-lock.json). Always generate and commit nimble.lock (or adopt Atlas's Git-commit-based pinning) for any project where reproducible builds matter, rather than assuming the bare requires line in the .nimble file is sufficient on its own. Do not invent Atlas commands from Rust/Go package-manager muscle memory (there is no atlas add-style single-dependency-add subcommand analogous to cargo add) - verify current Atlas usage against nim-lang/atlas before generating Atlas-specific instructions.

Testing Strategies

Verifying that generated Nim code actually avoids these pitfalls requires exercising the compiler and runtime, not just reading the code:

  • Always compile. nim check src/module.nim catches the majority of Pitfalls 1, 9, 10, and 12 (redeclarations, undeclared identifiers, syntax ambiguities, and compile-time-constant violations) with zero execution risk.
  • Negative/boundary tests for ranges. For any loop or slice using ../..<, add a test at the collection's exact length boundary to catch Pitfall 5's off-by-one class before it reaches production.
  • Explicit nil/absence tests. For any ref-returning or Option[T]-returning routine, test both the present and absent paths explicitly (Pitfall 7); do not rely on "it worked in the happy-path test" as evidence of correct nil handling.
  • Compile-time assertions for effect and constness guarantees. A {.raises: [].}-annotated proc that later starts calling something fallible fails to compile - treat that as a test in itself, not just documentation (Pitfall 8).
  • See testing.md for the full std/unittest vs testament comparison and CI wiring.

Performance, Security & Scalability Considerations

  • --panics:on in production builds removes ambiguity around Defect catchability (Pitfall 8) - deciding this project-wide, and documenting the decision, avoids code that assumes one behavior while the build configuration silently provides the other.
  • Unverified ref aliasing bugs (Pitfall 3) are a correctness and security concern, not just a stylistic one - shared mutable state accessed through unintentionally-aliased ref handles is a route to logic errors in concurrent code; see concurrency.md for the thread-safety implications specifically.
  • Dependency floors without a lockfile (Pitfall 13) are a supply-chain risk, not merely a reproducibility inconvenience: an unpinned requires line can silently pull in a newer, unaudited dependency version on a future nimble install.
  • -d:danger removes the runtime checks (bounds, overflow, nil-dereference) that would otherwise convert several of these pitfalls (Pitfall 5's off-by-one, Pitfall 7's nil dereference) into a controlled crash instead of undefined behavior - never adopt -d:danger in a codebase that has not been proven correct by testing every pitfall category above with checks still enabled.

Edge Cases & Advanced Usage

  • Cross-module identifier collisions (Pitfall 1) are especially dangerous because the colliding identifier may live in a different, imported module rather than the same file - a generated import combined with a locally-declared "new" variable can collide with an imported symbol in ways a single-file read-through will not reveal; compiling is the only reliable check.
  • Mixing --mm:arc (no cycle collector) with code that creates genuine reference cycles (Pitfall 2) - including the stdlib's asyncdispatch implementation - leaks memory silently rather than crashing, making it a slow-burn production issue rather than an immediately visible bug; default to ORC unless a specific, verified reason favors bare ARC.
  • {.raises.} on a proc that calls into a method (Pitfall 8) is harder to verify exhaustively, since dynamic dispatch means the effect system must account for every possible override, not just the base implementation - audit {.raises.} annotations on any hierarchy using runtime polymorphism especially carefully.
  • Migrating a pre-2.0 codebase surfaces most of Pitfall 2's issues at once, plus the strictFuncs tightening (a store through a ref/ptr dereference is disallowed in a func in more cases than pre-2.0) as a specific, common migration friction point documented in the Nim 2.0 release notes.

When Not to Use / Alternatives

This document's guidance applies specifically to writing or reviewing Nim; it is not a case for or against Nim itself. Where a specific pitfall category is a genuine, recurring source of friction for a team or use case, weigh it against the alternatives already covered in README.md and bestpractices.md:

Pitfall categoryNim's exposureRust's exposureGo's exposure
Identifier-equality surprisesHigh (unique to Nim)None (fully case-sensitive)None (fully case-sensitive)
Memory-model version drift in training dataHigh (2.0 default changed)Low (ownership model stable since 1.0)Low (GC model stable)
ref/value aliasing confusionModerate (opt-in ref, but easy to default wrong)Low (borrow checker forces explicit reasoning)Moderate (implicit pointer semantics for some types)
Hallucinated stdlib namesHigh (small ecosystem footprint in training data)ModerateModerate

If identifier-equality surprises alone are judged an unacceptable risk for a given team or generation pipeline, a fully case-sensitive language (Rust, Go) removes that specific risk category entirely - but does not remove the others, and trades away Nim's metaprogramming power (see metaprogramming.md) and Python-like readability in the process.

Glossary

  • Partial case/underscore-insensitivity: Nim's identifier-equality rule - case-sensitive on the first character only, case-insensitive (ASCII) and underscore-ignoring thereafter.
  • ARC: Automatic Reference Counting - Nim's deterministic, compile-time-hook-based memory management strategy; does not collect reference cycles on its own.
  • ORC: ARC plus a trial-deletion cycle collector; the default memory management mode since Nim 2.0.
  • refc: The deferred, tracing reference-counting garbage collector that was Nim's default throughout the 1.x line.
  • Defect: A subtype of Exception representing an unrecoverable programming error (out-of-bounds access, overflow, failed assertion); its catchability depends on the --panics:on|off compiler setting.
  • Option[T]: std/options' explicit "value or absence" type, an alternative to a bare nilable ref for API-level absence semantics.
  • CTFE: Compile-Time Function Execution - Nim's ability to run a restricted subset of ordinary procs during compilation, underlying const initializers and static[T] parameters.
  • concept: Nim's structural/duck-typed generic constraint mechanism, documented in the experimental manual and less stable than core generic constraints.

Maintenance note: When updating this document, also update lastupdated and re-verify every claim above against the current Nim manual, manualexperimental.html, and the Nim blog - this document's entire value proposition depends on its claims staying ahead of, not behind, the version an agent's training data assumes.