Skip to content

DSL reference

Syntax reference for .atl files, covering entities, hypertables, queries, procedures, jobs, workflows, ephemerals, and enums.

Syntax reference for .atl files.

Notation

In the grammar productions below:

  • [X] is optional.
  • {X} is zero or more.
  • X | Y is alternation.
  • Quoted text appears literally; Ident, Type, etc. are productions.

Whitespace separates tokens but is otherwise insignificant. Line comments start with // and run to end of line. There are no block comments.

Top-level

File         = { Declaration }
Declaration  = Entity | Hypertable | Query | Procedure
             | Job | Workflow | Ephemeral | Enum

Entities

Entity = "entity" Ident "in" Ident "{" EntityBody "}"

EntityBody =
    { FieldDecl }
    [ "primary" "by" IdentList ]
    { "unique" "by" IdentList [ "deferrable" ] }
    { "index" "by" IndexFieldList }
    { [ "unique" ] "index" "partial" "by" IdentList "where" PartialPredicate }
    { "index" "hnsw" "on" Ident "ops" VectorOps }
    { "index" "gin" "on" Ident }
    { ("has_many" | "has_one") Ident ":" Ident "via" Ident }
    [ "soft_delete" "by" Ident ]
    [ "touch_on_update" "by" Ident ]
    [ "partition" "by" Ident ]
    [ "table" StringLiteral ]
    [ "ttl_field" Ident ]
    [ "query_timeout" "=" Duration ]
    [ "keyless" ]
    { "check" "\"" SQLExpr "\"" [ "as" Ident ] }
    [ CacheBlock ]

IdentList      = Ident { "," Ident }

IndexFieldList = IndexField { "," IndexField }
IndexField     = ( Ident | "expr" StringLiteral ) [ "asc" | "desc" ]

VectorOps      = "cosine" | "l2" | "ip"

PartialPredicate = <any SQL boolean expression valid in a Postgres index predicate>

The where predicate is a SQL expression, parsed by Postgres’s own parser (pg_query) rather than a fixed DSL grammar. It accepts the full surface of a legal index predicate: boolean operators (and/or/not), comparisons, arithmetic, ||, like/~, JSON operators, is [not] null, [not] in (...), between, array literals with any/all, is distinct from, immutable function calls (lower(email)), casts (amount::numeric), and case … end. The predicate runs to the first newline, entity-closing }, or // comment outside any "..." string.

String literals use the DSL convention — double quotes (where status = "active") — consistent with the rest of the .atl; atlantis converts them to SQL on the way in. Because "..." is always a string (never a quoted identifier), a column whose name is a SQL reserved word can’t be referenced: where "order" > 0 reads as a string, and a bare where order > 0 is a Postgres syntax error.

It must be a legal Postgres index predicate. Subqueries, window functions, and aggregate syntax (count(*), … filter (…)) are rejected at parse with a clear error. A plain aggregate (sum(col)) or a volatile function (now(), random()) is caught by Postgres when the index is built at apply time.

Entity names use PascalIdent; namespaces use SnakeIdent. Underscores are syntactically valid in namespaces.

The namespace becomes the package segment under output_dir/ and the schema prefix on the generated table name: <namespace>_<snake_entity>.

Fields

FieldDecl = Ident Type { Modifier }

Modifier =
    "primary"
  | "identity"
  | "serial"
  | "not" "null"
  | "default" DefaultExpr
  | "unique"
  | "references" QualifiedField { "on" ("delete" | "update") RefAction }
  | "backfill" "\"" SQLExpr "\""
  | "check" "\"" SQLExpr "\""     // must share the field's line, or be indented past it

DefaultExpr =
    "now" "(" ")"                 // the only function with its own keyword
  | "raw" "\"" SQLExpr "\""       // any other expression, verbatim
  | IntLiteral                    // e.g. 0, -1 — integers only
  | StringLiteral                 // double-quoted, e.g. "pending"
  | BooleanLiteral                // true, false

QualifiedField = Namespace "." Entity "." Field

RefAction = "cascade" | "set" "null" | "restrict"

Field names use SnakeIdent. Modifier order is flexible; incompatible combinations — serial with default, primary on two different fields — are rejected at parse.

QualifiedField is always the full three-part form, including for a target in the same namespace: references shop.Customer.id. The referenced field must be declared primary or have a column-level unique.

check: the string body is parsed as a Postgres boolean expression. Anything valid inside CREATE TABLE ... CHECK (...) is accepted.

Where a check binds

check is the one keyword that is valid both as a field modifier and as an entity member, and both spellings are check followed by a string. Indentation decides which you get:

entity Order in shop {
  id      bigint primary
  status  varchar(20) not null
          check "status IN ('open','closed')"   // the field's — indented past it

  total   int not null
  qty     int not null

  check "total >= qty" as total_covers_qty      // the entity's — at member indent
}

The rule: a field’s modifiers may continue on following lines, and a continuation line must be indented past the field it belongs to. A check at or left of its field’s column starts a new member, so it is an entity-level constraint.

  • Only an entity-level check accepts as <name>. On a field’s continuation line, as is a syntax error. An unnamed field check gets a generated name — usually <table>_<column>_check, shortened with a hash suffix past Postgres’s 63-byte identifier limit, or suffixed with a digit when an entity-level check already claimed it.
  • A field carries exactly one check; a second is an error. Additional constraints go at the entity level.
  • Columns count bytes, so one tab is one column. A file mixing tabs and spaces inside one entity can bind a check differently from how it reads.

Field types

Type PostgreSQL Notes
bigint BIGINT
int INTEGER
smallint SMALLINT
real REAL 32-bit float
double DOUBLE PRECISION 64-bit float
boolean BOOLEAN
varchar(N) VARCHAR(N)
varchar VARCHAR no length limit; prefer text in new schemas
text TEXT
citext CITEXT case-insensitive text
jsonb JSONB
bytea BYTEA binary
timestamptz TIMESTAMPTZ timestamp with timezone
date DATE
interval INTERVAL
numeric(p, s) NUMERIC(p,s) arbitrary precision
uuid UUID
vector(N) vector(N) pgvector extension; index with index hnsw on <field> ops <...>
[]T T[] array; element type T is any scalar above except vector and []T

Go and proto mappings are in the type mapping reference.

Modifier semantics

  • primary — primary key. Exactly one field, unless primary by is used at the entity level. The two are mutually exclusive.
  • identity — Postgres GENERATED ALWAYS AS IDENTITY; the database assigns the value.
  • serial — Postgres assigns the value via a sequence. Valid only with bigint primary or int primary. Incompatible with default.
  • not null — disallows null. Implied by primary.
  • default <expr> — Postgres default expression. See DefaultExpr above.
  • unique — Postgres column-level UNIQUE. For multi-column, use unique by at the entity level.
  • references <ns>.<Entity>.<field> — foreign key, always fully qualified. on delete and on update each accept cascade, set null, restrict, in either order. Repeating one overwrites the earlier action.
  • backfill "<expr>" — the SQL expression the backfill writes into existing rows when a not null field is added to an entity that already holds data. See Add a new entity.
  • check "<predicate>" — Postgres CHECK constraint on this column, given a generated name. Must be on the field’s line or indented past it; see Where a check binds. One per field — declare further constraints at the entity level.

Entity-level clauses

  • primary by f1, f2 — composite primary key. Member fields must each be not null. Mutually exclusive with per-field primary.
  • check "<predicate>" [as <name>] — table-level CHECK constraint. Unlike the field modifier this may reference several columns, and as <name> sets the constraint name in the database. Must sit at member indentation; see Where a check binds.
  • unique by f1, f2 [deferrable] — multi-column unique constraint. May appear multiple times. For a single column, use the per-field unique modifier instead. The deferrable suffix is accepted and ignored: the emitted constraint is not deferrable.
  • query_timeout = <duration> — per-entity deadline applied to the entity’s generated RPCs. Unset uses the server default.
  • has_many <name>: <Target> via <field> / has_one <name>: <Target> via <field> — validated (the target entity and via field must exist) but inert: the declaration emits no DDL and no generated code.
  • index by f1, f2 — non-unique B-tree index. May appear multiple times. Each field may instead be an expression (expr "lower(email)") and may carry a per-field asc or desc (e.g. index by created_at desc).
  • index partial by f1, f2 where <predicate> — partial index. <predicate> is a PartialPredicate (any SQL boolean expression valid in a Postgres index predicate). e.g. index partial by sku where deleted_at is null, index partial by id where status = "active" and lower(sku) like "a%".
  • unique index partial by f1, f2 where <predicate> — partial unique index (CREATE UNIQUE INDEX … WHERE …). Use it for uniqueness scoped by a predicate — e.g. unique index partial by sku where deleted_at is null makes sku unique among non-soft-deleted rows, or unique index partial by user_id where is_default for one default per user. A Postgres UNIQUE constraint can’t be partial, so unique / unique by can’t express this. Same predicate grammar as index partial.
  • index hnsw on <field> ops <cosine|l2|ip> — pgvector HNSW index over a vector(N) field. ops picks the operator class: cosine, l2 (Euclidean), or ip (inner product).
  • index gin on <field> — GIN index, for jsonb and array fields.
  • soft_delete by <field> — replaces row deletion with setting <field> (must be timestamptz) to now(). Reads filter <field> IS NULL automatically.
  • touch_on_update by <field> — Postgres trigger sets <field> (must be timestamptz) to now() on every UPDATE.
  • partition by <field> — row-level multi-tenancy. <field> says which tenant a row belongs to and must be not null. The server binds the caller’s tenant per request from the atlantis-tenant request header, before any statement touches the database. Emitted DDL: ENABLE + FORCE ROW LEVEL SECURITY, an index on the column, and two policies — <table>_tenant_isolation (AS RESTRICTIVE, comparing the column to atlantis.current_partition() in both USING and WITH CHECK, with the cast on the function side when the column is not text-shaped) and <table>_default_access (AS PERMISSIVE USING (true), created only when the table carries no permissive policy, so a replacement survives later applies). Adding, removing, or moving the clause on an existing entity is classified cross-caller breaking. tide apply rejects any query or procedure body that calls set_config, which would rebind the tenant parameter. Semantics and access-control patterns: Per-tenant partition.
  • keyless — the table has no key. DDL, plan, apply, and drift all run for it; no service is generated — no Get, BatchGet, Create, Update, Delete, or Query. Mutually exclusive with primary and primary by; cannot be combined with cache. Another entity may references one of its columns when that column is unique. tide inspect --generate emits keyless for a table it finds with no key, with a suggested key that would give it an API.
  • table "<schema.table>" — overrides the physical table name. Without it, atlantis stores the entity at atlantis.<namespace>_<snake_entity>. The value’s shape is [schema.]table, each segment matching [A-Za-z_][A-Za-z0-9_]*; a bare name (table "vendors") lands in public. Foreign keys whose target carries the modifier render REFERENCES "<schema>"."<table>". Changing the value on a previously-applied entity is classified cross_caller_breaking and rejected by tide plan; atlantis does not auto-rename. Used when adopting an existing database — see Adopt an existing database.

The only unique-index form is unique index partial; index by, index hnsw, index gin, and the non-unique index partial are all non-unique. Non-partial uniqueness is declared with the per-field unique modifier or entity-level unique by (which emit UNIQUE constraints). A live CREATE UNIQUE INDEX the schema doesn’t account for is treated as drift and refused at apply; a declared unique index partial whose predicate matches the live one is recognised and not drift. See tide apply.

Cache block

CacheBlock = "cache" "{" { CacheClause } "}"

CacheClause =
    "read_through" "ttl" "=" Duration [ "tag" "=" StringLiteral ]
  | "invalidate_on" ":" InvalidateClause { "," InvalidateClause }
  | "consistency" "=" ( "strict" | "eventual" )

InvalidateClause =
    "write" "(" "self" ")"
  | "write" "(" Ident [ "where" Ident "=" "self" "." Ident ] ")"

Duration = Integer DurationUnit
DurationUnit = "ns" | "us" | "ms" | "s" | "m" | "h"

read_through is the caching mode: reads are served from the cache with the declared TTL.

The tag is a double-quoted string with {field_name} interpolation placeholders. Field names inside {...} must exist on the entity. Cache entries with the same resolved tag are invalidated as a group. See Caching and invalidation.

invalidate_on: write(<Target> where <field> = self.<field>) invalidates this entity’s cached rows when the named target entity is written: the where mapping says which column on the target carries this entity’s key, and the matching parent rows are invalidated. write(self) is accepted and redundant — an entity’s own writes always invalidate it.

consistency = strict | eventual is accepted and recorded in the schema, and changes no behaviour.

Queries

Query = "query" Ident "for" Ident "{" QueryBody "}"

QueryBody =
    "input"  "{" ParamList "}"
    OutputDecl
    SqlBlock

OutputDecl = "output" "as" Ident
           | "output" "{" ParamList "}"

ParamList = Ident ":" Type { "," Ident ":" Type }

SqlBlock = "sql" "touches" "(" IdentList ")" "{" SQL "}"

The for <Ident> after the query name names the entity the query semantically belongs to. It becomes a method on that entity’s generated client and must appear in touches(...).

  • output as <Entity> returns rows of that entity. The SQL must project every column the entity declares.
  • output { ... } returns an ad-hoc row type. The SQL must project columns matching the declared names and types.

Parameters in the SQL body use $name syntax and are rewritten to Postgres positional placeholders ($1, $2, …) before execution. The body is validated when you run tide apply.

touches(...) lists the entities the query reads. The cache layer uses it for query-result invalidation.

Procedures

Procedure = "procedure" Ident "for" Ident "{" ProcedureBody "}"

ProcedureBody =
    "input" "{" ParamList "}"
    "steps" "{" SqlBlock { SqlBlock } "}"

The for <Ident> after the procedure name names the entity the procedure belongs to (same semantics as a query). It becomes a method on that entity’s generated client.

Steps run inside one Postgres transaction. The transaction commits when every step succeeds; any error rolls back the entire transaction. Each step’s touches(...) declares the write set the cache outbox invalidates after commit.

Procedures do not return rows. Read the result with a separate query.

Jobs

Job = "job" Ident "in" Ident "{" { JobClause } "}"

JobClause =
    "args" "{" { FieldDecl } "}"
  | "retries" IntLiteral
  | "timeout" ( Duration | "none" )
  | "heartbeat" Duration
  | "queue" StringLiteral
  | "schedule" StringLiteral
  | "visible_to" StringLiteral

Clauses may appear in any order.

  • args { ... } — the job’s argument fields, using the entity field grammar.
  • retries <n> — attempts before the job moves to the dead-letter queue.
  • timeout <duration> | none — per-attempt deadline. none removes it, for long-running handlers that report progress through checkpoints.
  • heartbeat <duration> — how often a dispatched worker must signal liveness for this job. Unset uses the server default.
  • queue "<name>" — the queue the job runs on.
  • schedule "<cron>" — a five-field cron expression. The server fires the job on that cadence with empty args, carrying the job’s queue, retries and timeout, and skips a firing while a previous run is still outstanding.
  • visible_to "<caller>" — restricts which caller may submit the job and whose workers may claim it. Unset or "*" means any caller; aliases count.

Handler semantics are in Jobs and workflows.

Workflows

Workflow = "workflow" Ident "in" Ident "{" { WorkflowClause } "}"

WorkflowClause =
    "state" "{" { FieldDecl } "}"
  | "step" Ident "{" "job" QualifiedName [ ArgsBlock ] "}"
  | "compensate" Ident "{" "job" QualifiedName [ ArgsBlock ] "}"

ArgsBlock     = "args" "{" [ Ident ":" Expr { "," Ident ":" Expr } [ "," ] ] "}"
QualifiedName = [ Ident "." ] Ident
  • state { ... } — the fields the workflow instance carries between steps.
  • step <name> { job <ns.Job> args { ... } } — one step, running the named job. Steps run in declaration order.
  • compensate <step-name> { job <ns.Job> args { ... } } — the job that runs to undo the named step when a later step fails.

Ephemeral stores

Ephemeral = "ephemeral" Ident "in" Ident "{" { FieldDecl | "ttl" "=" Duration } "}"

An ephemeral declaration is a typed, expiring store with no table behind it. Fields use the entity field grammar; ttl sets how long an entry lives and may appear anywhere in the body. Semantics are in Ephemeral data.

Enums

Enum      = "enum" Ident "in" Ident "{" [ EnumLabel { "," EnumLabel } [ "," ] ] "}"
EnumLabel = Ident | Keyword | StringLiteral

Declares a Postgres enum type. Any keyword is a legal label, and the quoted form declares labels an identifier cannot spell — "in progress". A trailing comma is allowed.

enum Status in shop {
  open, closed, "on hold",
}

Hypertables

Hypertable = "hypertable" Ident "in" Ident "on" Ident "{" HypertableBody "}"

HypertableBody =
    { FieldDecl }
    [ "chunk_time_interval" Duration ]
    [ other EntityBody clauses... ]

The time column is named in the header — hypertable Reading in iot on recorded_at { ... } — and must be a timestamptz field declared in the body. It becomes the time dimension passed to create_hypertable. The entity-level partition by clause is a different mechanism: tenant isolation via row-level security, unrelated to TimescaleDB chunking.

chunk_time_interval sizes each chunk and uses the same Duration syntax as cache TTLs. Omit it to take TimescaleDB’s default (7 days). Changing it later emits set_chunk_time_interval, which applies to chunks created from that point on — existing chunks keep the size they were made with.

Hypertables accept every entity-body clause (indexes, unique constraints, soft delete, cache block).

Every unique index must contain the time column, including the primary key. TimescaleDB enforces uniqueness per chunk, and a chunk covers one time range. A hypertable therefore takes primary by id, recorded_at, and every unique clause includes the time column. atlantis does not check this before applying: a single-column primary key on a hypertable fails during tide apply with cannot create a unique index without the column ... (used in partitioning), SQLSTATE TS103.

Identifiers

PascalIdent = [A-Z][A-Za-z0-9]*
SnakeIdent  = [a-z][a-z0-9_]*

Entity, query, and procedure names use PascalIdent. Namespace, field, input, and output names use SnakeIdent.

Reserved words

A keyword either can name a field or cannot, and which it is follows from one rule: a word that can begin an entity member cannot also name a field, since both readings would be available at member indent and nothing would separate them.

The following cannot name a field:

as, asc, by, cache, cascade, check,
chunk_time_interval, consistency, cosine, deferrable, delete, desc,
entity, eventual, expr, false, for, gin,
has_many, has_one, heartbeat, hnsw, hypertable,
in, index, input, insert, invalidate, invalidate_on, ip, is,
keyless, l2, not, now, null,
on, ops, output, partial, partition, primary, procedure,
query, query_timeout, raw, restrict, self, set, soft_delete,
sql, steps, strict, table, touch_on_update, touches, true,
ttl_field, unique, update, via, where, write

These are field modifiers, and may also name a field. Indentation separates the two: at or left of the field’s own column the word begins the next member and names it; to its right, or on the field’s own line, it modifies the field above.

identity, serial, default, references, backfill
entity Region in rnacen {
  id       bigint primary
  identity double                  // a column called identity
}

entity Account in app {
  id bigint primary identity       // the modifier
}

primary, unique and check are in the first list rather than this one because each also begins an entity member — primary by, unique by, check "...". not is there because not null is two tokens.

The following are keywords only inside a block or declaration of their own, and may name a field anywhere else:

args, compensate, enqueue, enum, ephemeral, job, queue,
read_through, retries, schedule, state, step, tag,
timeout, ttl, visible_to, workflow

enum is here rather than in the first list because it begins a top-level declaration, not an entity member. read_through, ttl and tag belong to cache { ... }; the rest to job, workflow and ephemeral.

Known gaps

The DSL does not yet support: the ivfflat vector-index method (only hnsw is supported), GiST indexes, view declarations, and import statements.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close