Language & qSQL

Amber is a terse array notation with a q vocabulary bolted firmly on top. If you have written q you already know most of it; the rules below are the handful of places where the host grammar shows through, and they are properties of the host, not bugs.

Dialect notes (read these first)

Five rules that will bite you exactly once each
  • Any two-argument function can be applied infix, right-to-left. Built-in verbs, library dyads and your own functions alike: f:{x+y}; 3 f 4 is 7, 2 3 in 2 4 5 works, and `sym xasc t sorts. The one exception is an inline lambda literal between two args: x {x+y} y does not go infix, so assign it first (f:{x+y}; x f y). Bracket form always works too: lj[t;kt], in[x;y], except[a;b], xasc[`sym;t].
  • <> >= <= work as in q after a name, a number or a closing bracket (since 2.8.0): x<>y, x>=y. With nothing on the left, < and > keep their k reading, so <>x is the grade of the grade down.
  • Symbols cannot contain _. `a_b is a parse error because _ is a verb (drop, and floor). Use a quoted symbol `"a_b", a dotted name (my.name) or camel case.
  • Nested lambdas are not closures. An inner {…} sees only its own parameters and globals, never the enclosing function's locals. Pass captured values in by projection: f[captured]'list. (qSQL in a lambda is the exception: since 2.8.0 {[c]select from t where s=c} sees c, as in q.)
  • .x after a name is dyadic apply; after a verb it is monadic "value". Prefer the value function (value x ≡ . x) to avoid the ambiguity.

The first of those rules and the qSQL-in-any-script change both arrived in 2.0.0; two point oh is the account of what they cost to add.

Recursion and tail calls. o is the function you’re in, so {$[x;o x-1;0]} calls itself. Since 2.4.0 a call whose result is the function’s result (the last thing it does, in either branch of a $[..]) reuses the frame, so that kind of recursion goes as deep as you like: {$[x;o x-1;0]}1000000 is fine, and so are two functions calling each other. Anything else still counts against a depth of 2048 ('stack), e.g. x*o x-1, which has to come back to multiply.

Types and nulls

k typelist / atomexamplenull
int`I / `i0 1 2, !5, 101b0N
float`F / `f1.5 2.50n
char`C / `c"abc", "x"" "
symbol`S / `s`a`b`c`
dict`m`a`b!1 2(none)
table`M+`a`b!(1 2;3 4)(none)

There’s no separate bool type: a b literal like 101b is an int vector, and comparisons give ints. @x returns the type symbol, and type x q’s type number (7 for an int list, since 2.8.0). !n gives 0..n-1, a compact range whose @ reads `I.

Integer width is real, not hidden

Amber stores an integer vector in the narrowest type that holds every element, so 1 2 3 is physically int8 and 1 2 3000000000 is int64, and both are "long vectors" as far as the language is concerned. Arrays handed to NumPy or Arrow carry the true dtype rather than being widened behind your back.

Scalar, aggregation and uniform functions

All live in amber.k. Monadic functions apply prefix (sum x); dyadic ones use brackets (wavg[x;y]).

/ scalar (element-wise)
neg not null reciprocal sqrt floor ceiling signum abs exp log sin cos
til enlist string type key value first last reverse distinct group where
flip count mod div xbar xlog round

/ aggregation (vector -> atom)
sum prd min max avg med var dev svar sdev cov scov cor wsum wavg all any

/ uniform (vector -> vector of the same length)
sums prds mins maxs deltas ratios differ prev next

/ ordering and set operations
rank iasc idesc asc desc xrank rotate xprev in except inter union raze sublist cross

/ grouped aggregation in one pass (2.1) -- keys!values, first-appearance key order like =k
gsum gavg gmin gmax gcount gfirst glast

Fused idioms (since 2.1)

The compiler recognises a few expression shapes and compiles each to one fused primitive instead of a chain that materialises intermediates. The result is identical to the unfused expression in every case; \disasm shows the fused call.

+/x*y  +/x=y  +/x<y  +/x>y    / sum of products / count of matches: no product or mask vector
+/x@&m                       / sum of the elements selected by a mask: no compressed vector
x@&m                         / compress by a 0/1 mask: no index vector
a+s*b  a-s*b   (s a literal)  / one pass, no s*b vector
x@<x   x@>x                   / sort by value: a counting sort for integral-valued data, else grade+index

gsum[sym;px]                  / one pass: acc[group]+=px  (select sum px by sym from t takes the same path)
h in kr                       / one pass, one byte per element (`memb underneath)

Fused trees (since 2.6)

Inside a lambda, a whole tree of + - * % & | over float or int vectors and numbers (with < > = at the top, and +/ on top of that) runs as one pass in cache-sized blocks, split over the threads, once the vectors have 2048 items or more. Same bits as the unfused code.

f:{[a;b;c]+/(a*b)-c%2.0}      / one pass, no a*b or c%2.0 vectors

The two everyone gets backwards

amber>
wavg[sz; px]        / WEIGHTS FIRST. wavg[px;sz] is a plausible wrong number.
xbar[w; x]          / bucket width first: xbar[5;0 1 2 3 4 5 6 7] -> 0 0 0 0 0 5 5 5

Tacit trains: hooks and forks

A parenthesised, semicolon-separated list whose every element is a function is applied as a train (since 2.0.1): (f;g) is a hook, (f;g;h) a fork. It lets you write a whole class of little functions with no argument name at all.

amber>
avg:(+/;%;#)        / fork: mean = sum % count
avg 2 4 6 8 10      / 6.0
(|/;-;&/) 5 2 9 1    / 8            range = max - min
(%;+/) 3 1 4 1 5    / normalise to sum 1
(,;|) 1 2 3         / 1 2 3 3 2 1  hook: a vector then its reverse
(+;*;-)[10;3]       / 91           dyadic fork: (10+3)*(10-3)

A space-separated group (f g) is ordinary composition f(g x), not a train. The semicolon is the switch. See the full Tacit programming guide for hooks, forks, dyadic trains and the exact rules.

Dictionaries, tables and keyed tables

amber>
d:`a`b`c!1 2 3                    / dictionary
d`b                               / 2
key d                             / `a`b`c
value d                           / 1 2 3

t:([]sym:`AAPL`MSFT`AAPL; px:187.3 411.2 187.4; sz:100 250 50)
count t                           / 3
cols t                            / `sym`px`sz
meta t                            / c | t f a -- column, type, foreign key, attribute
t`px                              / 187.3 411.2 187.4  (a column IS a vector)
t 0                               / the first row, as a dictionary

kt:([sym:`AAPL`MSFT] lot:100 10)  / keyed table
keys kt                           / `sym
xkey[`sym; t]                     / key an unkeyed table
unkey kt                          / and back

xcols[`px`sym`sz; t]              / reorder columns
xasc[`sym; t]                     / sort ascending by a column (sets the `s attribute)
insert[`t; (`GOOG; 141.2; 300)]   / append a row

A bare table at the interactive prompt auto-renders as a grid. Big tables print Q-style, the first CROWS rows (default 20) then .., and the cap is applied before formatting, so previewing a million-row table is instant.

qSQL: select, exec, update, delete

At the prompt, type the query. Bare column names like wavg[sz;px] just work. How this layer was put on top of a K interpreter, tables, the join family and a parser that accepts bare qSQL, is in teaching K to speak q.

amber>
gentq 100000                               / sets the globals: trades, quotes
select from trades where px>150
select sym, px from trades where in[sym;`AAPL`MSFT]   / brackets (infix sym in `AAPL`MSFT works too)
select last px by sym from trades
select vwap:wavg[sz;px], n:#px by sym from trades where px>0
select vwap:wavg[sz;px] by time:minbar[1;time] from trades

select[5] from trades                      / the first 5 rows   (select[-5] -> the last 5)
select[>px] from trades                    / sorted by px descending  (<px ascending)
select[5;>px] from trades                  / sorted, THEN limited -- q's clause order
select[<sym;>px] from trades               / several keys: the first listed is primary
select[3;>v] v:sum px by sym from trades   / on a grouped result too

exec px from trades where sym=`AAPL        / one column -> a plain vector, not a table
exec px,sz from trades where sym=`AAPL     / several columns -> a `px`sz dict of vectors
update px:px*1.01 from trades where sym=`AAPL
delete from trades where sz<100

Nothing is loaded for you. ./amber script.k runs the file with no standard library, so neither the bare form nor sel"…" exists there. Load amber.k, std.k and qsql.k first, which is exactly what the ./a launcher and the REPL do. Once they are loaded, a file pulled in with \l gets the same treatment as a typed line: the loader runs its source through the rewriter, so bare qSQL works in a script with no wrapper. The string and functional forms below need the library too, since they are ordinary definitions in qsql.k.

script.k
\l amber.k
\l std.k
\l qsql.k

t:([]sym:`AAPL`MSFT`AAPL; px:187.3 411.2 187.4; sz:100 250 50; ex:`N`Q`Q)

sel"select vwap:wavg[sz;px] by sym from t"          / string form; also exq"…" upd"…" del"…"

qwhere[t; t[`px]>200]                               / filter: table + boolean mask
qselect[t; (,`v)!,{x[`px]*x`sz}]                    / project: name -> function
qby[t; `sym; (,`vwap)!,{wavg[x`sz;x`px]}]           / group + aggregate
xgroup[`sym; t]                                     / group into a keyed table of lists
ungroup xgroup[`sym; t]                             / and flatten it back

/ fby: an aggregate broadcast back over the rows of its group, as in q.
/ Usable INFIX, including inside a where-clause:
sel"select from t where px=(max;px) fby sym"        / each sym's top-priced rows
sel"select sym, m:(max;px) fby sym from t"          / ... or as a column
fby[(sum;t`sz); t`sym]                              / functional form
fby[(sum;t`sz); (t`sym;t`ex)]                       / grouped on several columns
fby[(sum;t`sz); ([]s:t`sym;e:t`ex)]                 / the same thing, as a table
qexec[t; t[`px]>100; ,`sym; (,`n)!,{#x`px}]         / where + by + aggregate, in one call
A grouped aggregate, spelled out

qby[t; keys; aggs] takes a table, a symbol (or symbol vector) of grouping columns, and a dictionary mapping output names to functions. Each function receives the sub-table for one group, so {wavg[x`sz;x`px]} reads two columns of that group and returns one number.

Joins

VerbWhat it does
lj[t;kt]left join a table against a keyed table
ij[t;kt]inner join: rows present in both
uj[x;y]union join: the union of columns and rows
pj[t;kt]plus join: numeric columns are added rather than replaced
ej[c;x;y]equi join on the named columns
aj[c;x;y] · aj0[c;x;y]as-of: for each left row, the most recent right row at or before it
wj[w;c;x;(y;a..)]window: aggregate right rows inside a window around each left row, plus the one in force when it opens (as q)
wj1[w;c;x;(y;a..)]The same, only the rows inside the window
asof[x;y]the scalar as-of lookup

As-of join

The join every tick shop needs, and the one Amber implements natively in C: aj matches each trade to its most recent quote with a branch-free lower_bound binary search over each symbol group's sorted nanosecond timestamp slice. The pure-K reference (ajmK) is kept alongside it.

amber>
trade:([]sym:`a`b`a; time:3 4 9; px:100 200 300)
quote:([]sym:`a`a`b`a; time:1 5 2 8; bid:10 11 20 12)

aj[`sym`time; trade; quote]
/  ╭─────┬──────────────┬─────┬─────╮   (a `time` column prints as a time-of-day)
/  │ sym │         time │  px │ bid │
/  ├─────┼──────────────┼─────┼─────┤
/  │ a   │ 00:00:00.003 │ 100 │  10 │
/  │ b   │ 00:00:00.004 │ 200 │  20 │
/  │ a   │ 00:00:00.009 │ 300 │  12 │
/  ╰─────┴──────────────┴─────┴─────╯

m:aj[`sym`time; trades; quotes]     / 5M trades against 10M quotes: ~704 ms
Layout is what makes it fast

The kernel needs rows sorted by (sym, time) with the `s attribute stamped on time, so it binary-searches instead of scanning. xasc sets the attribute for you; meta shows it in the a column. Correct on 64-bit ns timestamps, empty groups, and no-match rows (which become null).

Window join

amber>
/ aggregate quotes inside [-1s, +1s] around every trade
gentq 100000                / sets the globals: trades, quotes
w:(-1000000000 1000000000)+\:trades`time
/ as q: (quotes; (function; column)..); an aggregate may also name its column, (name; function; column)
wj[w; `sym`time; trades; (quotes; (`mbid;max;`bid); (`mask;min;`ask))]

See examples/wj.k for a full walkthrough.

Attributes: the C-level change

All four q-style attributes are implemented in C, and since 2.7 you set them the q way: `s#x, `u#x, `p#x, `g#x, `#x to take one off, and attr x to read it. They behave the way kdb+ 4.1 does (checked case by case against q.exe): every list can carry one, an atom is 'type, `s#t wants the table in row order, and a dict made `s looks up by steps: (`s#1 3 5!`a`b`c) 4 is `b. The one difference: an unparted `p# is 'p-fail (q says 'u-fail). `at also reads them; meta shows them; every ascending value sort (asc, x@<x, `srt, xasc) returns its result flagged `s (since 2.1), so a later ?, in, bin or aj on it takes the O(log n) path without an explicit `sa.

AttributeSet withEffect
`s sorted`s#v · asc · x@<x · xasc? (find) becomes a kernel binary search, O(log n)
`u unique`u#vasserts distinctness; lookups skip duplicate handling
`p parted`p#vequal values are contiguous; O(log n) group slicing
`g grouped`g#va group index, giving O(1) per-symbol slicing
amber>
v:asc 2000000?1000000000      / asc sets `s
attr v                        / `s
v ? 12345 67890               / O(log n) — see bench.k
rowslinear scanbinary (`s)speedup
100 k87 ms0.6 ms141×
500 k417 ms0.9 ms470×
2 M1.73 s1.4 ms1244×
5 M4.23 s1.9 ms2261×

Results are identical; only the time differs.

Strings

lower upper ltrim rtrim trim ss ssr sv vs like lk1
amber>
ssr["abcabc"; "b"; "X"]      / "aXcaXc"
vs[","; "a,b,c"]             / ("a";"b";"c")
sv[","; ("a";"b";"c")]       / "a,b,c"
like["AAPL"; "AA*"]          / 1b

On disk: splayed and partitioned tables (since 2.7)

Binary column files laid out the way q lays out a database. A numeric column is one mmap, so a 10M-row table loads in about 6 ms and its pages come in as a query reads them. Symbols live in the database's sym file, writes go through a temp file and a rename, and changing a loaded vector never touches the disk.

amber>
`:db/t/ set t                                  / splay t: one file per column, db/t/.d, db/sym
t2:get `:db/t                                  / mapped back
Q.dpft[`:hdb;2026.01.02;`sym;`trade]           / one day of trade, sorted by sym with `p on it
\l hdb                                         / map a date-partitioned database
select sum sz by sym from trade where date=2026.01.02

A select on a partitioned table keeps only the partitions its where-clause allows on the partition column and maps only the columns it names. Grouped by date, it runs once per partition, so select sum sz by date from trade over 10 days of 1M rows takes about 6 ms. update/delete on one is 'par, as in q.

Since 2.9.1 a write survives a crash: a table goes to disk whole or not at all, even if the process is killed or the power goes halfway through. Files are synced before they're renamed in, a new or rewritten table is built under a hidden name and swapped in, and two processes adding symbols to one database take turns on its sym file. AMBER_FSYNC=0 skips the syncs, for scratch data you'd rather write fast.

Binary serialization: -8! and -9!

-8!x gives q's bytes for any value and -9!y reads them back, the same bytes q sends over a socket (since 2.9.0, see talking to q). Amber's own compact format, the one peach and column files use, is `ser / `des. How it started is in from language to platform.

amber>
b:-8!+`a`b!(1 2 3;4 5 6)        / table -> q's bytes
(-9!b)~+`a`b!(1 2 3;4 5 6)      / 1b — exact

Parallelism: peach

peach[f;y] is real multi-core: it runs on a pool of AMBER_THREADS threads (default the online CPU count), so heavy per-item work scales across cores with no GIL. The result is identical to f'y.

amber>
peach[{avg x?1.0}; 8#1000000]         / 8 heavy tasks across the pool
Where peach earns its keep

Handing out tasks costs more than a cheap reduction. peach pays off on per-task-heavy work, not on sum. examples/peach.k shows the crossover.

Threads inside primitives (since 2.6)

You don't ask for these. Once a vector is big, sort and grade, find and in, the aj match, gather, distinct, group aggregates, float sum/dot/max/min and the fused trees above split over the same AMBER_THREADS. Small vectors never touch the pool, and AMBER_THREADS=1 turns it off.

One thing to know

Float sums of 1M+ items add in fixed 64K blocks, so the last digits can differ from a plain left-to-right sum (and from 2.5). They're the same at any thread count.

Terminal charts and grid modes

amber>
plot (14*{sin x%7}'!74;60;9)                            / Braille line chart
plot (a;b;c)                                            / several series: one labelled line each
plot exec px,ask from trades taq quotes where sym=`AAPL / a line per column, straight from a query
xyplot (trades`time; trades`px)                         / a time-of-day x axis -> HH:MM:SS ticks
candle bars[10; select from trades where sym=`AAPL]     / Unicode candlesticks, clock-labelled

tplot (trades`time; trades`px)                          / x axis read as a time: 09:30:00
dplot (days; values)                                    / ... as a date: 2026.09.18
chart `x`y`xunit!(t;v;`stamp)                           / ... as an ns timestamp
chart `y`yunit!(durations;`time)                        / a time on the Y axis

\grid clean | rounded | sharp | heavy                    / table frame style
CROWS:10                                                 / preview height
COLOR:0                                                  / disable ANSI highlighting
PREC:4                                                   / float precision
Why an axis unit has to be stated

A plain int or float vector carries no unit, only numbers: a column of milliseconds and a column of prices look the same to a plot. xunit/yunit take `num `time `sec `date `stamp or `auto. `auto claims a narrow range on purpose: only whole numbers at or above 1e15 (ns timestamps) and whole numbers inside one day and at least an hour in (ms of day), because guessing wrong turns an ordinary index into a clock. Ticks land on round clock and calendar boundaries, and the tick count comes from how wide that unit's labels are, so they never overlap.

REPL diagnostics

CommandWhat it prints
\va workspace inspector. Every global as a table of Name / Type / Shape / Memory, with a recursive deep-footprint walker so nested and table sizes are real
\ast expra colour-coded parse tree; nothing is executed. Leaves carry their literal type; tacit forms get explicit Hook/Fork/Projection labels
\disasm exprlocals, constant pool and instruction stream of the real compiled bytecode, without executing it
\trace expra 4-phase profiler (parse → arena → execute → format) with a bar chart and the arena's true high-water mark
\disasm (1+2)*3-4
locals (0):
constants (2):
  #0  -1
  #1  3
bytecode (6 bytes):
    0  MONAD    1
    1  CONST    #0
    2  CONSTDYAD const#1 dyad=3
    5  MONAD    0

Amber's real compiler constant-folds 1+2 and 3-4, so the constant pool holds 3 and -1 rather than the original literals. That is the kind of detail a disassembler for the real VM surfaces that a from-scratch reimplementation would not.

Error diagnostics

An error prints as a Rust-compiler-style report. AMBER_DIAG=0 gives the terse k form instead, the message and a ^ caret under the failing token:

error[E0104]: Vector length mismatch
  --> test.k:12:8
   |
12 |   prices + sizes
   |   ^^^^^^   ^^^^^
   |
   = help: Both vectors must have matching lengths for element-wise `+`.

An error a trap catches (.[f;args;handler], @[f;x;handler]) prints nothing, as in q (since 2.9.0). `diag switches the report at run time:

amber>
.[{1+`a};,0;{"caught"}]         / "caught", and nothing printed
prev:`diag 0                    / the terse k form from here on; gives the previous setting
`diag prev                      / and back

Finance / HFT module (fin.k)

Auto-loaded after amber.k.

amber>
gentq 100000                       / generate a session: sets globals `trades` and `quotes`
genopt 2000                        / random option chain into the global `options`
m:aj[`sym`time; trades; quotes]    / TAQ: prevailing quote for every trade
tsign m                            / Lee-Ready trade sign (+1 buy / -1 sell)
effspread m                        / effective spread = 2|px-mid|
qby[trades;`sym; `vwap!enlist {wavg[x`sz;x`px]}]
bars[1; trades]                    / 1-minute OHLCV bars
GroupFunctions
bookmid spread spreadbps micro imbal
tradesvwap twap tsign signedvol effspread notional
returns / volret logret rvol movavg movsum movmax movmin ema rollstd
aggregationbars symstats
indexbysym symrows gidx (O(1) per-symbol slicing)
generatorsgenopt gentq

Extended modules

Loaded automatically by repl.k after amber.k / fin.k.

ModuleAdds
std.kvectorised moving aggregates (mcount msum mavg mprd mvar mdev mmin mmax, O(n) prefix sums), dot / mmu, parse/eval/reval, a text ser/deser round-trip, protect (like .Q.trp), typed casts, peach, ts
sys.k.z clocks and handlers, .Q utilities, .j JSON (j.j/j.k), a minimal .h HTML renderer, plot/candle
hdb.kon-disk data, binary and memory-mapped since 2.7: set/get (`:db/t/ set t splays), dset/dget, splay/dload, Q.en/Q.dpft, loaddb and \l db for partitioned databases, ptab, and the older partsave/partload/parts. Text files from before 2.7 still read
ipc.kan in-process tickerplant (u.def u.sub u.pub u.get u.end). q's IPC (hopen, h x, neg[h], \p) is in the engine since 2.9.0: talking to q
temporal.kthe native date / time / timestamp layer. See Temporal mechanics

Built-in help

amber>
\               / the menu: every page, one key each
\0 \+ \' \: \`  / the language: types, verbs, adverbs, files, built-in names
\h              / the whole language on one screen
\q \j \z \m     / the q layer: functions, tables and qSQL, dates and times, finance
\o \i           / on disk, and talking to q
\g \r \e        / charts, speed, errors
\k              / every REPL command and editing key