Reference

Language Reference

Language Reference

The complete reference for the Grid language: model structure, references, literals, the type system, expressions, operators, and control flow. This is the long-form companion to the feature checklist in features.md.

Read quickstart.md first if you've never written a Grid model. Use this page when you need the precise rule.

Style note. Where Grid accepts more than one spelling, style-guide.md picks the canonical one.


Contents

  1. Model Files And Structure
  2. Statements
  3. References And Ranges
  4. Literals And Values
  5. Type System
  6. Expressions
  7. Operator Precedence
  8. Control Flow
  9. Functions And Calls
  10. Lambdas And Higher-Order Forms
  11. Arrays, Spilling, And Comprehensions
  12. Pipelines And Infix Sugar
  13. Reserved Words
  14. Multi-line And Layout Rules
  15. See Also

1. Model Files And Structure

A Grid program is a .grid source file. It is a sequence of statements — mostly assignments — optionally wrapped in model directives.

MODEL "My Model"
DESCRIPTION "What this model does."
VERSION "1.0.0"
AUTHOR "you@example.com"
TAGS "demo", "v1"
 
# Statements go here
A1 is currency = 100000
B1 is currency = A1 * 0.93
 
END MODEL

1.1 Directives

Directives are metadata headers. All are optional — a file with bare statements parses fine — but shared models include them.

Directive Meaning
MODEL "..." Human-readable model title
DESCRIPTION "..." One-sentence description
VERSION "..." Semantic version string
AUTHOR "..." Author identity
TAGS "a", "b" Comma-separated tags
RUNTIME "..." Legacy. Parsed for back-compat; omit it in new models
END MODEL Closes the model body

Do not choose an engine in model source: the RUNTIME directive still parses for older files, but new models should omit it.

1.2 Statement separation

Statements are separated by newlines or semicolons:

A1 = 1
B1 = A1 + 1
C1 = 2; D1 = 3        # semicolons separate statements on one line

Newlines fold (continue the statement) inside open brackets and a few block forms; see §14.

1.3 Comments

# line comment to end of line
A1 = 1   # trailing comment
 
/* block comment,
   may span multiple lines */
B1 = 2

2. Statements

Almost every statement is an assignment: a target, an operator, and an expression. Assignments are covered in depth in assignments.md; this is the summary.

The target denotes a binding through an address. Coordinate targets denote cells; named targets can define calculations or, for a direct reference, alias an existing binding. See concepts.md for the vocabulary.

2.1 Assignment shapes

<target> = <expression>                 # eager
<target> ~= <expression>                # lazy
<target> ?= <expression>                # conditional eager (skip on error)
<target> IS <type-tag> = <expression>   # typed
<type-tag> <target> = <expression>      # typed, prefix form
[a, b, ...rest] = <expression>          # destructuring
<range> = <expression>                  # broadcast to a finite rectangle
Operator Semantics
= Eager — recompute whenever an input changes
~= Lazy — compute only when read; cache until invalidated
?= Conditional eager — if the RHS errors, keep the previous value
+= -= *= /= Rule-action compound — read-modify-write a single-cell or named target using its committed value; ordinary top-level arithmetic use is rejected

On a table-typed target, +=/^=/*=/-= mean record mutations (insert/upsert/update/delete), not arithmetic. See assignments.md.

2.2 Decorators

input and output mark a binding's external write/read surface; default marks a formula as an overridable base value:

input   price = 0
output  total = price * qty
default estimate = model_value      # a rule/input may override; RESET restores it

input/output are advisory by default and strictly enforced in protected mode. default opts a formula into being overridden by a rule (see rule actions); a rule that overrides a non-default formula raises GRID_MIXED_OWNERSHIP. See assignments.md.

2.3 Schedule modifiers and rule blocks

A single assignment can carry a schedule modifier (ONCE, EVERY, AT), and WHEN/EVERY/AT blocks group rule actions. These are covered in rules-and-schedules.md.

2.4 SOLVE statements

SOLVE is a non-assignment statement that declares a goal-seek or bounded optimization over workbook cells:

SOLVE Z9 = A2 IN [0, 1] GOAL A3 = 200000    # drive A3 to a target
SOLVE B9 = A1 IN [0, 5] MINIMIZE A2         # minimize an objective cell
SOLVE B9 = A1 IN [0, 5] MAXIMIZE A2         # maximize an objective cell

The result cell (Z9) receives the solved value; the variable cell (A2) is never mutated, so the sheet stays consistent and auditable. Solves are reactive — editing an upstream input re-solves. Full syntax, limits, and the solverWrites report are in assignments.md.

2.5 Definitions

A parenthesised parameter list on the left of = declares a reusable, named, parameterised formula. It is a compile-time macro: it emits no cell of its own, and each call site is expanded in place into a LET(param, arg, …, body) binding. Define once, call from any cell:

taxDue(income, rate) = MAX(income, 0) * rate
hypot(a, b)          = SQRT(a * a + b * b)
 
B4 = taxDue(A1, 0.21)
C7 = taxDue(A2, 0.15)
D9 = hypot(3, 4)                 # 5

The parameter list is what designates a definition — no keyword is required. A leading DEFINE is accepted for those who prefer it explicit (DEFINE hypot(a, b) = …) but is optional; the shorter DEF is also accepted as a synonym. Contrast a definition with a plain named binding, which has no parameter list: pi = 3.14159.

Names are case-insensitive like the builtin catalog, and a definition shadows a same-named builtin at its call sites. A definition may call an earlier definition (they compose); a call before the definition, a self-reference, and mutual recursion are not expanded and resolve as ordinary — unresolved — calls. Calling with the wrong number of arguments is a compile error, and partial application (taxDue(A1, _)) is not supported.

The compiler infers one generalized signature per definition and instantiates fresh type variables at each call. Field access creates open record-row requirements (row.amount accepts additional fields), while MAP, REDUCE, and SCAN create collection protocol constraints backed by the runtime's shared capability matrix. Structural violations are compile errors; scalar mismatches follow the authored coercion mode, remaining warnings in loose source and becoming errors under strict. FUNCTION(name) exposes the inferred type and effect row. See named-functions.md for the complete contract.

2.6 Function reflection and calculus

Named definitions are inspectable at compile time. These forms fold into ordinary constant Grid values; they do not introduce mutable runtime function objects:

square(x) = x * x
 
A1 = ARGS(square)       # ["x"]
A2 = BODY(square)       # {kind: "binary", op: "*", left: ..., right: ...}
A3 = FUNCTION(square)   # {schema: "grid.function.v1", name: "SQUARE",
                         #  arity: 1, args: ["x"], body: ...}

BODY returns a normalized expression tree. Nodes use a small open schema: literal nodes carry kind and value; parameters and references carry kind and name; unary and binary nodes add op; calls add name and args. Cell and range nodes retain canonical addresses, including qualifiers; relative and spatial references retain their offsets or directions. Constants, arrays, objects, typed literals, and neighborhoods likewise retain their structure. The reflection match is exhaustive over the expression tree, so a new expression form must define its public representation before the compiler will build. As with ordinary Grid object literals, object values use the cell surface's key/value-row representation. Imported names work too: FUNCTION(math.square).

The same definition can feed calculus without manually rewriting it as a lambda:

position(t) = t ^ 3 - 2 * t
velocity(t) = D(position, t)
acceleration(t) = D(velocity, t)
 
rate(t) = EXP(-t)
accumulated(t) = INTEGRAL(rate, 0, t)

D(fn, x[, step]) is the concise spelling of DERIVATIVE(fn, x[, step]). The compiler turns the named definition into a lambda, then the runtime uses exact automatic differentiation where the expression is supported and central differences otherwise. This means an opaque or unfamiliar function remains differentiable as long as it can be evaluated numerically.

For a multi-parameter definition, name the parameter being varied inside a wrapper definition; the remaining parameters are captured normally:

demand(price, income) = price ^ 2 + 3 * income
priceSensitivity(price, income) = D(demand, price)

INTEGRAL(fn, start, end[, control]) similarly lowers to the existing adaptive numerical INTEGRATE implementation and currently accepts a one-parameter definition. These forms establish a stable reflected-AST foundation for later symbolic transforms without requiring symbolic algebra for numerical calculus to be useful today.

The same one-parameter definition can be realized as graph-ready values:

wave(x) = SIN(x)
 
input viewMin = -PI
input viewMax = PI
input viewPixels = 800
 
curve = SAMPLE(wave, viewMin, viewMax, viewPixels)
selected = SAMPLE(wave, [-PI, 0, PI])
adaptive = SAMPLE(wave, viewMin, viewMax, 0.001)
sampleInfo = SAMPLE_INFO(wave, viewMin, viewMax, 0.001)

SAMPLE(fn, coordinates) evaluates explicit coordinates. SAMPLE(fn, start, end) adaptively samples the interval; a fractional control between 0 and 1 sets its error tolerance, while an integer control of 2–65,536 requests that many evenly spaced points. The result is an array whose first row is ["x", "y"], followed by the realized points, so it binds directly to a Charts surface. Bounds and resolution are ordinary reactive dependencies: changing them, including from a chart viewport, recomputes the sample. The existing Frame ETL SAMPLE(frame, count) remains unchanged; the named-function form is selected only when its first argument names a definition.

SAMPLE_INFO accepts the same arguments and returns metadata with schema grid.sample.v1: sampling mode, actual and requested point counts, tolerance, detected discontinuities, and flags indicating whether the point or adaptive depth safety limit was reached. Sampling inserts undefined/error-valued break points at detected discontinuities so line charts do not connect across asymptotes.

For a multi-parameter family, name the varying parameter and bind every fixed parameter in an object:

demand(price, income) = 120 - 2 * price + 0.01 * income
input CurrentIncome = 80000
 
demandCurve = SAMPLE(
  demand,
  price,
  { income: CurrentIncome },
  0,
  100,
  800
)

Those bindings are ordinary reactive expressions, so changing CurrentIncome recomputes the family curve.

2.7 Choice sets — CHOICE / CHOICES

CHOICE declares a one-of enum — a closed set of symbol literals. It drives MATCH checking: a match whose arms are symbols from a declared set warns when an arm names a symbol outside the set, or when the match is non-exhaustive (a member is left uncovered with no _ catch-all). Matches unrelated to any declared set are never flagged.

CHOICE status = :green | :amber | :red
 
A1 = MATCH(health, :green -> "ok", :amber -> "warn", :red -> "page")   # exhaustive
A2 = MATCH(health, :green -> "ok", _ -> "other")                       # catch-all, ok
A3 = MATCH(health, :green -> 1, :red -> 3)     # warns: missing :amber

CHOICES declares a many-of flag set. Each member is implicitly assigned a distinct power-of-two bit (member i1 << i), so a single numeric value can carry several flags at once. A member symbol lowers to its bit value in expressions — combine flags with + (or BITOR) and test membership with HAS:

CHOICES perms = :read | :write | :exec       # :read=1, :write=2, :exec=4
 
E1 = :read + :write                          # 3 — one value, two flags
E2 = E1 HAS :read                            # TRUE  — has read
E3 = E1 HAS :exec                            # FALSE — no exec
E4 = E1 HAS :read + :write                   # TRUE  — has both

x HAS flags tests that every requested bit is set (it lowers to BITAND(x, flags) = flags). Over a value that isn't a flag set, HAS keeps its ordinary collection-membership meaning. A symbol should belong to only one declared set; declaring it in two warns (in a flag set it is a number, elsewhere a symbol).

CHOICE also has a payload-bearing algebraic form. Constructors use qualified names and typed fields:

CHOICE Delivery = Pending | Delivered(at: date) | Failed(message: string)
 
value = Delivery.Delivered(dt"2026-07-27")
label = MATCH(
  value,
  Delivery.Pending() -> "pending",
  Delivery.Delivered(at) -> TEXT(at),
  Delivery.Failed(message) -> message
)

Unlike symbol choices, constructor matches are fail-closed: every constructor needs an unguarded arm unless _ supplies a catch-all. Fields may refer to the enclosing type for finite recursive values. See algebraic-data-types.md for contracts, matching, the depth bound, and provenance.

2.8 Module imports — USE

USE "<path>" AS <alias> imports another source module's definitions under a namespace. Reusable modules conventionally use the .gs extension:

USE "shared/finance.gs" AS fin
 
B1 = fin.net_margin(revenue, cost)     # calls the imported definition

.gs is plain Grid source with a definitions-only contract. It may contain named functions, eager named constants, choices, units, directives, and transitive imports. Cells, ranges, rules, solves, mutations, scheduled or lazy bindings, and state/input/output bindings are rejected. Imported definitions inline at the call site exactly like local ones, and their arity is checked. Named constants are imported through the same namespace. A duplicate alias, a missing module, or importing when no module resolver is configured each raise an error.

USE remains extension-agnostic and continues to accept legacy .grid module paths. .grid denotes an executable model source file; .gs makes the library-only intent machine-checkable.

USE "path" EXPOSING (name, other_name) imports selected definitions into the local namespace. Imported modules may themselves use modules, and cycles are rejected with the full import chain. The compiler ships shared/politics.gs and shared/socsci.gs; other module sources are provided by the embedding compiler's resolver.


3. References And Ranges

A reference names a cell or a region.

3.1 Cell references

Form Example Notes
A1 A1, AA100 Column letters + row number
Absolute $A$1, A$1, $A1 $ pins column/row for fill
R1C1 R1C1 Row/column numbers
Relative R1C1 R[1]C[-2] Brackets denote an offset from the current cell
Named Revenue, tax_rate A semantic address for a binding or named calculation
Namespace-qualified Sheet1!A1, 'Q4 Revenue'!B2, Dataset_1!config ! is a namespace specifier, not an accessor

The ! join is a flat symbol name. The left side is a sheet or workbook-surface namespace; the right side is a cell address or named slot. Quote the namespace with single quotes when it contains spaces.

A plain direct-reference named assignment creates an address alias:

Revenue = A1

Revenue and A1 then address the same reactive binding. A named expression such as Tax = Revenue * rate creates a distinct calculation. See assignments.md for the exact boundary and observable behavior.

3.2 Ranges

Form Example Allowed as a target?
Finite rectangle A1:B10, R1C1:R3C3 Yes (must be finite)
Full column A:A, A:C Expression only
Full row 1:1, 1:3 Expression only

Range targets must be finite rectangles. Full-column/row ranges are valid in expression positions (e.g. SUM(A:A)) but never as assignment targets.

3.3 Spill and structured references

A1#                  # the whole spilled rectangle anchored at A1
Sales[Revenue]       # structured (table column) reference
data@"amount"        # header selection (single key)

Removed. A prefix @ on a reference (@A:A, "implicit intersection") was removed — it never reduced a range to the current row/column value; UnaryOp::At was pure identity, so the syntax was a silent no-op. It now rejects with GRID_IMPLICIT_INTERSECTION_REMOVED. Prefix @ is now reserved for temporal coordinates (§3.5). Postfix @ remains temporal after a reference and selects headers or fields after a structured value. Reference a cell directly instead (A5, not @A:A).

3.4 Spatial and neighborhood references

ABOVE      BELOW      LEFT      RIGHT          # adjacent cell
ABOVE(3)   BELOW(2)   LEFT(4)   RIGHT(1)       # offset by N
NEIGHBORS  NEIGHBORS(2)                        # surrounding ring(s)

Bare LEFT / RIGHT (no parentheses) are spatial references. LEFT(text, n) / RIGHT(text, n) (with arguments) are the text functions. The grammar disambiguates by the argument list.

3.5 Temporal references

A1@-1                 # previous revision
A1@dt"2026-04-10T15:30:00Z"   # value as of a timestamp
A1@2026-04-10         # same timestamp coordinate, authoring shorthand
A1@today              # start of today
A1@monday             # most recent Monday, including today
A1@january            # most recent January 1, including this month
A1@1776               # January 1, 1776 CE
A1@5000BCE            # year-start coordinate in the BCE era

A1@-N resolves the referenced cell at N revisions before the current value. A1@dt"..." and A1@<temporal-coordinate> resolve the latest revision at or before the supplied instant. Bare @ coordinates use past/current anchoring for calendar words: @monday is the most recent Monday and @january is the most recent January 1.

Current-cell lineage is exposed by the resident WHY(cell) inspection request, which returns the dependency trace and latest moved inputs. From the CLI, use gridctl cell why <model-id> <symbol>. WHY is not a workbook formula because provenance describes the evaluated model rather than becoming model state.

WHY reports semantic lineage and the current effective binding state. It does not make cache residency, evaluation order, parallelization, or the selected runtime strategy part of the language contract. EXPLAIN may expose a current physical plan for inspection; equivalent evaluations may use a different plan.

3.6 Iteration context

Inside broadcast and rule contexts, these resolve to per-cell position:

ROW_INDEX   COL_INDEX   IS_FIRST   IS_LAST   CELL_COUNT

4. Literals And Values

Every value has one of nine kinds: blank, number, string, boolean, date, array, object, complex, error. Literals are how you write them.

4.1 Numbers

42            3.14          .5            1.2e9         1.2e-9
1_000_000     0.000_001                          # numeric separators
0xFF          0b1010        0o755                # hex / binary / octal
5i            3.14e-2i      0xFFi                # imaginary (complex)

4.2 Unit and suffix literals

Numeric literals may carry a unit suffix; the value is the plain number, the suffix attaches a type tag:

Suffix Example Meaning
pct 21pct percentage → 0.21
bp, bps 25bps basis points → 0.0025
deg, rad 45deg angle
ms s min h d w mo y 5min, 2d duration
KB MB GB TB 512MB data size
3-letter code 100USD typed currency (currency:USD)
votes seats persons 100votes distinct civic count dimensions
% (postfix) 50% 0.5

Explicit type annotations also accept unit expressions such as unit:m/s, unit:kg*m/s^2, unit:m^1/2, unit:m^0.5, absolute temperatures (unit:C, unit:F, unit:K), temperature deltas (unit:delta_C, unit:delta_F, unit:delta_K), and ISO currency codes. These unit tags remain numeric at the value-kind layer while the dimensional MIR pass gives them load-bearing unit semantics.

votes, seats, and persons are deliberately incompatible dimensions, so strict dimensional checking rejects formulas such as 100votes + 5seats.

Models can declare scale-only custom units before formulas:

unit smoot = 1.7018 m
unit smoot_per_second = smoot/s
R1C1 = 2 INTO unit:smoot

Custom units layer over the built-in catalog but cannot shadow built-ins or use affine bases such as Celsius/Fahrenheit. Use explicit delta temperature units for temperature differences.

4.3 Strings

"double quoted"
"""triple quoted, may span
lines and contain "quotes" """
raw"\d+"                  # no escape processing
raw"""...multi..."""
`total={A1}`             # backtick interpolation, optional :format
`amount: {A1:"$#,##0.00"}`

Strings use double quotes. Single quotes are reserved for quoted namespace specifiers ('Q4 Revenue'!A1), not strings. Interpolation supports an optional format spec after : inside {...}.

4.4 Booleans and blank

TRUE    FALSE            # case-insensitive
BLANK                    # absence of a value — not the same as ""

BLANK means "no value". "" is an empty string. ISBLANK("") is FALSE. See errors.md.

4.5 Symbols

A :identifier is a symbol literal — a lightweight enum/state label that carries the symbol type tag:

status = :draft
color  = MATCH(state, "ok" -> :green, _ -> :red)

4.6 Dates, datetimes, durations, cron

date"2026-04-10"     d"2026-04-10"
datetime"2026-04-10T15:30:00Z"    dt"2026-04-10T15:30:00Z"
duration"P1D"        dur"PT15M"
cron"0 9 * * 1-5"

For human-authored temporal coordinates, Grid also accepts quote-free @ forms:

@today       @yesterday     @tomorrow
@monday      @tuesday       @wednesday
@january     @february      @march
@1776        @2026          @107AD       @5000BCE
@2026-04-10  @2026-04-10T15:30:00Z      @12/31/2025

Standalone @YYYY-MM-DD and @M/D/YYYY act as date values. Relative words, weekdays, months, and era-qualified years act as temporal coordinates resolved against the evaluation clock and calendar registry.

4.7 Patterns and wildcards

/^INV-\d+$/i         # regex literal with flags
glob"*.csv"          # glob pattern
wild"???-*.csv"      # wildcard pattern

4.8 Domain-typed string literals

url"https://example.com/path?q=1"
mol"H2O"     molecule"C8H10N4O2"
dna"ATCG"
color"#ff8800"   colour"hsl(30, 100%, 50%)"
note"C#5"
bin"DEADBEEF"   bytes"00112233"   b64"SGVsbG8="

4.9 Markup typed string literals

html"<p>Hello</p>"
xhtml"<p>Hello</p>"
svg"""<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg>"""
jsx"""render(<App />);"""

html, xhtml, svg, and jsx are typed strings: Grid preserves the markup/code payload verbatim and attaches the corresponding type tag. Use xml when you want a structured parsed object value; use these markup tags when round-tripping or rendering exact source text matters.

Any typed-literal tag also accepts an HTML-style payload form when that is clearer for embedded content:

<dna>ATCG</dna>
<json>{"a": 1, "b": [2, 3]}</json>
<html><p>Hello</p></html>
<jsx>render(<App />);</jsx>
R1C1 = R1C2@<dt>2026-04-10T15:30:00Z</dt>

The opening tag must be tight (<dna>, not < dna >), and the closing tag must match the opener spelling case-insensitively (<mol>...</mol>, not <mol>...</molecule>). The payload is verbatim: backslashes are not escape sequences, newlines are preserved, and nested non-matching tags are just content. If the payload itself can contain the exact closing sequence (</tag>), use the quoted or triple-quoted form instead. For example, <svg><circle/></svg> uses the outer <svg>...</svg> as the typed-literal delimiter and stores <circle/>; use svg"""<svg><circle/></svg>""" to preserve a complete SVG document with its root element.

4.10 Arrays

[1, 2, 3]            # row vector
[1; 2; 3]            # column vector (semicolons = new row)
[1, 2; 3, 4]         # 2x2 matrix
[0, ...A1:A3, 4]     # spread (desugars to HSTACK)

4.11 Objects

{name: "Ada", level: 9}
{"first name": "Ada"}        # quoted keys for spaces

4.12 Structured / embedded values

json"""{"a": 1, "b": [2, 3]}"""
yaml"""
a: 1
b: [2, 3]
"""
toml"""title = "demo""""
csv"""a,b\n1,2"""
tsv"""a\tb\n1\t2"""
xml"""<root/>"""

4.13 Error literals

Nine error codes are source-writable with the Excel-compatible spelling:

#N/A    #DIV/0!    #VALUE!    #REF!
#NAME?  #NULL!     #NUM!      #CALC!    #SPILL!
A1 = #N/A
B1 = IF(missing, #N/A, value)

Two additional codes are Grid-generated only — the parser rejects them as literals because they describe structural problems source code should not claim to emit:

Code Generated when
#TYPE! A type-tag check fails on an assignment
#CIRCULAR_REF! The dependency graph has a cycle and iterative calc is off

Errors are first-class typed values that propagate through formulas. The full catalog, propagation rules, and recovery forms are in errors.md.


5. Type System

Grid values carry two layers of type information:

  • A kind — the structural type Grid enforces (number, string, boolean, date, array, object, complex, error, blank).
  • A type tag — optional semantic meaning layered on a kind (currency, percentage, fx_rate, …).

5.1 Kinds

Kind Examples
blank BLANK
number 42, 3.14, 0xFF, 21pct, 25bps
string "hello", :label, html"<p>Hi</p>", svg"""<svg/>""", <jsx>render(<App />);</jsx>
boolean TRUE, FALSE
date d"2026-04-10", dt"2026-04-10T15:30:00Z"
array [1, 2, 3], [1; 2; 3], [1, 2; 3, 4]
object {a: 1}, json"""...""", <json>...</json>, regex literals
complex 5i, 3 + 4i
error #N/A, #DIV/0!, …

5.2 Type Tags

A type tag attaches semantic meaning without changing the kind. Apply one at assignment with IS (or the prefix form), or inside an expression with INTO:

A1 IS currency = 100000
currency A1 = 100000           # equivalent prefix form
B1 = A1 * fx_rate INTO currency   # tag a value mid-expression

as is not a type annotation (A1 as currency is an error). It is reserved for SQL-style aliasing in USING and RENAME.

Tags form a hierarchy. Each tag refines a more general type, all the way up to a representation root — USD is a currency is a number; json is a object. A tag is legal wherever any of its ancestors is, and a leaf implies its ancestors: writing is USD needs no separate currency.

number   ── representations: number(f64) · bigint · decimal
  ├─ currency ───────► USD · EUR · JPY …        (currency:<code>)
  ├─ unit ───────────► m · m/s · kg …           (unit:<expression>)
  ├─ percentage · bps · rate · score · angle · data_size · fx_rate
string   ── symbol · duration · cron · html · xhtml · svg · jsx
date     ── datetime
array    ── csv · tsv
object   ── json · yaml · toml · xml · regex · url · molecule · dna · color · note · bytes
complex
error    ── error:<CODE>

Concrete instances take the parent-label qualifier: money is currency:USD (or bare USD), physical quantities are unit:m/s. A currency is never spelled unit:USD — that is an error pointing you at currency:USD.

The bigint and decimal tags are exact: bigint carries an arbitrary-precision integer and decimal a fixed-precision decimal, and both stay exact through arithmetic and the number-theory functions instead of rounding like ordinary floating-point numbers. Create them with the bigint"..." / decimal"..." literals, the BIGINT(...) constructor, or by tagging a value (x INTO bigint).

5.3 Tag validation and stickiness

Under strict, a value that cannot inhabit its tag's representation root is a #TYPE! — the coercion axis of the strictness umbrella governs this, so strict except coercion turns it off. loose (the default) applies the tag as an overlay without checking.

strict
A1 is currency = "hello"      # #TYPE! — a word-string is not a number
A1 is currency = "100"        # OK — the numeric string coerces to a number
A1 is currency = 100          # OK — canonical

The check is by representation root, so it also refuses an object, array, or date tagged as a number. Tagging a non-currency value is only flagged under strict; loose keeps today's permissive overlay.

5.4 Strictness — strict / loose

A model chooses how strict Grid is with one keyword at the top:

strict        # strict everywhere
loose         # spreadsheet-friendly (the default; omit it for the same effect)

Strictness has two axes — dimensions (unit/currency safety) and coercion (silent value conversions like blank → 0, "5" + 3). The umbrella keyword sets both. When you need to differ on one, add a single except clause:

strict except coercion     # units strict, but keep spreadsheet coercion
loose except dimensions    # loose values, but still catch unit mistakes

Omitting the header entirely is loose (today's behavior).

Under strict, a lossy value coercion in an arithmetic or comparison expression is a TYPE_ERROR rather than a silent conversion:

strict
A2 = A1 + 1      # errors if A1 is blank or a string like "5"
A2 = 40 + 2      # fine — no coercion, evaluates to 42

loose (the default) keeps the spreadsheet behavior: blank → 0 and "5" + 3 → 8. Genuine numbers are never affected either way — strictness only rejects the silent conversion, not the arithmetic.

Rollout note. strict governs the dimensional axis in full and the coercion axis for scalar arithmetic and comparison operands (the blank → 0 / "5" + 3 conversions above). Constant folding and affine-chain compaction operate only on numeric operands, which carry no coercion, so no real conversion slips past. Like TypeScript's strict, the switch gains coverage over time under one word.

Dimensional checking (the units axis)

The dimensional axis can also be set on its own with the legacy per-axis header (equivalent to the umbrella driving just that axis):

dimensions warn
dimensions strict
dimensions off

When omitted, Grid uses its undeclared default. warn emits diagnostics for incompatible concrete units/currencies, strict blocks the model on conflicts, and off skips dimensional checking. FX_RATE(base, quote) is directional (quote/base), so amountEUR * FX_RATE("EUR", "USD") is USD.

Dimensional inference flows through arithmetic, branches, aggregates, SQRT/POWER with static exponents, CONVERT, and static lookup return positions. Fully dynamic lookup/index results stay unknown unless Grid can prove a static return range, row, or column.

For deterministic model-owned rates, declare the rate cell explicitly:

fx_rate R1C2 = EUR/USD
R1C1 = 50EUR
R1C2 = 1.08
R1C3 = R1C1 * R1C2 + 10USD

The declaration gives R1C2 dimension USD/EUR; it does not fetch a rate or perform ambient conversion. The numeric rate must come from model data, a table, or an explicit connector input.

Matrix shapes and value bounds (static proofs)

Two more compile-time analyses ride the same warn-by-default, strict-promoted machinery as units:

  • Matrix shapes. Grid infers the rows × columns extent of ranges, array literals, and nested matrix calls, and flags impossible combinations at edit time — MMULT operands whose inner dimensions can't match, a non-square matrix fed to MINVERSE/MDETERM, a bad MUNIT size, SUMPRODUCT arguments of different shapes (GRID_MATRIX_SHAPE_MISMATCH).

  • Value bounds. Grid propagates numeric intervals through formulas. A VALIDATE clause on an input cell seeds the range other formulas observe; a VALIDATE clause on a computed cell is a static assertion the compiler proves (recorded as a proof), refutes (GRID_VALIDATE_UNSATISFIABLE), or reports as not statically checkable (GRID_VALIDATE_UNVERIFIED):

    input x = 0 VALIDATE 0..1
    y = x / (x + 1) VALIDATE 0..1    # proven at compile time
    z = x + 2      VALIDATE 0..1    # can never hold — flagged

Under dimensions strict (or the umbrella strict), shape mismatches and refuted VALIDATE assertions are compile errors rather than warnings. Anything the analyses cannot prove stays silent — unknown shapes and unbounded ranges never diagnose.

Tags are sticky: once set, a tag persists across recomputations until a different assignment overwrites or omits it. See assignments.md and coercion.md for the coercion rules behind tag checks.

5.5 Unary predicates — is a :predicate

Where a type says what a value is (a closed, single-lineage hierarchy — §5.2), a predicate says what is true about a symbol. Unary predicates are an open, multi-valued set of colon names orthogonal to the type. A fieldless predicate needs no declaration. A structured predicate uses a typed schema declared with predicate. The same schema types a relates to ... via :predicate(...) graph payload:

predicate reviewed(by: string, score: number)
 
x is :happy USD = 1.00          # type USD, plus predicate :happy
x is a :draft :urgent = 5       # several predicates, no type needed
x is a :reviewed(reviewer, 0.95) USD = 1.00
ready = x is :urgent            # → TRUE
checked = x is :reviewed         # → TRUE (presence, independent of fields)
reviewer1 = PREDICATES(x).reviewed.by  # → reviewer
reviewer2 = x.predicates.reviewed.by   # equivalent property-style access

The a or an after declaration-side is is optional and has no semantic effect; the colon distinguishes a predicate from a type tag. The declaration colon is optional too: predicate reviewed(...) and predicate :reviewed(...) declare the same schema. The former tag and relation declarations remain accepted aliases. The declaration fixes field order and type wherever :reviewed(...) is used. An undeclared predicate cannot carry fields. Both structured-access spellings above resolve through the same schema-checked path. Selecting the predicate without a field (PREDICATES(x).reviewed or x.predicates.reviewed) returns its Boolean presence; selecting a field on an absent predicate returns BLANK. A misspelled or undeclared field is a compile-time diagnostic, and PREDICATES(x) by itself is incomplete. TAGS(x) and .tags remain accepted aliases.

Declared field types are contracts, not loose type overlays. A payload value that cannot inhabit its field type produces #TYPE! even in loose mode, and a presence query cannot hide that invalid payload. Predicate declarations in imported .gs libraries supply the same field schema to the importer. The field names kind, relation_id, predicate, target, membership_id, and present are reserved for carrier-owned relationship, membership, or state metadata. The former carrier name tag also remains reserved for compatibility.

Unary predicates describe the symbol, not the value, so they do not flow through arithmetic: after y = x + 1, y is :happy is FALSE. A query on a symbol without that predicate is FALSE, never an error. Predicates are order-independent — the declaration may come after the query.

Structured unary predicates are single-valued by default. An optional trailing one makes that choice explicit. Repeating a single-valued predicate on one declaration is an error. Use many by (...) to retain several instances and name the fields that form a stable, possibly composite identity:

predicate reviewed(
  id: symbol,
  by: symbol,
  at: datetime
) many by (id)
 
x is :reviewed(:r1, :ada, NOW()) :reviewed(:r2, :lin, NOW()) = report
review_ids = PREDICATES(x).reviewed.id  # array: [:r1, :r2]
reviewers = x.predicates.reviewed.by    # array: [:ada, :lin]

Each identity field must name a declared field, may occur only once, and at least one is required. For a composite key such as many by (tenant, id), a keyed removal supplies values in exactly that order. Repeated attachment or addition of the same identity replaces its entire payload in place (last write wins) while preserving stable first-insertion order. Field access on a many predicate returns an array in that order; presence is true when at least one instance remains, and an absent/empty field view is BLANK.

Unary predicates are live: each colon name on a symbol compiles to one hidden reactive state binding, whether it holds one record or a collection, so structured updates are atomic. For a single predicate, mutate with += :reviewed(...) (add or replace) and remove with bare -= :reviewed. For a many predicate inside a rule, += requires the complete declared payload and upserts by identity, -= :reviewed(key, ...) removes one matching identity, and bare -= :reviewed removes all instances. Every reader reacts:

x is a :flagged = 5             # declared with the predicate on
WHEN risk > limit THEN
  x -= :flagged                 # reactively clear it
END
alert = x is :flagged           # flips to FALSE once the rule fires

A numeric compound assignment in a rule (x += 3) remains arithmetic — only a colon-predicate operand is treated as a predicate operation. A fieldless predicate may be introduced by a rule without a declaration; its base state is absent until an add rule fires. A structured predicate requires its declaration. Keyed removal of a many predicate is a reactive state operation and is therefore rule-only; a top-level bare remove can still clear its static base. The many designation governs predicate instances; graph relationship edges retain their own independent identity even though they share the predicate's field contract.

For reverse lookup, use the unified reactive MODEL.PREDICATES Graph view. Its unary portion is bipartite: a predicate vertex points to every model symbol to which it currently applies. MODEL.TAGS remains a unary-only compatibility view.

reviewed_symbols = MODEL.PREDICATES.NEIGHBORS(:reviewed)
predicates_on_a1 = MODEL.PREDICATES.IN_NEIGHBORS("A1")
review_instances = MODEL.PREDICATES.OUT_DEGREE(:reviewed)

The namespace distinction is exact: colon symbols such as :reviewed are predicate vertices, while model-symbol vertices are their canonical text names such as "A1" or "order". Thus a model symbol named reviewed is distinct from the predicate :reviewed; the leading colon provides the separate predicate namespace. NEIGHBORS returns each matching model symbol once. A many predicate creates one parallel :applies edge per identity, so OUT_DEGREE and EDGE_COUNT count instances. Declared fields, their .__type metadata, and the stable membership ID are edge properties. Predicate vertices publish kind: :predicate; membership edges publish kind: :applies plus predicate, target, and membership_id. Rule adds, keyed removals, and bare remove-all operations update this view reactively. Predicate views remain separate from MODEL.GRAPH, so unary predicates do not alter dependency topology, components, PageRank, or path results. Membership IDs are opaque versioned strings (currently tag-membership:v2:<sha256>), stable for the same predicate, target, and complete typed identity across payload-only upserts. Compare or retain the whole string; do not parse its digest.

5.6 Predicate rules and qualitative relations

Open predicates support finite rules, laws, graph quantifiers, constraints, and proof provenance:

predicate releasable means
  reviewed
  AND NOT blocked
  AND EVERY dependency IS :releasable
 
predicate touches IS symmetric
predicate contains IS inverse OF inside
predicate before IS transitive AND asymmetric
predicate owner IS functional
 
answer = EXISTS dependency OF service WHERE dependency IS :vulnerable
safe = NO path VIA flow* FROM public_input TO secret_output AVOIDS :sanitized
sealed = NO path VIA ingress THEN (transform OR archive)* THEN store
  FROM public_input TO secret_output AVOIDS :sanitized

The predicate introducer also disambiguates open logical laws whose names overlap qualitative relations. The corresponding bare law form is rejected.

Top-level MUST, MAY, and CANNOT declare modal constraints; in an assignment they query a proposition. therefore proposition checks that the current facts and rules entail a conclusion. Inspecting a query cell with gridctl cell why returns the rule/law proof chain. See the predicate guide for fixed-point and open-world semantics. In particular, unknown is possible but neither entailed nor prohibited: it makes MUST and CANNOT false and MAY true. Top-level modalities and assigned open-predicate MAY/CANNOT each name one ground atom. Assigned MUST may contain a compound proposition. The explicit p(x) := ... form remains available for advanced generated source. NO path without VIA selects only the model dependency relation. Paths compose with THEN, choose with OR, repeat with postfix *, and run backward with REVERSE; parentheses group a route. Their precedence is *, REVERSE, THEN, OR. Every relation consumes one edge; write repetition explicitly, such as flow*. Bare VIA flow remains a Predicate v3 compatibility spelling and formats as VIA flow*. A path WHY proof carries either a checked product-frontier certificate or the ordered counterexample route that disproves the claim.

A bare clause asserts a fact; the same clause in an assignment asks whether that fact must follow. Temporal, topological, and conceptual-set predicates share the same query, reactivity, contradiction, and explanation surface while keeping their inference components independent.

Temporal predicates

design before build
build before launch
 
answer = design before launch       # TRUE
possible = MAY(build meets launch)
ruled_out = CANNOT(launch before design)

Assertions may contain alternatives, complements, and ordinary reactive conditions:

packing (before OR meets) pickup
packing (NOT meets) pickup
packing before pickup UNLESS expedited
packing meets pickup IF expedited

RELATION(x, y) returns the possibilities left after closure as a structured relation_set with family, state, stable relations, and natural display. Its fields compose normally: after where = RELATION(x, y), where.family is "temporal", "topology", or "sets". INTERVAL(start, end) binds numeric, date, or datetime endpoints to a symbol; temporal queries derive the exact live relation from those endpoints and intersect it with authored facts. Endpoint types must match and start must precede end. RELATION_STATUS(x) reports the connected component's consistency status. The resident WHY(query_cell) inspection includes the active assertions and retained composition steps. The complete primitive vocabulary is before, starts, during, finishes, equals, finished by, contains, started by, overlapped by, met by, and after. Underscore spellings of the four multiword converses remain compatibility aliases.

Topological predicates

Topology is written as ordinary spatial language:

island separate from mainland
porch touches house
meadow partially overlaps floodplain
copy coincides with original
parcel inside district
district encloses parcel
road intersects property

inside and encloses mean proper containment regardless of boundary contact. Refine them only when the distinction matters:

shed inside lot with boundary contact
house inside lot without boundary contact
lot encloses shed with boundary contact
lot encloses house without boundary contact

intersects means anything except separate from. encloses is deliberately distinct from contains, whose temporal and collection meanings remain unchanged.

Conceptual-set predicates

Conceptual sets describe membership extents without enumerating their members:

vip_customers included in active_customers
employees disjoint from contractors
campaign_a overlaps with campaign_b
audience_a same members as audience_b

included in allows equality; strictly included in requires a proper subset. includes and strictly includes are the converses. overlaps with requires at least one shared member, while partially overlaps with also requires each set to have members outside the other. Empty extents are exact: two empty sets have the same members and are disjoint.

Named values ground these relations from their current direct members. Scalars act as singleton extents; arrays and native collections use the ordinary duplicate-insensitive, type-sensitive collection equality rules. A named Graph projection, such as MODEL.PREDICATES.NEIGHBORS(:reviewed), participates directly and recomputes reactively.

All three families support OR, family-local NOT, MUST, MAY, CANNOT, and reactive IF / UNLESS. RELATION and RELATION_STATUS normally infer the family from authored facts. If the same symbols participate in several families, select it explicitly:

when = RELATION(phase, release, temporal)
where = RELATION(phase, release, topology)
who = RELATION(cohort_a, cohort_b, sets)
state = RELATION_STATUS(phase, topology)

See the predicate guide for the complete authoring model, contradiction behavior, explanation, bounds, and the MODEL.PREDICATES projection.

The qualitative_relations spec in the repository covers the formal semantics, contradiction behavior, and MODEL.PREDICATES projection.


6. Expressions

The right-hand side of an assignment is an expression. Grid supports the full spreadsheet expression surface plus a set of readable extensions.

6.1 Operators by family

Family Operators
Unary +, -, !, NOT, reductions +/, */, &/, temporal prefix @
Access ., ?., [], ?[], slices [a:b:c], from-end [^n], header @"k" / @[...] / @![...]
Postfix % (percent), ! (factorial), !! (double factorial)
Power ^, ** (right-associative)
Combinatoric CHOOSECOMBIN, PERMUTEPERMUT, MULTICHOOSECOMBINA
Bitwise BITAND, BITOR, BITXOR, BITLSHIFT/SHL, BITRSHIFT/SHR
Multiplicative *, /, // (floor div), %%/MOD/MODULO
Domain %OFPERCENTOF, TOGROWTH
Additive +, -, ++VSTACK, ±/+/-UNCERTAIN
Concatenation &
Comparison =, <>/!=, <, <=, >, >=, <=>, IN, LIKE, BETWEEN, IS …, HAS, SUBSET OF, DIVIDES, COPRIME, …
Set UNION, INTERSECT, EXCEPT, EXCLUDE
Logical AND/&&, OR/||, XOR
Nullish ??, DEFAULT
Conditional THEN … ELSE
Apply / pipe OF, >>/|>
Clauses UNLESS, ASSERT … ELSE, WHERE, USING … AS

The extended infix operators (divisibility, collection relations, bitwise, PICK/OMIT/RENAME, INTO, conditional pipe steps) have their own deep dive in infix-operators.md.

6.2 Comparison details

0 <= score <= 100          # chained comparison (special-cased)
a <=> b                    # spaceship: -1 / 0 / 1
x BETWEEN 1 AND 10         # range test, plus NOT BETWEEN
name LIKE "J*n"            # wildcard match, plus ILIKE / NOT LIKE
text CONTAINS "needle"     # case-sensitive contiguous substring
[1, 2, 3] CONTAINS [2, 3] # contiguous ordered elements
value IN [1, 2, 3]         # direct-element membership, plus NOT IN

IN and HAS are converse spellings and compare one whole direct element with deep, type-sensitive equality; they never recursively flatten a nested value. For 2-D arrays, direct elements are rows, including column literals: [1; 2; 3] exposes one-cell rows, so 1 IN [1; 2; 3] is false while [1] IN [1; 2; 3] is true. SUBSET OF and SUPERSET OF use those same direct elements with unordered, duplicate-insensitive set semantics. CONTAINS instead requires a contiguous run in an ordered value (text, array, tuple, vector, or deque) and is case-sensitive for text. Set and map membership operates on keys; unordered sets and maps do not support CONTAINS.

A scalar used where a membership or set-relation operand is expected is a singleton collection. Thus 1 IN 1, 1 SUBSET OF [1, 2], and [1, 2] SUPERSET OF 1 are true. Empty-set laws apply: [] SUBSET OF x is true and [] OVERLAPS x is false. For ordered containment, a scalar right operand is one-element pattern and an empty pattern always matches. Callable IN requires a value plus at least one candidate; CONTAINS, SUBSET, SUPERSET, and OVERLAPS require exactly two arguments; ISEMPTY requires exactly one.

ILIKE is wildcard matching rather than a second containment operator. Use text ILIKE "*needle*" for a case-insensitive substring test. The one contextual exception to ordinary HAS membership is a value declared by CHOICES: there flags HAS requested means every requested bit is set, as described in CHOICE and CHOICES.

Errors participate as structural values identified by error code; diagnostic message text does not affect membership. Two #VALUE! values therefore match, while #VALUE! and #N/A do not. Non-finite numbers retain numeric equality: NaN never equals any value (including another NaN), while infinities compare equal only when their signs match.

Set-like relations index and deduplicate one candidate side, then verify hash collisions with full structural equality. Ordered collection CONTAINS uses linear-time sequence matching with a direct single-element fast path. These paths do not recursively flatten operands or warm a descriptor's compatibility row cache.

Bare comparison is non-associative: 0 < x < 10 is not valid as a generic chain — use the special chained form above, BETWEEN, or 0 < x AND x < 10.

6.3 IS predicates

IS lowers to a type or domain predicate; IS NOT negates:

x IS BLANK        x IS EMPTY        x IS NUMBER       x IS NOT TEXT
x IS ERROR        x IS NA           x IS DATE         x IS DURATION
n IS ODD          n IS EVEN         n IS PRIME        n IS MULTIPLE OF 5
x IS URL          x IS COLOR        x IS COMPLEX

6.4 Access, slicing, and chaining

obj.field           obj["field"]           obj?.maybe?.deep
data[1]             data[-1]    data[^1]    # last element
data[2:5]           data[:3]    data[3:]    data[1:10:2]
data@"amount"       data@["id", "amount"]   data@!["secret"]

6.5 Reductions and broadcast-dot

+/ A1:A10           # sum-reduce        */ A1:A10   # product-reduce
&/ A1:A10           # concat-reduce
SIN.([1, 2, 3])     # broadcast a scalar function over an array
ROUND.(values, 2)   # broadcast with extra args

6.6 Domain and special operators

25 %OF 200          # PERCENTOF(25, 200)
100 TO 200          # growth/range domain operator
5 CHOOSE 2          # COMBIN(5, 2)
100 +/- 5           # uncertainty → UNCERTAIN(100, 5)
(A1 * 2 + 10) CLAMP[0, 100]   # clamp suffix

7. Operator Precedence

From tightest (1) to loosest (19). When in doubt, parenthesize.

Level Family Operators
1 unary @, +, -, !, NOT, +/, */, &/
2 access ., ?., [], ?[], [a:b], [a:b:c], [^n], @key, @[...], @![...]
3 postfix %, !, !!
4 power ^, ** (right-associative)
5 combinatoric CHOOSE, PERMUTE, MULTICHOOSE
6 bitwise BITAND, BITOR, BITXOR, SHL, SHR
7 multiplicative *, /, //, %%, MOD, MODULO
8 domain %OF, TO
9 additive +, -, ++, ±
10 concatenation &
11 comparison =, <>, !=, <, <=, >, >=, <=>, IN, LIKE, BETWEEN, IS …, HAS, SUBSET OF, DIVIDES, COPRIME, CLAMP[…]
12 set UNION, INTERSECT, EXCEPT, EXCLUDE
13 logical AND, &&, OR, ||, XOR
14 nullish ??, DEFAULT
15 conditional THEN … ELSE
16 apply OF (right-associative; reversed >>)
17 pipe / clause >>, |>, UNLESS
18 clause ASSERT, ASSERT … ELSE
19 clause WHERE

The decisions that bite most often:

  1. Power is right-associative: 2 ^ 3 ^ 2 = 2 ^ (3 ^ 2) = 512.
  2. & binds tighter than comparison: "a" & "b" = "ab" compares the concatenation, as expected.
  3. Comparison is non-associative: write x BETWEEN 0 AND 10, not a bare 0 < x < 10 chain.
  4. THEN … ELSE is below logical: A AND B THEN x ELSE y reads as (A AND B) THEN x ELSE y.
  5. Pipes are below conditionals: cond THEN x ELSE y >> f() reads as (cond THEN x ELSE y) >> f().
  6. Combinatoric binds tighter than *: 5 * 3 CHOOSE 2 is 5 * COMBIN(3, 2).

8. Control Flow

Grid is an expression language: control flow produces values rather than performing statements. Every form below is an expression you can assign, nest, or pass as an argument.

8.1 Conditional — THEN … ELSE

The single-condition form. Reads top to bottom, may chain:

C1 = score > 0 THEN "positive" ELSE "non-positive"
C2 = x > 90 THEN "A" ELSE x > 80 THEN "B" ELSE "C"   # chained

A chained ladder may wrap across lines at each THEN / ELSE (see §14):

C1 = score > 90 THEN "A"
   ELSE score > 80 THEN "B"
   ELSE "C"

The ladder folds at every point that expects a continuation — a line-ending THEN/ELSE, or a continuation line beginning with ELSE. A completed ladder still ends at the newline. For very long ladders, CASE or MATCH may still read better.

8.2 Multi-branch — CASE WHEN

When each branch tests a different condition; desugars to IFS. Spans multiple lines and ends with END:

tier = CASE
  WHEN score >= 0.9 THEN :excellent
  WHEN score >= 0.7 THEN :good
  WHEN score >= 0.5 THEN :fair
  ELSE :poor
END

8.3 Value matching — MATCH

When every arm compares the same subject; desugars to IFS. _ is the catch-all. MATCH is a call, so its arms may stay on one line or wrap across lines (§14.1):

color = MATCH(status, "draft" -> :gray, "live" -> :green, _ -> :red)

Error-kind arms. When any arm is an error literal, MATCH branches on the subject's error kind by name, without propagating: value arms are guarded so an error subject skips them, and the error arms discriminate the kind (#NULL!, #DIV/0!, #VALUE!, #REF!, #NAME?, #NUM!, #N/A). This makes errors typed and matchable rather than something a formula can only swallow whole with TRY / IFERROR:

safe = MATCH(risky,
  #DIV/0! -> 0,          # divide-by-zero → 0
  #N/A    -> BLANK,      # missing lookup → blank
  _       -> risky)      # anything else (incl. a plain value) passes through

8.4 Local bindings — DO … END

Introduce named intermediates ending in a result expression. Pure blocks desugar to LET; blocks containing async-worker calls become dependency-aware workflows:

B3 = DO
  margin = revenue - cost
  deduction = 2500
  MAX(margin - deduction, 0)
END

LET(name, value, body) is also available as a function; DO is the canonical multi-line form.

Async bindings start as soon as their arguments and active branch are ready. Independent calls may run concurrently; a binding that reads an earlier result waits for it. The final expression is an implicit join over every call started by the block.

result = DO
  a = HTTP_JSON(url_a)          # starts with b
  b = HTTP_JSON(url_b)
  combine(a, b)                 # waits for both
END
 
page2 = DO
  first = HTTP_JSON(url_a)
  second = HTTP_JSON(first.next_url)  # waits for first by dependency
  second
END

AWAIT value adds an ordering barrier for later async calls without changing the value. DO SYNC inserts that barrier after every async binding:

result = DO
  first = MUTATE_A()
  second = MUTATE_B()           # may start with first
  AWAIT first
  second
END
 
result = DO SYNC
  first = MUTATE_A()
  second = MUTATE_B()           # starts after first settles
  second
END

AWAIT on a synchronous expression is a compile error. Unchosen conditional branches never launch their calls. A failed stage prevents dependent stages and fails the block; v1 has no implicit retry, cancellation, or timeout.

8.4.1 Bounded effect traversal

MAP, FILTER, REDUCE, and the other ordinary collection operators require pure lambdas. Grid infers job-queue effects from the lowered lambda body, including the body of an inlined named function, and rejects an effectful lambda in those operators. Use an explicit traversal when one effect should run for each element:

serial = TRAVERSE(urls, url => HTTP_JSON(url), 100)
parallel = PAR_TRAVERSE(urls, url => HTTP_JSON(url), 8, 100)

The final argument is a static safety limit, not a batch size. It defaults to 64 and must be an integer from 1 through 256. If the runtime collection is larger, the traversal fails with ASYNC/TRAVERSE_LIMIT_EXCEEDED before launching any request. PAR_TRAVERSE also requires a literal concurrency bound that is no greater than the limit.

TRAVERSE admits one request at a time. PAR_TRAVERSE uses a sliding window, so completing any slot admits the slot one window-width later; it does not wait for a whole batch. Results are always assembled in source order, independent of completion order. Arrays traverse row-major, a scalar is a singleton, and a top-level blank is empty. Native iterable collections traverse their stable logical elements; keyed collections use the same entry values as REDUCE. Non-iterable sketches and streams fail with ASYNC/TRAVERSE_SOURCE before an effect launches. An algebraic choice remains one scalar value even though its resident representation is a sealed record.

The lambda must contain exactly one inferred job-queue call per element. The source expression itself must be pure and already available. Grid evaluates it once before admitting element effects, and callback-local bindings cannot capture the compiler's element read. Publish an async source result first instead of hiding a second effect in the traversal source. Failure is fail-fast: a failed request becomes the traversal result and suppresses dependent requests that have not started; already-started parallel requests are not rolled back. Traversal itself performs no implicit retry. Function/connector timeout and retry policy remains owned by the selected job route, and a terminal timeout or exhausted retry is an ordinary failed request that follows the same fail-fast rule. This keeps retries idempotency-aware instead of silently replaying arbitrary effects in the formula evaluator.

8.5 Error-tolerant bindings — WITH … THEN … ELSE

Bind a sequence of values; if any step errors, return the ELSE fallback. Bindings may span lines (each , at end of line), and the THEN <expr> ELSE <fallback> tail folds at THEN/ELSE too:

result = WITH data = HTTP_JSON(url), first = data.results[1]
THEN first.name
ELSE "unavailable"

8.6 Inline clauses — WHERE / USING / UNLESS / ASSERT

total + tax WHERE total = SUM(A1:A10), tax = total * 0.21
total + tax USING total AS SUM(A1:A10), tax AS total * 0.21
amount UNLESS amount = 0                  # IF(NOT(cond), value)
value ASSERT value > 0 ELSE #N/A          # guard with custom error

WHERE and USING are post-fix binding clauses (both desugar to LET). UNLESS suppresses a value on a condition. ASSERT short- circuits a value through a predicate.

8.7 Error recovery — TRY / IFERROR / DEFAULT

TRY primary() THEN backup() ELSE "n/a"    # try chain
IFERROR(risky(), 0)                       # function form
A1 DEFAULT 0                              # blank-or-error fallback (?? is sugar)
A1 ?= MAYBE_FAIL()                        # conditional assignment

Full error semantics live in errors.md.


9. Functions And Calls

Grid ships a large built-in catalog (2,000+ functions). Function names are case-insensitive at parse time; emit them uppercase. Names may contain dots (Z.TEST, CEILING.MATH, INTERPOLATE.LINEAR).

SUM(A1:A10)
ROUND(3.14159, 2)
Z.TEST(data, 0.5)

9.1 Named arguments

ROUND(3.14159 AS number, 2 AS digits)
FX_RATE("GBP" AS base, "USD" AS quote)

AS binds the preceding value by parameter name; positional and named arguments may be mixed with positional first.

9.2 Variadics and partial application

SUM(1, 2, 3, 4)             # variadic tail
ADD(5, _)                   # partial application → a lambda value

A _ placeholder inside a call (outside a higher-order helper) produces a lambda: ADD(5, _)LAMBDA(__p, ADD(5, __p)).

9.3 Execution classes

Class Meaning
sync_builtin Returns immediately in the formula evaluator
async_worker Enqueues a job; result is written back asynchronously

External functions (FX_RATE, HTTP_JSON, ML_SCORE, AI_PROMPT, ASK_*, PG_SELECT) are async_worker. See external-functions.md.

9.4 Finding functions

  • functions.md — every function by category, with signature, return type, and aliases.
  • function-compatibility.md — each name's compatibility label (excel_exact, grid_extension, …) and aliases.

Alongside the familiar spreadsheet catalog, Grid ships an analytical and scientific-computing surface as ordinary functions (all grid_extension):

  • OptimizationLINEAR_PROGRAM, MIXED_INTEGER_PROGRAM, LINEAR_PROGRAM_SENSITIVITY (shadow prices, reduced costs, IIS), MINIMIZE / MAXIMIZE, MINIMIZE.GLOBAL / MAXIMIZE.GLOBAL, BOUNDED_MINIMIZE_N, GOALSEEK, PORTFOLIO_QP_WEIGHTS. For a declarative goal-seek over cells, prefer the SOLVE statement.
  • Differential equationsODE_SOLVE, ODE_FINAL, ODE_SAMPLE, ODE_EVENT_TIME, HEAT_SOLVE_1D / HEAT_SOLVE_2D, PDE_SOLVE_1D (adaptive Dormand-Prince with automatic stiff/TR-BDF2 switching).
  • Spectral linear algebraEIGEN.VALUES / EIGEN.VECTORS, EIGEN.VALUES.COMPLEX, EIGEN.SPECTRAL_RADIUS, SVD.VALUES / SVD.U / SVD.V / SVD.RANK.
  • Nonlinear solve and fitNSOLVE, CURVE_FIT, FINDROOT, interpolation and splines (INTERPOLATE.CUBIC, INTERPOLATE.PCHIP).
  • Signal processingSIGNAL.PSD, WINDOW.HANN / HAMMING / BLACKMAN, POWER_SPECTRUM, DFT / FFT, CONVOLVE, CORRELATE.
  • Symbolic mathematics — the SYMBOLIC.* family over symbol declarations: SYMBOLIC.CANONICALIZE / SIMPLIFY / EXPAND / FACTOR / COLLECT / CANCEL, SYMBOLIC.D, SYMBOLIC.SOLVE, SYMBOLIC.RATIONAL / APPROXIMATE, SYMBOLIC.ASSUMPTIONS / DOMAIN / DECIDE, SYMBOLIC.SUBSTITUTION / EVALUATE. Exact, assumption-aware, and certificate-carrying; see symbolic-mathematics.md.

Worked examples for these live in cookbook.md.

MIXED_INTEGER_PROGRAM requires a solver-enabled build; on other builds it returns an explicit capability error rather than a wrong answer.


10. Lambdas And Higher-Order Forms

10.1 Lambda syntax

x => x * 2                  # single-parameter arrow
(acc, x) => acc + x         # multi-parameter arrow
LAMBDA(x, x + 1)            # function form
_                           # placeholder lambda (single)
_1, _2                      # positional placeholders

Prefer arrow lambdas. Reserve LAMBDA(...) for when you want an explicit named function value. Use placeholders (_, _1, _2) only where the surrounding higher-order helper owns the element binding, or where _ marks the whole-value insertion slot of a pipe call. In a functional FILTER pipe step, prefer the named form FILTER(value => predicate) so the element binding cannot be confused with pipe insertion.

10.2 Higher-order helpers

MAP(A1:A10, x => x * 2)
REDUCE(0, A1:A10, (acc, x) => acc + x)
SCAN(0, A1:A10, (acc, x) => acc + x)
BYROW(matrix, row => SUM(row))
BYCOL(matrix, col => AVERAGE(col))
MAKEARRAY(rows, cols, (r, c) => r * c)

For a callback with local bindings, use a named trailing block:

MAP(rows) WITH row DO
  amount = row.amount DEFAULT 0
  ROUND(amount * exchange_rate, 2)
END

This lowers to the helper's existing final lambda argument. MAP takes one name per source; REDUCE, SCAN, and MAKEARRAY take two; ITERATE, BYROW, and BYCOL take one.


11. Arrays, Spilling, And Comprehensions

11.1 Spilling

A formula that returns an array writes (spills) into a rectangle of cells anchored at its target. Refer to the whole spilled block with the # suffix:

A1 = SORT(B1:B100, -1)      # spills down from A1
C1 = SUM(A1#)               # sum the whole spilled rectangle

If a spill would overwrite a populated cell, the anchor becomes #SPILL!.

11.2 Comprehensions

Array comprehensions read more naturally than MAKEARRAY for index-driven fills; they lower to a single array cell:

[x * 2 FOR x IN A1:A10 IF x > 0]
[r * c FOR r IN 1..100, c IN 1..10]

11.3 Sequences

1..10           # inclusive 1 through 10
1..<10          # exclusive upper bound
1..2..10        # step of 2
d"2026-01-01"..d"2026-12-31"   # date sequence

11.4 Constant fills

Prefer array-valued formulas over giant range targets:

A1 = ZEROS(1000, 1)         # column of zeros
A1 = ONES(1000, 1)
A1 = FILL("TBD", 1000, 1)   # any constant
A1 = REPEAT(0, 1000)

See assignments.md.


12. Pipelines And Infix Sugar

12.1 Pipe — >> / |>

Thread a value through a sequence of calls. _ marks the insertion point when it isn't the first argument:

A1 = data >> FILTER(value => value > 0) >> SORT(_, -1) >> TAKE(5)
B1 = data |> WHERE("amount > 0") |> COLLECT()

Desugars: x >> f()f(x); x >> f(_, y)f(x, y).

FILTER has one additional pipe rule:

values |> FILTER(value => predicate)
values |> FILTER(value => predicate, if_empty)

These lower respectively to FILTER(values, MAP(values, value => predicate)) and FILTER(values, MAP(values, value => predicate), if_empty). Outside a pipe, the ordinary signature remains FILTER(values, include_mask [, if_empty]). The legacy pipe spelling FILTER(_, _ > 0) is accepted and normalized to the same tree, but named lambdas are canonical.

Every pipe evaluates stages from left to right. Desugaring occurs before MIR, so generic evaluation and compiler fusion share one semantic tree; optimization does not change ordering, errors, or the authored empty fallback. See collections.md for collection residency and incremental-performance behavior.

12.2 Conditional pipe step

Append IF cond to apply just that step conditionally:

A1 = x >> ABS() IF x > 0     # IF(x > 0, ABS(x), x)

12.3 Infix apply — OF

The function-first mirror of >> (right-associative):

ABS() OF A1                    # ≡ A1 >> ABS()
ROUND(_, 2) OF ABS() OF A1     # ≡ A1 >> ABS() >> ROUND(_, 2)

The left operand must be a call; the right operand must be a value.

12.4 Projection and cast

users PICK id, glob"*name*"        # bare identifiers are literal keys
profile OMIT glob"*token*"         # drop matching keys
table RENAME "Revenue" AS "rev"    # rename keys/headers
amount INTO currency               # TYPE_TAG(amount, "currency")

12.5 Native relational query syntax

Grid accepts a first SQL-shaped relational expression:

Active =
  SELECT status, SUM(amount) AS total, COUNT(DISTINCT customer) AS customers
  FROM Orders
  WHERE status IN ("paid", "pending") OR amount BETWEEN 10 AND 30
  GROUP BY status
  HAVING total > 100
  ORDER BY total DESC
  LIMIT 25

This is source syntax, not a SQL string. The compiler lowers it to a structured relational call:

REL_SELECT(source, projection, predicate, order, limit, group, having)

The language promise is one relational meaning with source-dependent physical execution. Header arrays and Tuple/Vector/Deque collections of Record rows execute natively today. Queries preserve the collection sequence family and return projected Record rows. Unknown columns and inconsistent record schemas are errors rather than silently omitted data. Connector-produced Frame sources also execute as native query plans and can be chained as the source of another SELECT. Warehouse-backed plans push this relational subset to BigQuery, Snowflake, Redshift, and Databricks and execute supported residuals in DataFusion. WHERE accepts scalar comparisons, IS NULL, IS NOT NULL, IN, inclusive BETWEEN, and SQL LIKE/ILIKE, including their negated forms, composed with NOT, AND, OR, and parentheses. % matches zero or more characters and _ matches one character; patterns are string literals limited to 4,096 bytes. IN accepts up to 1,024 non-blank scalar literals. SQL precedence applies: NOT binds first, then AND, then OR. Projection items may be columns, aliased columns, or numeric scalar expressions using literals, columns, parentheses, unary +/-, and +, -, *, /, %. Portable scalar calls are COALESCE (two to sixteen arguments), two-argument NULLIF, and unary ABS, SQRT, FLOOR, CEIL, ROUND, LOWER, UPPER, and LENGTH, plus two-argument POWER. CEILING is accepted as an authoring alias for CEIL. Searched CASE WHEN predicate THEN scalar ... ELSE scalar END and simple CASE scalar WHEN scalar THEN scalar ... ELSE scalar END accept one to 64 arms; computed items require AS. Division or remainder by zero produces blank/NULL; so does SQRT of a negative value. ROUND rounds halves away from zero. POWER returns blank/NULL for a negative base with a fractional exponent, zero with a negative exponent, or a non-finite resident result. NULLIF(left, right) returns blank/NULL when both non-null arguments compare equal, otherwise it returns left. CASE evaluates arms in order, selecting only the first TRUE condition; FALSE and UNKNOWN fall through, and an omitted ELSE returns blank/NULL. The compiler emits canonical scalar-equality arms for simple CASE; blank/NULL discriminators do not match blank/NULL arms and fall through according to SQL three-valued logic. The canonical predicate and scalar trees are consumed by resident collections, Frames, Polars, DataFusion, and provider parameter binding. Blank/NULL equality is explicit, ordered null comparisons are rejected, and nulls sort last in either direction. Other predicates use SQL three-valued logic: blank input produces UNKNOWN, negation preserves it, and only TRUE passes a filter. GROUP BY supports COUNT(*) plus COUNT, COUNT(DISTINCT ...), SUM, AVG, MIN, and MAX over the same portable scalar-expression tree used by projections and predicates, with explicit aliases. For example, SUM(price * quantity) AS revenue, AVG(ABS(delta)) AS mean_delta, and COUNT(DISTINCT LOWER(email)) AS users are portable across resident, Frame, Polars, DataFusion, and supported warehouse execution. COUNT ignores blank arguments, while the other aggregates ignore blanks and return blank for an empty input. At most 64 group keys and 256 aggregate outputs are accepted per query.

SELECT DISTINCT ... removes duplicate projected rows before the outer ORDER BY and limit stage. LIMIT n, FETCH FIRST n ROW|ROWS ONLY, and FETCH NEXT n ROW|ROWS ONLY are equivalent spellings. Duplicate blank/NULL values collapse to one row. Header arrays retain their header, native record sequences retain their Tuple/Vector/Deque family, and source-proven Frames lower to the same typed distinct stage used by Polars and DataFusion execution.

Complete SELECT results compose with SQL set operators:

SELECT id FROM CurrentCustomers
UNION ALL
SELECT customer_id FROM ImportedCustomers
EXCEPT
SELECT customer_id FROM SuppressedCustomers
ORDER BY id
LIMIT 1000

UNION, INTERSECT, and EXCEPT all accept ALL. Without ALL, duplicate typed rows collapse and blank/NULL rows compare equal. With ALL, SQL multiset counts are preserved. Arms match columns by position, must have equal column counts, and retain names from the left arm. INTERSECT binds before UNION and EXCEPT; UNION and EXCEPT associate left to right. The final ORDER BY and the canonical limit stage apply to the complete set expression. Resident arrays, native record sequences, and source-proven Frames share this contract.

Joins require explicit, distinct source aliases and qualified column names:

SELECT o.id AS order_id, c.name AS customer
FROM Orders AS o LEFT JOIN Customers AS c ON o.customer_id = c.id

INNER, LEFT, RIGHT, and FULL joins accept equality conditions joined by AND, up to 16 key pairs. Null/blank keys never match. Resident array and record sources and source-proven Frames use bounded typed hash joins; exceeding two million resident output rows is an explicit error. HAVING is evaluated after grouping and before ordering, limit, and final projection.

Parenthesized queries are relation-valued derived tables, and non-recursive SQL common-table expressions are lexical relation bindings:

WITH active AS (SELECT id, amount FROM Orders WHERE amount > 0)
SELECT q.id FROM (SELECT id, amount FROM active) AS q WHERE q.amount >= 20

Up to 64 case-insensitively distinct CTE names are accepted. Source-proven CTE chains fuse into the same physical Frame region as derived tables. A CTE body must be a SELECT (or another SQL WITH query). Single-source aliases are valid throughout projection, filtering, grouping, windows, HAVING, and ordering. Resident header arrays, Tuple<Record>, Vector<Record>, or Deque<Record> support resource-governed recursive binding groups:

WITH RECURSIVE nums AS (
  SELECT n FROM Seed
  UNION ALL
  SELECT n + 1 AS n FROM nums WHERE n < 100
)
SELECT n FROM nums ORDER BY n

The recursive term reads the previous frontier. UNION retains only novel rows, while UNION ALL preserves duplicates. Authored SQL runs until fixpoint without a fixed iteration ceiling. Execution reports an error at 2,000,000 output rows or when the resident-work budget is exhausted; it never returns a silently partial result. Column lists rename the established seed schema and are enforced by arity. Multiple bindings use simultaneous working-table semantics: every recursive term reads all prior frontiers, and no next frontier becomes visible until the following iteration. Source-proven recursive groups promote to one resident frame.recursive_cte_group region, including column lists, multiple and mutually recursive bindings, set expressions, and recursive joins against additional resident Frame handles. DataFusion and connector inputs can feed those handles after hydration; recursive SQL pushdown into a remote provider is not required for exact execution.

Uncorrelated, one-column subqueries are accepted as scalar comparison operands and as the value source for IN during resident execution. A scalar subquery returns blank for zero rows and errors above one row. Equality-correlatable EXISTS, NOT EXISTS, IN, and NOT IN subqueries over explicitly aliased sources are decorrelated into duplicate-free composite-key hash semi/anti joins; inner-local predicates and trailing outer AND predicates are retained. Correlated NOT IN is null-aware: a null in the matching inner partition produces SQL UNKNOWN instead of incorrectly passing the outer row. Correlated scalar aggregates are also accepted as explicitly aliased projections:

SELECT c.id,
       (SELECT SUM(o.amount) AS total
        FROM Orders AS o
        WHERE o.customer_id = c.id AND o.status = "paid") AS spend,
       (SELECT COUNT(*) AS total
        FROM Orders AS o
        WHERE o.customer_id = c.id AND o.status = "paid") AS paid_orders
FROM Customers AS c

The inner query must project exactly one aggregate and may use one to 16 equality correlation keys plus inner-local AND filters. Grid groups and indexes each inner query once rather than evaluating it for every outer row. A missing group yields zero for COUNT and blank/NULL for the other aggregates. Multiple projections compose for resident relations and source-proven Frames. Correlation under general boolean OR is not yet accepted.

Window projections use function(...) OVER (...) AS name. A reusable complete window specification can be named in the query's WINDOW clause and referenced as OVER name:

SELECT id,
       ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC, id) AS row_num,
       LAG(amount, 2, 0) OVER (PARTITION BY region ORDER BY id) AS prior,
       SUM(amount) OVER (
         PARTITION BY region ORDER BY id
         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ) AS rolling
FROM Orders
SELECT id, ROW_NUMBER() OVER newest AS row_num
FROM Orders
WINDOW newest AS (PARTITION BY region ORDER BY amount DESC, id)
QUALIFY row_num = 1

Supported functions are ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST, LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE, and windowed COUNT, SUM, AVG, MIN, and MAX. Ranking, distribution, navigation, and value functions require window ordering. NTILE uses a positive bucket count and NTH_VALUE uses a one-based positive index. Navigation offsets are non-negative integers no greater than 1,000,000. Their optional third argument is a number, string, boolean, or BLANK literal returned when the requested row is outside the partition; omission defaults to blank. Aggregate windows without ordering cover the whole partition; ordered aggregate windows use the peer-aware default frame. Aggregate and value windows also accept ROWS BETWEEN <bound> AND <bound> or RANGE BETWEEN <bound> AND <bound>, where a bound is UNBOUNDED PRECEDING, n PRECEDING, CURRENT ROW, n FOLLOWING, or UNBOUNDED FOLLOWING; offsets are capped at 1,000,000 and the start cannot follow the end. Offset RANGE frames require exactly one finite numeric order key; peer-only ranges may use multiple order keys. Polars fails closed on RANGE, while resident, Frame, DataFusion, and warehouse paths preserve it. A frame wholly outside a partition is empty. Windows in a grouped query run after aggregation and HAVING, so they may order, partition, or aggregate over grouped output aliases. QUALIFY runs after those analytic outputs are projected and before final DISTINCT, ORDER BY, and result extent. It may therefore refer to an analytic projection alias. Named windows are complete specifications: OVER name does not accept a partial override or inheritance clause.

The full operator-by-operator treatment — divisibility, collection relations, bitwise, projection, cast, conditional pipe — is in infix-operators.md.


13. Reserved Words

Grid's grammar is contextual — it has no truly reserved words, so a clever parse can often accept a keyword as an identifier. Don't rely on that. The structural keywords below are effectively reserved: using them as binding names creates ambiguous parses and breaks under grammar extensions.

Structural / block keywords:

MODEL  DESCRIPTION  VERSION  AUTHOR  TAGS  RUNTIME  END
WHEN   CHANGES   BECOMES   EVERY   AT   THEN   ELSE   DO   CASE   WITH   MATCH
LAMBDA   LET   TRY   ASSERT   WHERE   USING   UNLESS   USE
INPUT  OUTPUT  DEFAULT  RESET  CLEAR  IS   INTO   ONCE   SKIP   MISSED
BACKFILL   DEBOUNCE   THROTTLE   FOR   IN   AS   STRICT   LOOSE
SERIAL   CONCURRENT   LATEST   QUEUE   LEDGER   OVERFLOW
DIMENSIONS   COERCION
GAME   MECHANISM

STRICT / LOOSE lead the strictness directive (with EXCEPT, which is also the set-difference operator); DIMENSIONS / COERCION name the axes. DEFAULT is also the null-fallback operator; RESET / CLEAR are also rule-action modes. They are contextual — default leads an assignment, RESET / CLEAR lead a rule action — so they don't collide in practice, but don't bind values to those names.

GAME <name> { ... } and MECHANISM <name> { ... } introduce the optional declarative strategic-model blocks documented in game-theory.md. Their lookahead is deliberately narrow: the legacy-compatible bindings GAME = value and MECHANISM = value remain ordinary assignments.

GRAMMAR, GENERATOR, PATTERN, THEORY, ALGEBRA, TRANSFORM, TRANSLATE, SOLVER, and THEOREM introduce the grammar-and-tree declaration blocks documented in grammars.md. They are contextual in the same way: an ordinary identifier with one of those spellings keeps its existing meaning unless the complete declaration header matches.

symbol <name> declares an immutable mathematical unknown for Grid-native symbolic mathematics, documented in symbolic-mathematics.md. It is contextual the same way — symbol = 4 remains an ordinary binding named symbol; only the complete declaration form declares an unknown.

Operator-word keywords (also avoid as names):

AND  OR  XOR  NOT  MOD  MODULO  DIVIDES  COPRIME  CHOOSE  PERMUTE
MULTICHOOSE  BITAND  BITOR  BITXOR  SHL  SHR  UNION  INTERSECT  EXCEPT
EXCLUDE  HAS  SUBSET  SUPERSET  OVERLAPS  LIKE  ILIKE  BETWEEN  CONTAINS
STARTS  ENDS  PICK  OMIT  RENAME  OF  TO  DEFAULT  TRUE  FALSE  BLANK

A note on LEFT / RIGHT: bare (no parentheses) they are spatial references; with arguments (LEFT(text, n)) they are text functions. Don't bind a value named LEFT or RIGHT.

Pick descriptive names (revenue, total_cost, is_active) and this never bites you.


14. Multi-line And Layout Rules

Most statements live on one line. Newlines either fold (continue the current expression) or separate statements, depending on context.

14.1 Multi-line Rules (important)

Newlines fold (the expression continues) inside:

  • Open (...), [...], {...} — function-call argument lists, parenthesized expressions, array literals, object literals, comprehensions, and MATCH(...) (which is a call).
  • DO … END bodies (between bindings and the result).
  • CASE WHEN … ELSE … END (between arms; ELSE may start its own line).
  • WHEN / EVERY / AT rule bodies.
  • WITH binding lists (after a trailing ,).
  • THEN … ELSE ladders and the WITH … THEN … ELSE tail, at each THEN/ELSE. The fold is scoped to those keywords: a line-ending THEN/ELSE continues, and a continuation line may start with ELSE. A completed ladder still ends at the newline, so the next statement is never absorbed.

Newlines separate statements everywhere else.

# Folds — inside brackets
A1 = SUM(
  B1, B2,
  B3
)
 
# Folds — DO body
B1 = DO
  x = 1
  y = 2
  x + y
END
 
# Folds — chained THEN/ELSE wraps at each THEN/ELSE
C1 = a THEN 1
   ELSE b THEN 2
   ELSE 3

14.2 Other layout conventions

  • One statement per line is the canonical style.
  • Put spaces around operators: A1 = SUM(B1:B5).
  • Trailing commas are allowed in calls, arrays, objects, and MATCH arms.

See style-guide.md for the full canonical style.


15. See Also