Tables & fields
f.* covers the full column catalog — scalars, f.timestamp, the four file resources, the
six f.geo.* types, f.enum(values), f.vector(size), f.object(children). A json column
can declare the shape stored inside it with f.json({ children: [{ name, type }] }) — an
ordered array, unlike the named map f.object takes, because the engine persists json
children in the order given. Omit it for an unstructured json column. Foreign keys
are f.tableRef(table), whose link resolves to the target table’s guid at export — a
target that isn’t registered on the same workspace fails there, naming the table and the
schema field, rather than during the import it would otherwise break. Any
scalar becomes a list column with { array: true }, surfacing as string[] in
InferRow. Tables take a named-map schema, filter methods carry args ("min:8"), and
views[] encode through the shared comparison encoder.
- A column
defaultmust stay within the BMP. A 4-byte character (codepoint > U+FFFF, e.g. an emoji) is mangled into invalid UTF-8 by the engine’s default pipeline, so it is rejected at export rather than 500ing at deploy with Postgres22021. Accents,€, and most CJK are fine; otherwise put the value on an endpoint input, applied at runtime bind. idandcreated_atauto-inject at the head of the schema unlesssystem: falseor you declare them (idType: "uuid"for a uuid key). Both are usable wherever a column name is expected and both appear inInferRow. The standard indexes —primary(id),btree(created_at desc), plusgin(xdo)when the table stores fields as JSON — auto-prepend, de-duped against your own. Declare yours as{ type, fields: [{ name, op? }] };"unique"is shorthand for"btree|unique".use_xdopicks the storage mode — every field as JSON under the internalxdocolumn, or a real Postgres column per field. It is a workspace setting (defaultfalse) each table mirrors, overridable per table withtable({ useXdo }), resolved atexport()so the two can register in any order.
Statements, values & inputs
Thestack of a function/query/tool is a list of statements, all reachable through one
discoverable, typed namespace — s:
s. to explore. Each declarative statement takes one typed args object;
control-flow specials (s.set_var, s.conditional, s.for, s.foreach, s.while,
s.group, s.switch, s.try_catch, s.return, …) keep their authored signatures. Every
statement also carries description and disabled — inline on the object-arg factories,
a trailing options object on the positional specials. disabled: true is Xano’s
commented-out state: the step stays in the stack and the engine skips it.
Filter a statement’s result as it binds. Any statement with an as also takes
asFilters — the editor’s return as <var> | upper — applied in order, from the same
fl.* catalog as value filters:
s.set_var for the common “bind it in a different shape” case. A
statement that binds nothing does not offer the option.
The chain retypes the value. InferResponse folds each filter’s declared result, so a
filtered binding reports what it actually holds rather than unknown:
any — get, set, transform, json_decode —
fold to unknown, since no declaration could name their shape. Note this models a filter’s
OUTPUT, not its input: a filter applied to a value it cannot accept returns null at
runtime rather than erroring, and still types as its declared result.
Fields with a fixed set of values take a bare literal. Where the engine accepts only
certain spellings, the field’s type is that set, so autocomplete offers them and a typo is a
compile error rather than a runtime failure after deploy:
"stream" and c.text("stream") encode identically — use whichever reads better. A value
the SDK can’t evaluate (an inp/ref, or anything with a filter chain) is never checked,
so a computed field stays authorable.
One field is resolved at encode time and so is narrower than the rest: s.db.query’s
returnType picks which context.return block gets written, which has to happen before
there is a runtime. It takes the bare literal only — a tagged value is a compile error, and a
dynamic one (inp/ref, or any filter chain) throws rather than deferring. A spelling
outside the set throws too, in place of quietly falling back to list.
The db family. Single-record reads and mutations match one field
({ fieldName, fieldValue }, defaulting to id) — there is no composite (a, b) form; for
a two-column lookup use s.db.query with a where array. Writes take a partial row: {…},
and an s.db.edit writes only the columns you list, leaving every unmentioned column at
its stored value. A cell takes a tagged value or a bare JS literal typed against its
column — row: { is_hidden: true, notes: "…" } is exactly { is_hidden: c.bool(true), notes: c.text("…") }. The tag comes from the column, so the literal is checked against it:
a string on an f.bool() column is a compile error, and an f.enum() column keeps its
member union. Only s.db.query takes a where, and its where/sort/paging/output
are applied by the engine, not in your stack.
Bulk writes: bulk.update REPLACES the row, bulk.patch doesn’t. s.db.bulk.update
writes every column an item omits to its zero value (""/0/null), with an HTTP 200 and
no error — { id: 7, status: "done" } blanks the rest of row 7. s.db.bulk.patch writes
only the keys each item carries, which is what “update these rows” almost always means.
export() warns when a static items array omits columns of the bound table (and
--strict fails on it), but an items built from a ref/inp can’t be inspected. Related:
s.db.bulk.delete with no where is a truncate, so it refuses to encode unless you say
allRows: true.
What each op binds decides your response type: s.db.get binds null on a miss (it does
not throw — null-check it), s.db.add/edit/patch bind the full written row including
auto-assigned id/created_at, s.db.del binds null, and edit/del throw
NotFound (404) when nothing matches. InferResponse derives all of that automatically.
s.db.query mirrors the whole Xano query builder — returnType, bind joins, computed
eval columns, aggregate groups, distinct, and the full operator set via
cmp(left, op, right) with and(...)/or(...) for boolean groups. Signatures are in
llms/statements-data.md; five behaviors are worth knowing here:
-
A join condition spells its two sides differently. The joined table’s column takes its
asalias; this query’s own columns stay bare:bind: [{ table: users, as: "author", join: "left", where: expr(col("author_id"), "=", col("author.id")) }]. Qualifying your own column by the table’s name (col("posts.author_id")) resolves only if the query also setstableAlias— the alias the qualifier is matched against. Unqualified, the engine reads the operand as a text literal and fails at runtime with a parse error naming the other operand, sodb.queryrejects that spelling at export instead. -
A join adds no columns to the returned row. With or without a
bind, a row is the queried table’s columns — which is whatInferResponsetypes, so there is norow.authorto read. To bring a joined column back, project it with anevalwhosenameis the dotted path:eval: [{ name: "author.name", as: "author_name" }]putsauthor_nameon the row and on the inferred type. A bare name there fails at runtime (it qualifies to the base table), and a dotted joined column inoutputis dropped with no error. -
Paging changes the response shape. Supplying
pagingwith metadata on (the default) returns a paging envelope —{ items, curPage, nextPage, prevPage, offset, perPage, itemsReceived }, plus totals whentotals: true— instead of a bareRow[], andInferResponsereflects that. Passmetadata: falseto keep the bare array. ReadnextPage(number | null) as the typed has-next signal. It also moves whatoutputselects from. The selection applies to the value the statement binds, so under the envelope its roots are the counters anditems.<column>—output: ["itemsReceived", "curPage", "items.id", "items.title"]. A bare column list there matches no envelope key, so every key is dropped and the endpoint answers[]at HTTP 200 with no error.export()warns and--strictfails; withmetadata: falsethe statement binds the rows and bare columns are the right form. -
Don’t author
mixed(...)conditions. Xano’s editor allows a container whose terms don’t all join the same way, so pulled workspaces contain it and it round-trips — but the stored form doesn’t record the intended grouping, and the two places it can appear disagree: a branch folds terms strictly left to right (a OR b AND c=(a OR b) AND c) while adb.queryfilter inherits SQL’s AND-before-OR precedence (a OR (b AND c)). Writeand(or(a, b), c)oror(a, and(b, c))— each says exactly one thing in every context. -
like/iliketake the operand as the pattern, verbatim. The operand is the pattern, socmp(col("body"), "ilike", inp("q"))with a bare term matches only a whole-string equal — and the endpoint answers 200 with an empty list, which reads as “no results for that query”. For substring search useincludes/not includes, which wrap the operand in%…%themselves and match case-insensitively. Prefer that over building"%" + term + "%": a hand-built pattern is non-empty even for a blank term, which defeats theignoreEmptybelow.contains/@>/overlapsare JSON and array containment, not text — on a text column they fail with a parse error. -
ignoreEmptyDROPS the clause — it does not match zero rows.cmp(col("owner"), "in", ids, { ignoreEmpty: true })with an emptyidsreturns the unfiltered table, where the same clause without the flag matches nothing. Never put it on a filter that scopes rows to a permitted set: an empty permission list then returns everything. It is for an optional search filter, where a blank box really does mean “don’t filter”.export()warns when the operand is empty in the bundle; a runtime-empty list is yours to reason about. -
Compose a rule set as siblings, not a folded chain.
and(...)/or(...)take any number of terms and encode flat, so build the array and spread it —and(...rules). Folding one term at a time (rules.reduce((acc, r) => and(acc, r))) nests a container per rule, and nesting costs quadratic bytes: 512 terms are 394 KiB as siblings and 21 MiB folded. Past 128 levels the build fails with a message naming the fix. Mixing joins? Group each run:and(or(...anyOf), ...allOf). -
An aggregate or
evalnameis written bare ("status") and alias-qualified on emit; the engine rejects a bare column in either, and an already-dotted joined column passes through. The statement also declares the alias it qualified with, so the qualified name resolves — nothing to set by hand. -
evalis where vector search lives. Anevalfilter pipeline compiles to SQL, so it resolves a different filter registry thanfl.*(which runs in the request) — including the distance filters anf.vectorcolumn needs. Compute the distance, then sort by the alias it grafts onto the row; the ranking happens in the database, over the column’s index:Match the filter to the indexop(VECTOR_FILTERS, on@xanots/sdk/internal, lists the family). The same filter works on awhereoperand to cut off by distance rather than by row count. -
A
whereoperand may carry filters — except the request-time timestamp ones. Awhereis compiled into SQL, and most filters have a SQL form there (trim,concat,upper,lower, and theepochms_add_day/epochms_sub_monthfamily). The request-time timestamp filters do not —epochms_transform,epochms_add_ms,epochms_add_secs,epochms_date,epochms_from_format— and the engine doesn’t degrade gracefully: the request dies with a bare fatal naming nothing. For a relative cutoff, use the SQL-side spelling — by raw name, sincefl.*carries no builder for that family:filter("epochms_add_day", …)or the raw{ name, arg }form — or compute it in an earliers.set_varandref()that.export()warns; a barec.now()is always fine. -
A
s.switchcase withoutbreak: truefalls through. The engine’s default is fallthrough, so a matched case also runs every later case body — and thedefaultblock too. Whatever those bodies write gets written two or three times, at HTTP 200, withtscand a plainexportboth clean. Setbreak: trueon every case unless you mean the cascade;export()warns when a break-less case has somewhere to fall into.
query/get/add/edit/patch). An addon is a single table-bound db query rather than a
statement stack: addon({ table, where, output, cardinality }), where where binds it to
the parent row and cardinality shapes the graft ("single" object, the default "list",
"count", "exists", "aggregate").
InferResponse with no cast;
a bare-name reference grafts unknown. Author as relative to a row (_author) — when
the query returns a paging envelope the items[] offset is added for you. If an alias
shadows an existing column the build throws, because the engine would silently overwrite
that column at runtime (Xano convention: prefix with _).
Values — c.int/text/bool/decimal/null/obj/array, c.now(), ref(var), inp(input),
col(name), the context refs auth(path?)/env(name)/setting(name)/sys.*(),
out(name) for a parent-row column in an addon input, and toolset(path) for the token
and URL parameters bound while a tool runs under its toolset. withFilters(value, fl.a(), fl.b())
attaches the value pipeline from a typed catalog of filters generated from the engine’s own
sources.
-
c.obj/c.arraytake plain JSON literals only. A nested tagged value (inp/ref/auth/c.*) is a compile error. For a computed object — a response, or anapi.requestparams— use a record of values ({ count: ref("count") }). For a dynamic object argument useobj({...}), which builds a checked expression. -
An
obj({...})member may carry a filter chain. That matters most for the null-safe drill:db.getbindsnullon a miss, soobj({ city: ref("row.address.city", { safe: true }) })is the normal shape — no per-members.set_varto hoist it out.c.now(),env()andsys.*()are members too.{ safe: true }is for a base that exists and may be null: on a base nothing binds it would hide a typo behind anull, which is whyexport()warns (with a did-you-mean) when aref()’s base segment names noasin that stack. -
c.inttakes a string or bigint pastNumber.MAX_SAFE_INTEGER. The engine stores integers as strings and has no 53-bit limit, soc.int("18446744073709551615")is exact where the number literal for it is already…616. Anumberthat is not a safe integer throws rather than encoding the rounded value. -
A bare scalar works in any
fl.*argument.fl.get("a.b", 0)encodes identically tofl.get(c.text("a.b"), c.int(0)); strings, numbers and booleans are all wrapped for you. Objects and arrays still needc.obj/c.array. -
A typed
fl.*call is capped at its declared argument count. Passing more throws, in the type and at runtime — an extra argument used to ride into the filter’s arg list for the engine to ignore or fail on.filter("name", …)is the untyped escape. Seven filters the catalog under-declares stay variadic (concat,index_by,get,array_merge,array_merge_recursive,jwe_encode,jwe_decode). The other 95 filters take no arguments at all — they are emitted(), sofl.abs(x)is a compile error rather than an argument silently shipped to an engine that does not accept one. -
api.requestheaders take a{ "Name": value }record, values may be tagged:headers: { "x-api-key": env("KEY") }. Prefer a header over a?key=query param for a credential — a URL travels into access logs, proxies andReferer. That is not envelope safety, though: theasenvelope’srequesthalf mirrorsurl,paramsandheaders, so never return it raw from a credentialed request; readresponse.result. -
Some filters require an argument their own docs call optional. Filter arguments are
positional, and a short call is refused by the engine before the filter runs — so
fl.csv_encode()andfl.number_format()are compile errors here rather than a failure on a deployed endpoint. Which filters those are is probed, not declared:fl.round()is also documented optional and genuinely works. Pass every argument the signature shows without a?; the runtime guard names the count if you reach it from JavaScript. -
fl.csv_encodewrites no header —fl.csv_createis the one that does. They read as interchangeable and are not.csv_encodeemits each row’s values in that row’s key order with no normalization across rows, so rows whose keys differ in order or count misalign columns silently; nested cells are JSON-encoded,falsewrites empty, and a piped array of scalars collapses to a single line.csv_createtakes the column names as its piped value and the data as itsrowsargument. -
Only
fl.fsort({ type: "number" })sorts numerically. Every other comparator — including a spelling the engine does not recognize — sorts as case-insensitive text, silently and with no error, so[2, 10, 1]comes back[1, 10, 2]. A lexicographic sort agrees with a numeric one whenever the values share a digit count, so this looks correct on small data and goes wrong on real data: a “top N by score/distance/recency” endpoint returns the right rows in the wrong order. The union rejects the two plausible wrong spellings ("decimal","int") outright. -
col()does not resolve to a stored value inside adb.editrow. To read-modify-write a column — incrementing a counter —db.getthe row first and pipe its bound value through a filter.col()evaluates tonullthere, sofl.add(1)computesnull + 1and the engine aborts.That pair is not atomic — concurrent writers can lose an increment, and no atomic increment statement exists. A genuinely safe counter needs the arithmetic in the database vias.db.direct_query, which in turn needs the table’s physical Postgres name; that name is assigned at import and is not knowable from atable()def, so it has to be hardcoded after inspecting the deployed table. A typed path requires an engine change (issue #35). -
A JavaScript body is written as a function, not a
c.textstring. The lambda statement (s.lambda) and eight filters (fl.map/filter/some/every/find/findIndex/reduce/lambda) run JavaScript against a small, closed set of injected identifiers — and which ones are in scope depends on the surface. Write the body inline and the surface is implied by where it sits: the bindings are the function’s parameters, typed from the position, so your editor supplies them and a wrong name is a compile error rather than a wrong value in production.The parameters are a fiction — only the body is sent, and the engine injects the bindings as free identifiers — so destructure them.(b) => b.$this * 2would emitreturn b.$this * 2withbundefined at runtime, and the SDK refuses it.fl.transformis not one of these. It sits next to them and reads like one, but it takes a Xano expression — noreturn, and the piped value binds as$0(or$$), not$this. A$thisthere resolves to null and the call still returns HTTP 200, so the SDK refuses both spellings at author time and points at$0.For a body built away from its call site,lam.*names the surface explicitly:Omitsurfaceand the check is deferred to wherever the body lands, which is the thing that knows.lam.file("./lambdas/total.ts")reads a default-exported function of the same shape from its own type-checked module — the deterministic option under a bundler, where a function’s own source is whatever the bundler emitted. It needs a filesystem, so it ships on the Node entry only:import { lam } from "@xanots/sdk/node", whoselamcarriesfnandrawunchanged.lam.raw(code)is the text escape hatch, guarded identically, and works on either entry. The full binding table per surface is inllms/lambda.md. Three things to know, all live-verified against a real engine:- A body that throws does not fail the request — the engine returns its diagnostic text as the value with HTTP 200, so the failure arrives as bad data rather than an error. That is engine behavior and not interceptable from an SDK; validate before consuming a lambda result numerically, and prefer an authored body, which cannot fail that way for a binding reason.
- The body is a function body, not a module: it must
return, and a top-levelimportis a syntax error. Reach a dependency through the preloaded globals —crypto,fetch,Buffer,axios,jose,_,math,moment,DateTime,uuidand friends, which need no specifier. A dynamicimport("…")orrequire("…")with a literal specifier is not portable: some instances bundle the body before running it and resolve every literal specifier ahead of time, soawait import("node:crypto")comes back as the textCould not resolve "node:crypto"with HTTP 200; others resolve it at run time and it works (issue #265). consoleoutput goes to the request log, not stdout.
c.text(...)body is still accepted and gets the same build-time check — the guard sits at the call site, not insidelam.*— so an unknown$identifierfails whichever way you write it (issue #221). -
c.expression("…")is carried through verbatim and NOT validated. XanoTS does not parse it or type-check it; nothing inside participates inInferResponse, so a var named there is invisible to a rename that updates every typedref(). A malformed expression fails at runtime; one that is merely wrong ($var.tota1) returns a wrong answer. Reach for it only for syntax the typed surfaces can’t express —~concatenation, inline arithmetic, conditionals — and note it is not theexpr()condition builder. (c.expressionLegacyexists only socodegencan return an older stored form.)
sys.*). Xano’s built-in request context reads as
$env.$remote_ip in XanoScript — note the second $: these are settings with a
$-prefixed name, the same tag env() emits. That prefix is the footgun, because
env("remote_ip") reads a workspace env var literally named remote_ip (almost always
unset → null) rather than the caller’s IP. sys.* spells the prefixed names for you:
setting("$<name>") covers anything sys doesn’t. The one that matters most in practice is
sys.remoteIp(), the rate-limit key for public endpoints.
Inputs — input.* mirrors f.* exactly: every engine-legal field type is a valid
function/query input, with input.object(children) and input.list(element) for structured
shapes. Comparisons use = != > < >= <=.
Validate input at the boundary. Field types don’t enforce arbitrary
rules, and s.precondition raises a status-bearing error a client can detect via
res.ok — unlike s.throw, which returns 200 with an error body. error_type picks the
status: badrequest/inputerror → 400, unauthorized → 401, accessdenied → 403,
notfound → 404, toomanyrequests → 429, standard (the default) → 500.
methods run at bind, before your stack, so
input.email({ methods: ["lower"] }) makes inp("email") read already-normalized. Don’t
reroll trim/lower/upper into a var.
Email/password auth. The trap: input.password() hashes on bind, so a password
typed that way is already a hash before your stack runs, and check_password then compares
hash against hash — login always fails. Take the password as plain text and let the
f.password column hash it on write; check_password compares the plaintext submission
against the stored hash.
input.password() only when you specifically want its bind-time hash and are
not also feeding it to check_password.
Middleware, request history & env vars
Amiddleware({...}) is reusable logic (input/stack/response + resultStrategy: "merge"|"replace" + exceptionPolicy). To run one, attach it with a host’s
middleware: { pre, post } field on query/apiGroup/defineFunction/task/tool
(not triggers). Prefer a def handle over a bare name, the same rule as auth/apiGroup
references; { middleware: mw, active: false } keeps an entry but disables it.
exceptionPolicydecides whether a guard is a guard."silent"is the default and swallows the throw, so a rate limit or auth check authored without an explicit policy is not enforced."rethrow"aborts the request and surfaces the authorederror/status (a trippeds.redis.ratelimit→ 429) while still runningpost;"critical"is the same but skips thepostchain. That is the only difference.- Inheritance is override, not merge. Providing a phase overrides it; omitting a phase
inherits the parent tier’s chain, resolved at request time Query → API Group →
Workspace.
pre: middleware.clear()overrides a phase with nothing. - Setting
workspaceConfig.middlewareat all emits the whole map. Any host/phase you don’t list is emitted empty, which clears that tier on deploy. Omit the field entirely to leave the workspace’s existing middleware untouched. The same wholesale rule applies todatasources. auth()isnullon a public host, and apremiddleware runs after auth resolution. A rate limit keyed byauth("id")on a public endpoint collapses every caller into one bucket, silently.export()warns on direct attachment of anauth()-keyed middleware to a host whereauth()may be null.- A
resultStrategy: "replace"middleware attachedpostrewrites the response at runtime, whichInferResponsecan’t see — declareresponseShapeon the endpoint. workspaceConfigalso carriesrealtime,documentation, andswagger, which are server-shaped and carried verbatim rather than authored.realtimethere is the legacy workspace-level block, not the realtime primitives you author.
"prefix" + auth("id") doesn’t exist):
sys.remoteIp() — since
auth("id") is null there. And note the shared-bucket rule: co-attaching one middleware
object to N hosts means all N share the same key and therefore one counter, so max: 10 is
a global budget across them. Vary the key (fold the host name into the prefix) for an
independent limit per host.
Reading the request body in a pre middleware. It does receive the host’s inputs, via
s.util.get_all_input({ as: "payload" }) — but the result is wrapped as { type, vars },
so a body field lives at ref("payload.vars.<field>"). The un-nested path is the usual cause
of an Unable to locate var 500.
Request history — the per-object execution trace behind Xano’s debugger, authored as a
single scalar history field: false off, true on at the default depth, a number =
capture depth (statement executions recorded per record, not records retained), "all" =
unlimited. Omitting it inherits; any value stops inheriting. Inheritance resolves
object → container → workspace (a query from its API group, a tool from its
toolset/agent, everything else straight from the workspace). Per-kind defaults when
inheriting: query / task / tool capture on; function / trigger / middleware off.
workspaceConfig.history is wholesale in the same way the middleware map is.
env("NAME"):
Seed data
Give a tableseed rows and they ship into the database on deploy — so a fresh
environment comes up with lookup tables, demo content, or fixtures already in place,
not empty:
id this way is a seed property, not a general bulk-insert one: the runtime
statement s.db.bulk.add drops id from every row unless you pass allowIdField: true,
assigning the next sequence value instead.
Its sibling s.db.bulk.delete has the mirror-image rule: a filter that constrains nothing
matches every row, so a where-less delete is a truncate. It throws unless you say so
with allRows: true, which wipes the table and returns the deleted count — the wipe can no
longer be reached by forgetting an argument. Reach for s.db.truncate({ table, reset: true })
when the id sequence should restart too.
Deploy is a full replace, so re-deploying re-seeds cleanly — no duplicate rows. Seed
data travels only in the deploy package (resolved at deploy time); it never enters the
compiled workspace bundle.
For data in a file, use seedFile:
node:fs
at deploy time. Note the tradeoff: inline rows are typed against the table schema at compile
time, while a seedFile/thunk seed is opaque to the typechecker — xanots export/deploy
validates it instead, naming the row index, the offending column and the table’s known
columns. A thunk (seed: () => import("./products.seed.json")) also works and is
the right shape for computed seeds — but be aware it does not keep seed values out of
a frontend build: the import() lives in your module, so a bundler emits the JSON as a
served chunk, and any frontend that imports a def whose module graph reaches that table
ships the seed to the browser. seedFile stores a path string, which a bundler has nothing
to follow.
Either way, keep secrets out of seed — it is throwaway fixture data for disposable
environments. As a backstop, xanots deploy <entry> --static <dir> and xanots release <entry> --static <dir>
refuse to publish a frontend build containing seed values from columns your schema marks
access: "internal" or sensitive (pass --allow-seed-in-static if the data is
deliberately public).
Typing is unaffected by the form you choose — the table’s row type and column names stay
inferred.