Complete reference for all built-in functions, traits, and types.
- Query Pushdown & Auto-Indexing
- Relation Operations
- Concurrency
- Text Operations
- Console I/O
- Control Flow
- File System
- Time
- Random
- JSON
- Bytes
- HTTP
- Cryptography
- Utility Functions
- Operator Behavior (Intrinsic)
- Built-in Types
- Operators
Many relation operations over a source relation (*name : [T]) don't load
rows into memory and process them in Knot — the compiler pushes them down to a
single SQL query against the underlying SQLite table, and the runtime
auto-indexes the columns involved.
What pushes down:
- Filters —
whereclauses andfilter (\r -> r.f OP x) rowsbecomeWHERE. - Joins — a read-only comprehension binding two or more sources and
relating them with an equi-join predicate plus single-table predicates
compiles to one multi-table
SELECT:→with {result (do e <- *employees d <- *departments where e.dept == d.name where e.salary > 75 yield {name e.name dept d.name})} yield resultSELECT t0."name", t1."name" FROM "_knot_employees" AS t0, "_knot_departments" AS t1 WHERE (t0."dept" = t1."name") AND (t0."salary" > ?)
- Aggregates —
count,countWhere,sum,avg,minOn,maxOnbecomeSELECT COUNT(*)/SUM(...)/MIN(...)/MAX(...) .... sortBy— becomesORDER BY.- Pure helper functions used in a predicate are inlined, so
where (salaryOf e) > 75still becomesWHERE salary > 75.
Auto-indexing. Columns referenced in a pushed-down WHERE or ORDER BY
clause — including both join columns of a multi-table join (employees.dept
and departments.name) — get a CREATE INDEX IF NOT EXISTS on first use.
ADT tables also index the _tag discriminator at creation. There is no
CREATE INDEX syntax and nothing to declare; the runtime observes the queries
and indexes accordingly.
Fallback. Any comprehension or operation the planner cannot translate falls back to reading the relation(s) and computing in memory (hash-join / nested-loop for joins). The results are identical — only the execution strategy differs — so you can write the natural comprehension and not worry about whether it pushed down.
filter : (a -> Bool) -> [a] -> [a]
Keep rows where the predicate returns True.
&seniors = *people |> filter (\p -> p.age > 65)
map : (a -> b) -> [a] -> [b]
Apply a function to each row. Results are deduplicated (relations are sets).
&names = *people |> map (\p -> {name p.name})
map is the Functor trait method for [].
match : Constructor -> [Constructor] -> [Payload]
Filter a relation to rows matching a constructor tag, extracting the payload.
&circles = *shapes |> match Circle -- : [{radius: Float 1}]
&rects = *shapes |> match Rect -- : [{width: Float 1, height: Float 1}]
fold : (b -> a -> b) -> b -> [a] -> b
Left fold over a relation. fold is the Foldable trait method for [].
totalAmount = \rel -> fold (\acc r -> acc + r.amount) 0 rel
single : [a] -> Maybe a
Extract the single element of a relation. Returns Just {value: x} for a singleton, Nothing {} for empty or multi-element relations.
single [{name "Alice"}] -- Just {value {name "Alice"}}
single [] -- Nothing {}
single [1 2] -- Nothing {}
count : [a] -> Int u
Return the number of rows in a relation.
numPeople = count *people
When the argument is a source relation (or its bound alias), the compiler emits a single SELECT COUNT(*) query. Pipe forms like *people |> filter (\p -> p.age > 30) |> count collapse into one SELECT COUNT(*) FROM ... WHERE ....
countWhere : (a -> Bool) -> [a] -> Int u
Count rows that satisfy a predicate. Equivalent to count . filter, but pushes down to a single SELECT COUNT(*) FROM ... WHERE pred when the predicate is SQL-compilable.
engHeadcount = do
employees <- *employees
yield (countWhere (\e -> e.dept == "Eng") employees)
sum : [a] -> a
Sum of a numeric relation. Takes the relation directly — there is no projection argument. To sum a field of a record relation, project first with map. Works with Int 1, Float 1, and unit-annotated types — units are preserved.
total = sum [10 20 30] -- 60
-- Sum a record field by projecting first:
totalAge = sum (map (\p -> p.age) *people)
-- Unit-preserving:
totalDistance = sum (map (\t -> t.distance) *trips) -- Float M if distance : Float M
avg : (a -> Float u) -> [a] -> Float u
Average of a projected numeric field over a relation. Returns Float 1. Preserves units from the projection function — if the projection returns Float M, the average is Float M.
minOn : (a -> b) -> [a] -> b
Minimum of a projected field over a relation. The projection can return any orderable type — Int 1, Float 1, or Text (lexicographic ordering). Panics if the relation is empty.
lowestSalary = do
employees <- *employees
yield (minOn (\e -> e.salary) employees)
firstName = do
employees <- *employees
yield (minOn (\e -> e.name) employees)
When applied to a source (or bound source variable), it pushes down to SELECT MIN(col) FROM .... Combined with filter it becomes SELECT MIN(col) FROM ... WHERE ....
maxOn : (a -> b) -> [a] -> b
Maximum of a projected field over a relation. Like minOn, works with any orderable type. Panics if the relation is empty. Pushes down to SELECT MAX(col) FROM ....
highestSalary = do
employees <- *employees
yield (maxOn (\e -> e.salary) employees)
min : a -> a -> a
max : a -> a -> a
Binary minimum and maximum of two values. Use minOn/maxOn to aggregate
over a relation; min/max operate on two single values.
min 3 7 -- 3
max "a" "b" -- "b"
union : [a] -> [a] -> [a]
Set union of two relations.
&all = union *employees *contractors
diff : [a] -> [a] -> [a]
Set difference — rows in the first relation but not the second.
&nonManagers = diff *employees *managers
inter : [a] -> [a] -> [a]
Set intersection — rows present in both relations.
head : [a] -> Maybe a
First row of a relation in iteration order, or Nothing {} if empty.
findFirst : [a] -> (a -> Bool) -> Maybe a
First row matching the predicate (left-to-right), or Nothing {} when no row matches. Stops at the first hit.
findFirst [1 2 3 4 5] (\x -> x > 3) -- Just {value 4}
any : (a -> Bool) -> [a] -> Bool
all : (a -> Bool) -> [a] -> Bool
any is True when some row matches; all is True only when every row matches (vacuously True on []).
elem : a -> [a] -> Bool
Membership check by structural equality.
sortBy : (a -> b) -> [a] -> [a]
Reorder rows by a projected key. The key type b must be Ord. Returns a new relation with rows in ascending key order. Sets have no inherent order; the result preserves the sorted order for downstream iteration (fold, map, forEach, etc.).
Pushes down to SQL ORDER BY when applied to a source relation. Combined with take it becomes ORDER BY ... LIMIT:
&topFive = do
employees <- *employees
yield (employees |> sortBy (\e -> -e.salary) |> take 5)
-- SQL: SELECT ... FROM _knot_employees ORDER BY -salary LIMIT 5
take : Int 1 -> [a] -> [a] -- Sequence.take
drop : Int 1 -> [a] -> [a] -- Sequence.drop
First / drop n rows. take/drop are built-in polymorphic functions that work on both [a] (rows) and Text (characters).
upsertBy : (a -> Bool) -> a -> [a] -> [a]
Replace every element matching the predicate with the supplied value. If no element matches, append the value. Useful for "insert or update" patterns on source relations.
-- Bump or insert a per-user counter
bump = \user counters -> upsertBy (\c -> c.user == user)
{user user count lookup user counters + 1}
counters
fork : IO {| r} a -> IO {| r} {}
Run an IO action on a new OS thread (fire-and-forget). The spawned action can
return any value a (it is discarded) and may have any effect row r. That
effect row propagates through fork to the caller, so a program that forks an
IO performing println is visibly typed with {console} in its IO row. Each
thread gets its own SQLite connection via WAL mode for safe concurrent access.
The main thread waits for all spawned threads before exiting.
main = do
fork do
println "hello from thread 1"
fork do
println "hello from thread 2"
println "hello from main"
Do blocks can be passed directly as arguments without parentheses.
race : IO {| r1} a -> IO {| r2} b -> IO {| r1 \/ r2} (Result a b)
Run two IO actions concurrently and return the winner. Each argument carries
its own effect row; the result IO's row is the union of both (written
r1 \/ r2). Effects required by either side flow into the result IO.
The winner is reported via the built-in Result a b ADT — Err {error: a} when the left action wins, Ok {value: b} when the right action wins.
slow = do
sleep (1000 : Int Ms)
yield "slow"
fast = do
sleep (50 : Int Ms)
yield "fast"
main = do
r <- race slow fast
case r of
Err {error: a} -> println ("left won: " ++ a)
Ok {value: b} -> println ("right won: " ++ b)
yield {}
Cancellation is cooperative but aggressive: the loser's knot_io_run checks its cancel token between every IO thunk, and sleep parks on a condvar that's signalled on cancel — so a loser stuck in a long sleep wakes immediately when the peer wins. The parent does not wait for the loser; it returns as soon as a winner is observed, and the loser unwinds at its next safe point (tracked for the final program-exit join).
race cannot be used inside atomic — its effects are not rollback-safe.
atomic : IO {} a -> IO {} a
Run an IO body in a database transaction. The body must contain only DB operations — no external effects (console, fs, etc.) are allowed. If the body calls retry, the transaction rolls back and waits for a relation change before re-executing.
transfer = \from to amount -> atomic do
accounts <- *accounts
*accounts = do
a <- accounts
yield (if a.name == from then {a | balance a.balance - amount}
else if a.name == to then {a | balance a.balance + amount}
else a)
retry : a
Used inside atomic blocks only. Causes the transaction to rollback and wait until some relation changes, then re-executes the atomic block. Implements STM (Software Transactional Memory) style concurrency.
waitForTask = \id -> atomic do
tasks <- *tasks
with {done do
t <- tasks
where t.id == id
where t.status == "done"
yield t}
(do
where (count done) == 0
retry
yield done)
The compiler enforces that retry is only used inside atomic.
Row-level wakeup filtering. The runtime tracks which rows the atomic block
actually read by inspecting WHERE/single (filter ...) patterns and the
predicates inside them (equality, inequality, ordered comparisons, and IN
sets). A parked retry is only woken when an UPDATE, DELETE, or INSERT touches
a matching row. So a worker retrying on WHERE id = 1 is not woken by writes
to id = 2, and a worker retrying on status IN ("queued", "running") is
unaffected by writes that leave the status outside that set. Bulk
replacements (*rel = ...) wake all watchers conservatively.
toUpper : Text -> Text
Convert text to uppercase.
toLower : Text -> Text
Convert text to lowercase.
length : Text -> Int 1
Return the number of characters (Unicode-aware).
trim : Text -> Text
Strip leading and trailing whitespace.
reverse : Text -> Text
Reverse text.
chars : Text -> [Text]
Split text into a relation of single characters.
take and drop are Sequence trait methods with built-in impls for both
Text (characters) and relations (rows):
take : Int 1 -> Text -> Text -- characters
take : Int 1 -> [a] -> [a] -- rows
drop : Int 1 -> Text -> Text
drop : Int 1 -> [a] -> [a]
take 3 "hello" -- "hel"
take 2 [10 20 30] -- [10, 20]
drop 3 "hello" -- "lo"
drop 1 [10 20 30] -- [20, 30]
contains : Text -> Text -> Bool
Check if the second argument contains the first as a substring.
has = contains "ell" "hello" -- True
println : a -> IO {console} {}
Print a value to stdout followed by a newline. putLine is an alias.
print : a -> IO {console} {}
Print a value to stdout without a trailing newline.
logInfo : a -> IO {console} {}
logWarn : a -> IO {console} {}
logError : a -> IO {console} {}
logDebug : a -> IO {console} {}
Leveled logging to stderr (so output does not mix with println on stdout).
When stderr is a TTY, output is colored; otherwise each record is written as
one JSON line for log aggregators. logDebug only emits when the program is
launched with --debug — debug records are dropped silently otherwise. (Every
compiled Knot program accepts --debug automatically; see
Runtime CLI.)
main = do
logInfo "starting"
logWarn {event "low memory" availableMb 64}
yield {}
show : a -> Text
Convert any value to its text representation. This is a pure function (no IO).
readLine : IO {console} Text
Read a line of input from stdin.
when : Bool -> IO r {} -> IO r {}
unless : Bool -> IO r {} -> IO r {}
Run an IO action conditionally. when cond a runs a if cond is True {}; unless cond a runs a if cond is False {}. The skipped branch becomes yield {}. The action's effect row r propagates to the result.
when (n > 0) (println "positive")
unless verbose do
println "(quiet mode)"
forEach : [a] -> (a -> IO r {}) -> IO r {}
Sequence an IO action over each row of a relation. Iteration follows the relation's deterministic order (after any sortBy).
forEach ["a" "b" "c"] (\s -> println s)
All file system functions return IO {fs} values.
readFile : Text -> IO {fs} Text
Read an entire file's contents as text.
writeFile : Text -> Text -> IO {fs} {}
Write text to a file (creates or overwrites). First argument is the path, second is the content.
appendFile : Text -> Text -> IO {fs} {}
Append text to a file.
fileExists : Text -> IO {fs} Bool
Check whether a file exists at the given path.
removeFile : Text -> IO {fs} {}
Delete a file.
listDir : Text -> IO {fs} [Text]
List directory entries as a relation of filenames.
main = do
files <- listDir "."
yield (filter (\f -> contains ".knot" f) files)
now : IO {clock} Int Ms
Return the current Unix timestamp in milliseconds. The result is tagged with the built-in Ms unit; use stripUnit if you need a plain Int 1.
sleep : Int Ms -> IO {clock} {}
Pause the current thread for the given number of milliseconds. Inside a race worker, sleep parks on the worker's cancel condvar and wakes immediately if the peer wins.
randomInt : Int u -> IO {random} Int u
Generate a random integer in the range [0, bound). Unit-polymorphic — the bound's unit is preserved in the result, so randomInt 100 Usd returns Int Usd.
randomFloat : IO {random} Float u
Generate a random float in the range [0.0, 1.0). Unit-polymorphic — the unit is inferred from context.
randomUuid : IO {random} Uuid
Generate a fresh UUID. The output is a RFC 9562 UUIDv7 — time-ordered, so values sort chronologically and are well-suited as primary keys.
main = do
u <- randomUuid
println u
yield {}
Uuid values are stored as TEXT in SQLite and compare by their canonical string representation.
toJson : a -> Text
Encode any value as a JSON string.
parseJson : Text -> Maybe a
Parse a JSON string into a value, returning Just value on success and Nothing on a parse failure. Objects become records, arrays become relations, strings become Text, numbers become Int 1 or Float 1, booleans become Bool, and null becomes Nothing {} (the Maybe wire convention). Decoding is type-directed where a target type can be inferred.
textToBytes : Text -> Bytes
Encode text as UTF-8 bytes.
bytesToText : Bytes -> Maybe Text
UTF-8 decode bytes to text. Returns Nothing {} on invalid UTF-8.
bytesLength : Bytes -> Int u
Return the byte length.
bytesToHex : Bytes -> Text
Encode bytes as a hexadecimal string. Always succeeds.
bytesFromHex : Text -> Maybe Bytes
Decode a hexadecimal string to bytes. Returns Nothing {} on odd-length, non-hex, or non-ASCII input. hexDecode is an alias.
bytesConcat : Bytes -> Bytes -> Bytes
Concatenate two byte strings.
bytesGet : Int u1 -> Bytes -> Int u2
Get the byte value (0–255) at the given index.
bytesSlice : Int u1 -> Int u2 -> Bytes -> Bytes
Extract a sub-range. Arguments: start index, length, bytes.
hash : a -> Bytes
BLAKE3 hash of any value, returned as 32 bytes. Bytes and Text hash their
raw contents; structured values (records, relations, constructors) hash a
canonical serialisation, so equal logical values always produce equal digests.
bytesToHex (hash "hello") -- "ea8f163..."
The HTTP types and primitives are defined in the language spec (DESIGN.md). The standard library exposes:
listen : Int u -> Server a r -> IO {network | r} {}
listenOn : Text -> Int u -> Server a r -> IO {network | r} {}
Start an HTTP server built with serve API where .... listen binds to all interfaces; listenOn takes an explicit bind address. The r row variable unifies with the server's effect row, so handler effects (e.g. console from a handler that calls println) flow into the program's IO type.
fetch : Text -> Endpoint -> IO {network} (Result HttpError T)
fetchWith : Text -> {headers: [{name: Text, value: Text}]}
-> Endpoint -> IO {network} (Result HttpError T)
Type-safe HTTP client built from route declarations. Endpoint is a route constructor; the response type T is inferred from the route. fetchWith lets you add ad-hoc headers on top of the route's declared ones. When the route declares response headers, the success body wraps as {body: T, headers: H} inside Ok.
Knot provides elliptic-curve cryptography built-ins using X25519 (encryption) and Ed25519 (signing).
generateKeyPair : IO {random} {privateKey: Bytes, publicKey: Bytes}
Generate an X25519 key pair for encryption/decryption. Inside a do block, bind with keys <- generateKeyPair.
generateSigningKeyPair : IO {random} {privateKey: Bytes, publicKey: Bytes}
Generate an Ed25519 key pair for signing/verification. Inside a do block, bind with keys <- generateSigningKeyPair.
encrypt : Bytes -> Bytes -> IO {random} Bytes
Encrypt plaintext bytes with a public key (sealed-box: X25519 ECDH + ChaCha20-Poly1305). First argument is the public key, second is the plaintext. Returns IO because a fresh ephemeral key pair and nonce are generated per call.
decrypt : Bytes -> Bytes -> Bytes
Decrypt ciphertext bytes with a private key. First argument is the private key, second is the ciphertext.
sign : Bytes -> Bytes -> Bytes
Sign a message with a private key (Ed25519). First argument is the private key, second is the message. Returns a 64-byte signature.
verify : Bytes -> Bytes -> Bytes -> Bool
Verify a signature. Arguments: public key, message, signature.
id : a -> a
Identity function — returns its argument unchanged.
not : Bool -> Bool
Boolean negation.
stripUnit : Int u -> Int 1
withUnit : Int 1 -> Int u
stripFloatUnit : Float u -> Float 1
withFloatUnit : Float 1 -> Float u
Drop or attach a unit tag. Identity at runtime — they only adjust the
compile-time type. Use them when you need to rebrand a value with a different
concrete unit (e.g. Ms → S).
strip : a u -> a 1
dress : a 1 -> a u
Generalized unit rebranding that works across both Int and Float with a
single call. strip drops a value's unit; dress attaches one to a
dimensionless value, the target pinned by context or annotation. The u is a
unit variable (kind Unit), so only unit-carrying numerics qualify. Identity
at runtime:
toS : Int Ms -> Int S
toS = \ms -> dress (strip ms / 1000)
toMiles : Float M -> Float Mi
toMiles = \d -> dress (strip d * 0.000621371)
A dimensionless Int 1/Float 1 does not unify with a concrete unit, so
dress is the explicit escape hatch for re-attaching one — an annotation alone
cannot rebrand a value already pinned to 1.
There is no user-facing trait system. You cannot declare a trait, write an
impl, or put a Num a => bound on a function — the parser rejects all of it.
The operators below are intrinsic: the compiler knows how to evaluate them
directly on the supported types, with no trait dictionary involved. This table
describes what each operator does and which types it works on.
Works on Int 1, Float 1, Text, Bool, and unit-annotated numerics.
Pushes down to = in SQL when used in a SQL-compilable comprehension.
Works on Int 1, Float 1, Text, and unit-annotated numerics. Ordering is
the LT {} / EQ {} / GT {} ADT (see base.compare). Pushes down to SQL
comparison operators.
Works on Int 1, Float 1, and unit-annotated numerics. Int 1 arithmetic is
checked and panics on overflow. Units compose algebraically (see Units of
Measure). +/- require matching units; *// combine
units. Pushes down to SQL arithmetic.
Int 1 remainder (sign follows the dividend) and Float 1 fmod. Modulo by
zero panics. Handled by intrinsic codegen — there is no user-overridable
operation. Pushes down to SQLite % in a SQL-compilable comprehension.
Works on Text (string append) and [a] (relation union). Pushes down to SQL
|| for text.
Short-circuiting AND/OR on Bool. Pushes down to SQL AND/OR.
x |> f is f x. Purely syntactic.
Functions like base.map, base.fold, base.traverse are ordinary
polymorphic functions over concrete types ([a], Maybe a, Result e a) —
not trait methods. They appear in this reference with plain a/b type
variables and no bounds.
| Type | Description |
|---|---|
Int 1 |
64-bit signed integer (i64); arithmetic is checked and panics on overflow |
Float 1 |
64-bit floating point |
Int u |
Integer with compile-time unit (e.g. Int Usd) |
Float u |
Float with compile-time unit (e.g. Float M, Float (M/S^2)) |
Text |
Unicode string |
Bool |
True {} or False {} |
Bytes |
Byte string |
Uuid |
RFC 9562 UUIDv7 identifier (TEXT in SQLite) |
[a] |
Relation (set of values of type a) |
IO {effects} a |
IO action with tracked effects |
Ordering |
LT {}, EQ {}, or GT {} |
Maybe a |
Nothing {} or Just {value: a} (supports do/<-) |
Result e a |
Err {error: e} or Ok {value: a} (supports do/<-) |
Compile-time units on Int and Float. Fully erased at runtime — no performance cost, no runtime representation. Every numeric type carries a unit; write Int 1 / Float 1 for the dimensionless case.
Units are not declared. Any name used in a unit position is a unit — there is nothing to declare since a unit has no body, only a name. Compound units are written inline as expressions.
distance = (42.0 : Float M) -- Float M
speed : Float (M / S)
force : Float (Kg * M / S^2)
cents : Int Usd
+/-require matching units*//compose units algebraically- Unary negation preserves units
- Scalar (dimensionless) multiplication preserves the other operand's unit
(10.0 : Float M) + (5.0 : Float M) -- Float M
(10.0 : Float M) + (5.0 : Float S) -- type error
(10.0 : Float M) * (5.0 : Float M) -- Float (M^2)
(100.0 : Float M) / (10.0 : Float S) -- Float (M/S)
2.0 * (5.0 : Float M) -- Float M
-((5.0 : Float M)) -- Float M
Concrete units are uppercase; lowercase names are unit variables:
double : Float u -> Float u
double = \x -> x + x
avg, minOn, and maxOn preserve units from their projection function; sum preserves the units of the numeric relation it sums:
avg (\t -> t.distance) *trips -- Float M if distance : Float M
sum (map (\t -> t.distance) *trips) -- Float M if distance : Float M
minOn (\t -> t.distance) *trips -- Float M if distance : Float M
maxOn (\t -> t.distance) *trips -- Float M if distance : Float M
| Operator | Works on | Behavior |
|---|---|---|
+ - * / |
Int 1, Float 1, unit-annotated |
Arithmetic (checked on Int 1) |
% |
Int 1, Float 1 |
Modulo / fmod |
unary - |
Int 1, Float 1, unit-annotated |
Negation |
== != |
Int 1, Float 1, Text, Bool |
Equality |
< > <= >= |
Int 1, Float 1, Text |
Ordering |
++ |
Text, [a] |
Concatenation / union |
&& || |
Bool |
Short-circuiting logic |
|> |
any | Pipe forward (x |> f = f x) |
All are intrinsic (no trait mechanism); see Operator Behavior (Intrinsic).
Every compiled Knot program accepts a common set of runtime flags and subcommands without any user wiring:
| Argument | Description |
|---|---|
--debug |
Enable logDebug output; without this flag, logDebug calls are dropped silently. |
--help |
Print usage including any compile-time overrides exposed by the program. |
--http-max-body-bytes=N |
Cap HTTP request and response bodies. Suffixes: K, M, G. Default 16M. Applies to both listen and fetch. |
--<name>=<value> |
Override a compile-time constant. The compiler exposes top-level constants annotated for override; see the program's own --help. The same flags may be set at build time via knot build … --<name>=<value>. |
<program> db |
Launch a terminal-UI database explorer over the program's <name>.db SQLite file. Browses every source relation, paginates rows, and lets you drill into individual records. |
<program> api <RouteName> |
Print an OpenAPI 3.0 JSON specification for the named route declaration. Useful for generating client SDKs or feeding into Swagger UI. |
The compiler itself (knot) supports:
| Command | Description |
|---|---|
knot build <file.knot> [-o <path>] [--<name>=<value> …] |
Compile to a native executable. Overrides supply compile-time constants. |
knot fmt [--check] [--stdout] <file.knot> … |
Format source files in place. --check exits non-zero when files are unformatted; --stdout prints to stdout instead of rewriting. |
knot help |
Show CLI usage. |