Standard Library Tour

Nim's standard library is small on purpose: it covers strings, containers, files, processes, JSON, time, and the async runtime, and leaves everything else to Nimble packages. The skill worth building is navigation — knowing which module owns a task, and reading its documentation in the compiler's own output.

How the Library Is Organized

Everything user-facing is imported with an explicit std/ path, and several modules can be imported with one statement. The system module is the only thing you get without asking: echo, len, add, repr and the basic types all come from it.

Import Paths and Grouping

# Bracket imports keep the preamble short and make the dependency list obvious.
import std/[strutils, sequtils, tables, options]

# Individual imports are clearer when a module is used in only one place.
import std/times

# The 'system' module is implicit: these need no import at all.
echo "echo, len, @[] and add come from system".len      # 43

let words = "one two three".splitWhitespace()            # strutils
echo words.len                                           # 3
echo words.mapIt(it.len).sum()                           # 11 — sequtils + system

The std/ prefix exists because the library was reorganized: modules that reach outside the language (OS, files, sockets) are separated from those that are portable, and a few helpers that used to live in system — typedthreads, sysatomics, assertions — can be imported individually in slim-system builds. When a snippet you find online imports a name without std/, that is either an old version or a Nimble package shadowing it.

Strings and Sequences

Two modules do the daily work: strutils for text and sequtils for containers. sequtils is built from templates, so mapIt, filterIt and foldl expand inline at the call site — no closure is allocated and the loop disappears into the surrounding code.

import std/[strutils, sequtils, algorithm]

let raw = "  Ada, Grace, Alan, Edsger  "

# strutils: cleaning and querying text without regular expressions.
let names = raw.strip().split(',').mapIt(it.strip())
echo names                      # @["Ada", "Grace", "Alan", "Edsger"]
echo names.join(" / ")          # Ada / Grace / Alan / Edsger
echo "grace".capitalize()       # Grace

# sequtils: transform, filter and reduce with 'it' as the implicit element.
let lengths = names.mapIt(it.len)              # @[3, 5, 4, 6]
echo lengths.sum()                             # 18
echo names.filterIt(it.startsWith("A"))        # @["Ada", "Alan"]
echo lengths.foldl(a + b, 0)                   # 18 — explicit initial accumulator

# algorithm sorts in place; the comparison is a function you supply.
var sorted = names
sorted.sort(proc (a, b: string): int = cmp(a, b))
echo sorted                     # @["Ada", "Alan", "Edsger", "Grace"]

Reach for strutils before writing a loop over characters, and prefer mapIt/filterIt over hand-written accumulator loops — both are shorter and compile to the same code.

Files, Processes and Data

The next group of modules touches the outside world: the file system, child processes, and the formats programs exchange with each other.

Paths, Files and Processes

import std/[os, osproc, strutils]

let root = getCurrentDir()                  # the working directory as a string
let config = root / "config" / "app.txt"    # '/' joins paths portably
echo config.splitPath().head                # .../config — the parent directory
echo config.splitFile().ext                 # .txt — extension, dot included

if dirExists("build"):
  for entry in walkDir("build", relative = true):
    # walkDir yields tuples of (kind: PathComponent, path: string).
    if entry.kind == pcFile and entry.path.endsWith(".nim"):
      echo "source: ", entry.path
else:
  createDir("build")            # creates the parents it needs, like mkdir -p

# Environment variables go through 'os' as well; the second argument is the
# default used when the variable is not set.
echo getEnv("HOME", "unknown")

# Run a child process and capture both streams. 'execCmdEx' does not raise on a
# non-zero exit status: it reports the status in the returned tuple instead.
let (output, status) = execCmdEx("nim --version")
echo status                     # 0 when the command succeeded
echo output.splitLines()[0]     # the first line of its output

JSON, Options and Tables

Configuration and API payloads arrive as JSON, and the answer to "this field may be absent" is std/options, which models absence as a value instead of as an exception.

import std/[json, options, tables]

# The '%*' macro builds a JSON tree from Nim syntax: object literals become JSON
# objects, sequences become arrays, and values convert with 'toJson'.
let document = %*{
  "name": "nim",
  "released": 2008,
  "tags": ["compiled", "macros"],
  "lead": nil
}
let parsed = parseJson($document)           # round trip: text back into a tree

# '[]' raises KeyError when a key is absent; '{}' returns a JSON null instead,
# which is what you want when a missing key is normal.
echo parsed["name"].getStr()                # nim
echo parsed{"released"}.getInt()            # 2008
echo parsed{"missing"}.getStr("n/a")        # n/a — no exception
echo parsed{"lead"}.kind                    # JNull
echo parsed["tags"][2].getStr()             # macros

# 'to' converts a tree into a native object, checking names and types. A
# mismatch raises JsonConversionError rather than producing a half-filled value.
type Project = object
  name: string
  released: int
let project = parsed.to(Project)
echo project.name, " ", project.released    # nim 2008

Two more Option rules prevent the classic mistakes. get() with no argument "returns the underlying value, or raises UnpackDefect", and UnpackDefect "inherits from system.Defect and should therefore never be caught" — so check with isSome/isNone or pass a fallback to get instead of catching anything. And the combinators keep option-returning code flat: map transforms a present value, filter drops it when a predicate fails, flatMap chains calls that themselves return an Option, and flatten removes one level of nesting from Option[Option[T]].

import std/options

let present = some(42)
let absent = none(int)

echo present.mapIt(it * 2)              # some(84)
echo absent.mapIt(it * 2)               # none(int) — the mapping never runs
echo present.filterIt(it mod 2 == 0)    # some(42)

# 'mgetOrPut' inserts a default and returns a var, the shortest way to count.
var counts = initTable[string, int]()
for tag in ["a", "b", "a", "a"]:
  counts.mgetOrPut(tag, 0) += 1
echo counts["a"]                        # 3
echo counts.getOrDefault("missing", -1) # -1

# 'pairs' iterates key and value together.
for tag, count in counts.pairs:
  echo tag, ": ", count                 # a: 3 / b: 1, in table order

Time, Random and Math

import std/[times, random, math]

let start = now()                       # local time with its timezone attached
echo start.year, "-", start.month       # e.g. 2026-9
echo start.format("yyyy-MM-dd HH:mm")   # explicit, unambiguous formatting

let later = start + initDuration(seconds = 90)
echo (later - start).inSeconds          # 90 — a Duration, not a raw float

randomize()                             # seed from the OS: never omit this
echo rand(1 .. 6)                       # a die roll in a closed range
echo sample(["red", "green", "blue"])   # one element, uniformly chosen

echo sqrt(2.0)                          # 1.414213562373095
echo pow(2.0, 10.0)                     # 1024.0
echo round(PI, 3)                       # 3.142 — precision given explicitly
echo floor(-1.5), " ", ceil(-1.5)       # -2.0 -1.0: rounding is explicit

Name the unit in the type, not in a comment: Duration and Time prevent the class of bug where a millisecond count is passed where seconds were expected. And never omit randomize() in a program you intend to run more than once — without a seed from the operating system, every run produces the same sequence.

The standard library is large enough that the useful skill is not memorising it, but knowing where to look and when a module is not needed. Most of what you need is already compiled in; the rest is one import away.

What Lives Where

TaskModuleStarting point
Text handlingstrutils, unicodesplit, strip, align, runeLen
Formattingstrformatfmt"...", &"{x:>6}"
Collectionstables, sets, deques, heapqueueinitTable, incl, addFirst, push
Missing valuesoptionssome, none, mapIt, get(default)
Files and processesos, osproc, tempfileswalkDir, execCmdEx, createTempDir
Data formatsjson, parsexml, parsecsvparseJson, %*, to
Timetimesnow, Duration, format
Randomnessrandomrandomize, rand, sample
Concurrencythreads, locks, atomics, asyncdispatchcreateThread, Channel, Future
Testingunittestsuite, test, check, expect

Reading the Documentation Offline

The compiler can generate the same documentation locally, which is faster than a web search and always matches the version you compile with.

nim doc --project --outdir:docs src/app.nim   # documentation for your own module
nim doc --index:on src/app.nim                # build a searchable index page
nim --version                                 # the compiler version that decides the docs

When an import resolves to an unexpected file, nim dump prints the search paths the compiler is using and nimble dump the resolved dependencies — together they explain almost every "wrong version" problem.

Keep the standard library index and the language manual bookmarked, and prefer a module from the standard library over a dependency for anything that fits in a few hundred lines: a dependency must be tracked, updated and trusted, while a standard module is compiled into your binary at no maintenance cost.