---
title: "DSL reference"
description: "Syntax reference for .atl files, covering entities, hypertables, queries, procedures, jobs, workflows, ephemerals, and enums."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.tryatlantis.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# DSL reference

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](/reference/dsl-types/).

### 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](/guides/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](#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](#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`](#entities) (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](/guides/add-a-new-entity/#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](/guides/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`](/reference/cli-tide/).

### 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](/concepts/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](/concepts/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](/concepts/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.

Source: https://docs.tryatlantis.dev/reference/dsl-grammar/index.mdx
