Syntax reference for .atl files.
Notation
In the grammar productions below:
[X]is optional.{X}is zero or more.X | Yis 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 | EnumEntities
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
checkacceptsas <name>. On a field’s continuation line,asis a syntax error. An unnamed fieldcheckgets 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
checkdifferently 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, unlessprimary byis used at the entity level. The two are mutually exclusive.identity— PostgresGENERATED ALWAYS AS IDENTITY; the database assigns the value.serial— Postgres assigns the value via a sequence. Valid only withbigint primaryorint primary. Incompatible withdefault.not null— disallows null. Implied byprimary.default <expr>— Postgres default expression. SeeDefaultExprabove.unique— Postgres column-levelUNIQUE. For multi-column, useunique byat the entity level.references <ns>.<Entity>.<field>— foreign key, always fully qualified.on deleteandon updateeach acceptcascade,set null,restrict, in either order. Repeating one overwrites the earlier action.backfill "<expr>"— the SQL expression the backfill writes into existing rows when anot nullfield is added to an entity that already holds data. See Add a new entity.check "<predicate>"— PostgresCHECKconstraint on this column, given a generated name. Must be on the field’s line or indented past it; see Where acheckbinds. One per field — declare further constraints at the entity level.
Entity-level clauses
primary by f1, f2— composite primary key. Member fields must each benot null. Mutually exclusive with per-fieldprimary.check "<predicate>" [as <name>]— table-levelCHECKconstraint. Unlike the field modifier this may reference several columns, andas <name>sets the constraint name in the database. Must sit at member indentation; see Where acheckbinds.unique by f1, f2 [deferrable]— multi-column unique constraint. May appear multiple times. For a single column, use the per-fielduniquemodifier instead. Thedeferrablesuffix 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 andviafield 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-fieldascordesc(e.g.index by created_at desc).index partial by f1, f2 where <predicate>— partial index.<predicate>is aPartialPredicate(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 nullmakesskuunique among non-soft-deleted rows, orunique index partial by user_id where is_defaultfor one default per user. A Postgres UNIQUE constraint can’t be partial, sounique/unique bycan’t express this. Same predicate grammar asindex partial.index hnsw on <field> ops <cosine|l2|ip>— pgvector HNSW index over avector(N)field.opspicks the operator class:cosine,l2(Euclidean), orip(inner product).index gin on <field>— GIN index, forjsonband array fields.soft_delete by <field>— replaces row deletion with setting<field>(must betimestamptz) tonow(). Reads filter<field> IS NULLautomatically.touch_on_update by <field>— Postgres trigger sets<field>(must betimestamptz) tonow()on everyUPDATE.partition by <field>— row-level multi-tenancy.<field>says which tenant a row belongs to and must benot null. The server binds the caller’s tenant per request from theatlantis-tenantrequest 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 toatlantis.current_partition()in bothUSINGandWITH 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 applyrejects any query or procedure body that callsset_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 — noGet,BatchGet,Create,Update,Delete, orQuery. Mutually exclusive withprimaryandprimary by; cannot be combined withcache. Another entity mayreferencesone of its columns when that column isunique.tide inspect --generateemitskeylessfor 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 atatlantis.<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 inpublic. Foreign keys whose target carries the modifier renderREFERENCES "<schema>"."<table>". Changing the value on a previously-applied entity is classifiedcross_caller_breakingand rejected bytide 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" StringLiteralClauses 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.noneremoves 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’squeue,retriesandtimeout, 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 "." ] Identstate { ... }— 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 | StringLiteralDeclares 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, writeThese 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, backfillentity 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, workflowenum 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.