Schema reference
The Keel schema is a DSL used to define data models, actions, permissions, and other components that are transformed into APIs and project infrastructure. It allows you to specify the structure and behavior of your application's data and operations in a concise and readable manner.
A Keel schema (one or many .keel files) is composed of declarations:
Each declaration begins with a keyword (model, union, enum, message, role, api, job) followed by a name and a block defining its contents.
keyword EntityName {
// ..
}Comments in Keel DSL use the // syntax for single-line comments.
Models
Models represent data structures in your application. They can have fields, actions, and permissions.
model ModelName {
// Sections: fields, actions
}Fields
Fields define the data properties of a model.
fields {
fieldName FieldType [modifiers] [attributes]
}fieldName: The name of the field.FieldType: The data type of the field.- Modifiers:
[]: Indicates the field is an array (repeated).?: Indicates the field is optional.
- Attributes: Additional metadata for the field, defined using attributes.
Field types
Built-in scalar types:
| Field Type | Description |
|---|---|
ID | A unique identifier (KSUID) |
Text | A string |
Number | An integer |
Decimal | A decimal number |
Date | A date without time (ISO 8601 format) |
Duration | A time interval (ISO 8601 format) |
Timestamp | A UTC timestamp |
Boolean | A boolean value (true or false) |
Secret | An encrypted secret |
Password | A hashed password |
Markdown | Rich text in Markdown format |
Vector | A vector type |
File | A file, as a data URL or upload key |
You can also use other models or enums as field types.
Example
fields {
name Text
rating Number?
tags Text[]
books Book[]
}Actions
Actions define operations that can be performed on the model.
Standard actions
Standard actions are actions where Keel handles the implementations. The functionality can be extended using hooks.
actions {
actionType actionName(readFields) [with (writeFields)] [attributes]
}| Parameter | Description |
|---|---|
actionType | The type of action: get, create, update, delete, or list |
actionName | Globally unique name of the action (e.g., createPost, updateUser) |
readFields | Comma-separated list of model fields or custom parameter used for selecting the entry |
writeFields | Comma-separated list of model fields or custom parameter to write |
attributes | Additional controls for the behavior of the action |
Supported attributes
| Attribute | get | list | create | update | delete |
|---|---|---|---|---|---|
@embed | ✓ | ✓ | |||
@permission | ✓ | ✓ | ✓ | ✓ | ✓ |
@function | ✓ | ✓ | ✓ | ✓ | ✓ |
@where | ✓ | ✓ | ✓ | ✓ | ✓ |
@orderBy | ✓ | ||||
@sortable | ✓ | ||||
@set | ✓ | ✓ | |||
@validate | ✓ | ✓ | ✓ |
Custom parameter
For write actions, you can specify custom fields that are not part of the model with the syntax fieldName: FieldType. These fields must then be used as part of a @set or @where:
actions {
update updatePost(id) with (customInput: Text) {
@set(title = customInput)
}
}Custom actions
Custom actions are actions where you define the implementation. Either as a read function that returns data or a write function that modifies data.
actions {
actionType actionName(readFields) returns (returnTypes) [attributes]
}| Parameter | Description |
|---|---|
actionType | The type of action: read, write |
actionName | Globally unique name of the action (e.g., createPost, updateUser) |
readFields | Comma-separated list of model fields or message to use as inputs |
returnTypes | Message to use as output |
attributes | Additional controls for the behavior of the action |
Supported attributes
| Attribute | read | write |
|---|---|---|
@permission | ✓ | ✓ |
N.B. @permission expressions can't use row-based data for custom actions. For row based permission checked, handle the logic within the function.
Example
actions {
create createAuthor(name) @function
get getAuthor(id)
list listAuthors() {
@sortable(firstName, surname)
@orderBy(firstName: asc, surname: desc)
}
read getExternalAuthor(extId: Text) returns (GetAuthorResponse)
write processAuthor(id) returns (GetAuthorResponse)
}
message GetAuthorResponse {
name Text
}
message GetAuthorResponse {
authorId ID
}Unions
A union is a read-only fact set assembled from several models. It declares a shape in a fields block, maps each contributing model's rows into that shape with a from arm, and can serve get and list actions over the result. Keel compiles it to a database view, so a union has no table, no columns of its own and no write surface.
union UnionName {
// Sections: fields, from arms, actions
}union StockOutlook {
fields {
item Item
location StockLocation?
occursOn Date
quantity Decimal
}
from StockEntry {
item @computed(stockEntry.item)
location @computed(stockEntry.location)
occursOn @computed(stockEntry.occurredAt)
quantity @computed(stockEntry.quantity)
}
from SalesOrderLine {
@where(salesOrderLine.status == SoStatus.Open)
item @computed(salesOrderLine.item)
occursOn @computed(salesOrderLine.shipBy)
quantity @computed(0 - salesOrderLine.unallocatedQuantity)
}
actions {
get getMovement(id)
list listMovements(item.id?, occursOn?)
}
@permission(expression: true)
}Union sections
| Section | Purpose | Required |
|---|---|---|
fields | the shape every arm maps into | yes |
from <Model> [as <Alias>] | one arm, and at least one is needed | yes |
actions | get and list only | no |
Union fields
Union fields use ordinary field syntax and take no attributes. Every required field must be mapped by every arm; an optional field (?) may be left unmapped, which puts that arm's rows in the field's null group. A field cannot be typed as another union.
Three fields are supplied by the runtime and cannot be declared: id (the source row's primary key), sourceModel and sourceArm. There is no createdAt or updatedAt. For each relationship-typed field, an <name>Id field is supplied alongside it.
Union arms
An arm is from <Model>, optionally as <Alias>, both written in UpperCamelCase. Two arms drawn from one model must each carry an alias. Inside an arm, each mapping is a union field name followed by a single @computed expression rooted at the source model — its own name, not the alias — and an optional @where, which must resolve to a Boolean, narrows which source rows contribute facts. Both are type-checked against the source model at build time; an arm cannot traverse to a to-many relationship and cannot reference ctx. Each mapping is cast to the type its union field declares.
Union actions and permissions
A union serves get and list actions, declared exactly as on a model. A get is only valid when no model feeds more than one arm, since rows from such arms share an id. Union actions accept @where, @permission, @orderBy, @sortable and @embed.
@permission is required once a union declares any actions, because a union does not inherit its source models' rules. A rule with no actions: argument grants every action type the union can serve.
For the full details see Unions.
Attributes
Attributes provide metadata and additional behavior to models, fields, and actions. They are denoted using the @ symbol, followed by the attribute name and optional arguments.
@attributeName(arguments)Attributes can be applied to fields, actions, or models.
| Attribute | Fields | Actions | Models | Jobs | Unions |
|---|---|---|---|---|---|
| @permission | ✓ | ✓ | ✓ | ✓ | |
| @unique | ✓ | ✓ | |||
| @relation | ✓ | ||||
| @default | ✓ | ||||
| @accepts | ✓ | ||||
| @maxSize | ✓ | ||||
| @function | ✓ | ||||
| @orderBy | ✓ | ✓ | |||
| @sortable | ✓ | ✓ | |||
| @embed | ✓ | ✓ | |||
| @where | ✓ | ✓ | |||
| @set | ✓ | ||||
| @validate | ✓ | ✓ | |||
| @on | ✓ | ||||
| @schedule | ✓ |
A union is stricter about where an attribute goes than the column above can show. @permission goes on the union declaration or on one of its actions, @where on an arm or on an action, and @orderBy, @sortable and @embed on an action. A union's fields take no attributes at all, and a field mapping inside an arm takes exactly one @computed. See Unions for the details.
@permission
Defines access control for actions or models. It specifies which roles have access and can include expressions for conditional permissions.
Syntax:
@permission(
roles: [Role1, Role2, ...],
actions: [actionType1, actionType2, ...],
expression: <condition>
)roles: A list of roles granted access.actions: A list of action types the permission applies to (get,list,create,update,delete).expression: An optional logical condition that must be met for the permission to be granted.
Multiple @permission attributes can be applied to the same action or model and will be evaluated as a logical or.
If a permission is defined on an individual action, it will replace any permissions defined for that action type on the model.
Usage:
@permission(
roles: [Admin],
actions: [create, update, delete]
)Row-based permissions:
@permission(
expression: employee.isActive == true && employee.department == "HR",
actions: [update]
)Note: In row-based permissions, you can reference model fields and compare them to static values or context variables.
For more information see permissions.
@unique
Ensures that the field's value is unique across all records in the model. This attribute is applied to fields within a model or on the modal for compound unique constraints.
It can be used on a field whose type is Text, Number, Boolean, Date, an enum, or a non-repeated relationship. Timestamp fields cannot be unique, and neither can array fields or has-many relationships.
Usage:
fields {
username Text {
@unique
}
}modal User {
fields {
username Text
email Text
}
@unique([username, email])
}Permissions specificity
model Post {
fields {
name Text
author Author
owner Identity
}
actions {
update updatePost(id) with (name)
update updatePostAuthor(id) with (author) {
// This will override the model level permission
@permission(expression: post.owner == ctx.identity)
}
}
@permission(
expression: post.author.identity == ctx.identity,
actions: [update]
)
}@function
Marks a standard action as a function, this exposes action hooks where you can extend the functionality.
Usage:
actions {
create createAuthor(name) @function
}@orderBy
The @orderBy attribute specifies default ordering for list actions. You can define multiple fields and specify the sort direction (asc for ascending or desc for descending).
Usage:
actions {
list listItems() {
@orderBy(price: asc, createdAt: desc)
}
}@sortable
The @sortable attribute specifies fields that can be used for sorting in list actions. Clients can sort results by these fields when making queries.
Usage:
actions {
list listProducts() {
@sortable(name, price, rating)
}
}@on
Defines event subscribers for models based on the action type.
Syntax:
@on([actionType], functionName)Run keel generate to scaffold the subscriber function.
Usage:
model User {
@on([create], sendWelcomeEmail)
@on([create, update, delete], syncUsersWithExternalSystem)
}@embed
Embedding specifies related models to include in the JSON and RPC api responses for get or list actions. This is useful for fetching associated data in a single query.
Can be used multiple times to embed multiple fields and can also embed multiple levels deep.
Usage:
actions {
get getOrder(id) {
@embed(customer)
@embed(items)
}
list listOrders() {
@embed(customer)
@embed(customer.addresses)
}
}@relation
If you need multiple relationships between the same two models, then you will need to explicitly specify to which fields each of the relationships join with.
This is done using the @relation attribute.
Usage:
model Post {
fields {
writtenBy Author @relation(written)
reviewedBy Author @relation(reviewed)
}
}
model Author {
fields {
written Post[]
reviewed Post[]
}
}Note that @relation is only valid on the has one side of the relationship.
@default
Sets a default value for a field when a new record is created.
Using @default without arguments will default to empty value of the field type.
| Data Type | Empty Value |
|---|---|
| Text | "" (empty string) |
| Number | 0 |
| Decimal | 0 |
| Boolean | false |
| Timestamp | Current timestamp |
| Date | Current date |
| ID | Auto-generated KSUID |
Usage:
fields {
isActive Boolean @default(true)
createdAt DateTime @default
}@accepts
Restricts the content types a File field will accept when a file is uploaded to it. Takes a list of MIME patterns, each either an exact type or a type with a wildcard subtype. Matching ignores case and any parameters on the content type. */* and a bare * are not valid patterns — omit the attribute to accept anything.
Usage:
fields {
image File @accepts(["image/png", "image/jpeg"])
manual File @accepts(["application/pdf"])
asset File @accepts(["image/*", "video/*"])
}@maxSize
Sets the largest file a File field will accept when a file is uploaded to it. Takes a size with a unit: B, KB, MB and GB are decimal (1KB is 1000 bytes) and KiB, MiB and GiB are binary. Units are case-insensitive, whitespace between the number and the unit is optional, and fractional values are allowed. TB is not accepted. Without the attribute a File field accepts up to 100MB, and the largest size you can declare is 5GB.
Usage:
fields {
image File @maxSize("5MB")
manual File @maxSize("250 kb")
video File @maxSize("1.5GiB")
}Note that @accepts and @maxSize apply to the upload path only. A file sent inline as a data URL is stored without either check, so they constrain uploads rather than validating the field. See Files.
@where
Adds conditions to actions to filter data based on specified criteria. Applicable to get, list, update, and delete actions.
Usage:
actions {
list listActiveUsers() {
@where(user.isActive == true)
}
update deactivateUser(id) {
@where(user.isActive == true) // Will 404 if user is not active
@set(user.isActive = false)
}
}@set
Assigns values to fields during create or update actions. You can set fields to static values, input variables, or expressions.
Usage:
actions {
create createPost(title) {
@set(post.status = "draft")
@set(post.createdAt = ctx.now)
@set(post.author = ctx.identity)
}
update publishPost(id) {
@set(post.status = "published")
@set(post.publishedAt = ctx.now)
}
}Note: You can also use @set with custom input fields not defined in the model.
Example with custom input:
actions {
create createEvent() with (startTime: Timestamp, duration: Number) {
@set(event.startTime = startTime)
@set(event.endTime = startTime + duration * durationUnit)
}
}@validate
Expresses a business rule that must hold for a write to proceed. On a built-in create, update or delete action it guards writes made through that action. On a model it declares an invariant every record must always satisfy, which the database enforces for every writer. The syntax is the same in both places, and the two forms are described in turn below.
Syntax:
@validate(<boolean expression>)
@validate(<boolean expression>, "<error message>")The expression must resolve to Boolean. The message is optional and must be a non-empty string literal. When you omit it, a failure reports a generic validation failed rather than the expression, so write a message on every rule you want a caller to be able to act on.
Usage:
model Order {
fields {
customer Customer
quantity Number
status OrderStatus
trackingNo Number?
}
actions {
create createOrder() with (customer.id, quantity) {
@validate(quantity > 0, "quantity must be greater than zero")
}
update dispatch(id) with (trackingNo) {
@set(order.status = OrderStatus.Dispatched)
@validate(order.status == OrderStatus.Packed, "order must be packed")
}
delete deleteOrder(id) {
@validate(order.status != OrderStatus.Dispatched, "dispatched orders cannot be deleted")
}
}
}You can put more than one @validate on an action. All of them must hold for the write to happen, and a failing request reports every rule that failed rather than stopping at the first.
@validate is not supported on get or list actions. Use @where to filter what a read returns. It is also not supported alongside @function, since a function implements its own behaviour and can throw errors.BadRequest instead. See Error handling.
What a rule can reference
| A rule can reference | create | update / delete |
|---|---|---|
| Action inputs | ✓ | ✓ |
ctx, environment variables, secrets | ✓ | ✓ |
| Enums and literals | ✓ | ✓ |
The record (order.status) | ✓ | |
Relations (order.customer.isActive) | ✓ | |
| Computed fields | ✓ |
A create rule cannot reference the record, because the record does not exist when the rule runs. Referencing it is a schema error, so reference the inputs directly.
Rules see the record before the write
An update or delete rule reads the record as it was when the request arrived, the same way @where does. In the dispatch example above, order.status is the status the order had before the action ran, even though the same action uses @set to change it. That is what makes the rule a guard on the transition rather than a check on the result.
Null handling in an action rule
An action's rules are evaluated by the database, and a rule whose expression does not come out true fails. A comparison against null is not true, so the rule fails rather than being skipped. This covers an optional input the caller omitted, a nullable field that is not set, and an absent relation. A model-level rule does the opposite, as described under model-level rules below, so check this when moving a rule between the two.
// Rejects a request that omits trackingNo, because null > 0 is not true.
@validate(trackingNo > 0)To write a rule that only applies when the value is present, guard it with IF:
// Passes when trackingNo is omitted, and applies normally when it is supplied.
@validate(IF(trackingNo > 0, trackingNo > 10, true), "tracking number must be above ten")What the caller sees
A failing rule returns an HTTP 400 listing every failure, in the order the rules are declared in the schema:
{
"code": "ERR_INVALID_INPUT",
"message": "one or more validation rules failed",
"data": {
"errors": [{ "error": "order must be packed" }]
}
}This is the same envelope the request schema validator uses. On the JSON-RPC API the payload is under error.detail, and on the GraphQL API it is under extensions.
Permission checks run before validation, so a caller who is not allowed to perform the action gets ERR_PERMISSION_DENIED and no rule is evaluated. A missing record is reported as ERR_RECORD_NOT_FOUND. Validation and the write happen in one transaction, so a failed rule leaves nothing behind, including in nested creates, where every level is gated.
Action rules apply at the action boundary
An action's rules guard writes made through that action. Writes made with the Model API from a custom function, subscriber, flow or task, raw SQL through useDatabase, and seed data do not pass through the action, so its rules do not apply to them. Use an action rule for a rule about a particular request, and a model-level rule for anything the stored data must always satisfy.
Model-level rules
@validate on a model block, alongside @permission and @unique, declares an invariant every record of that model must always satisfy:
model Order {
fields {
quantity Number
customer Customer
items Item[]
}
@validate(order.quantity > 0, "quantity must be greater than zero")
@validate(order.customer.isActive, "the customer must be active")
@validate(SUM(order.items.amount) <= 1000, "the order total is too large")
}The attribute takes the same arguments as the action-level form. What differs is where the rule is enforced: the migration compiles each model rule into database triggers, so it holds for every writer, including all of the paths an action rule does not reach. It is not valid on a field, nor on any declaration other than a model or one of its actions.
A model rule reads the record as the write leaves it, so it describes the state the record must end up in rather than a transition. Because the rule is run by the write itself, and the write has no request behind it, a model rule cannot reference ctx, environment variables or secrets. Referencing ctx is a schema error that points you at the action-level form. Nor can a model rule read action inputs: a field must be reached through the record, so write order.quantity rather than quantity. Otherwise a model rule can use everything the expression language offers, including relations, computed fields, aggregates over a to-many relationship, quantifiers, and date functions.
Two rules on the same model with both the same expression and the same message are rejected. The same expression carrying a different message is a different rule and is accepted.
What the caller sees for a model rule
A write that leaves the record breaking a model rule returns HTTP 409:
{
"code": "ERR_CONFLICT",
"message": "a validation rule failed",
"data": { "errors": [{ "error": "quantity must be greater than zero" }] }
}ERR_CONFLICT rather than the action-level ERR_INVALID_INPUT, because the input was well formed and it is the record's state that conflicts. The data.errors shape is the same, so a client can read both the same way. Exactly one rule is reported, since the first failure aborts the statement, whereas an action's rules are evaluated together and report every failure. A rule with no message reports the generic validation failed.
A model rule is re-checked when a relation changes
A rule that reads a relation is checked again when that relation is written, not only when the record itself is written. SUM(order.items.amount) <= 1000 therefore refuses an Item insert that would take its order over the cap, and order.customer.isActive refuses deactivating a customer who still holds orders. The write that is refused is the one to the related record, and the message belongs to the rule that refused it.
The statement is the unit of evaluation
A model rule is evaluated once per statement, across every row that statement wrote. A multi-row INSERT containing one violating row is refused whole, and a bulk UPDATE that leaves any row violating leaves every row unchanged. This is what lets a nested create satisfy a rule that aggregates over the record's children, since the rule sees the parent and its children together.
Because these are ordinary triggers, they hold at every statement boundary and SET CONSTRAINTS ALL DEFERRED does not defer them. A presence-style rule such as COUNT(order.items) > 0 is satisfied by a nested create, which writes the parent and its children in one statement, but not by a writer that creates the parent in one statement and its children in another. They are also not enforced under session_replication_role = replica, which disables triggers. Trigger-maintained computed fields have the same exposure.
Null handling in a model rule
A model rule treats null the opposite way to an action rule. Only a rule the database can judge to be false counts as a violation, so a rule that evaluates to null passes and the write goes ahead.
// On a model, this holds for an order with no trackingNo, because null > 0 is null.
// The same expression on an action rejects that order, because null is not true there.
@validate(order.trackingNo > 0)This covers a nullable field that is not set, and a quantifier over no rows is satisfied for the same reason. To make a model rule reject a record whose value is missing, test for it, for example order.trackingNo != null && order.trackingNo > 0.
== and != are the exception, because they compare null-safely and so always come out true or false rather than null. That matters for a rule that reaches through an optional relationship: project.lead.team.id == project.team.id is false when the project has no lead at all, so the rule blocks a project whose lead is simply not set. Guard it by comparing the relationship itself to null:
model Project {
fields {
team Team
lead Member?
}
@validate(
project.lead == null || project.lead.team.id == project.team.id,
"the lead must be on the project's team"
)
}A relationship compared to null resolves to whether the record has one, and works the same way in @where, @permission and in computed fields.
Adding a model rule to existing data
The migration checks the rows already in the table and refuses to deploy if any of them violate the new rule:
cannot enforce @validate on Order: 2 existing row(s) violate it
DETAIL: rule: quantity must be greater than zero
HINT: correct or remove the offending rows, then deploy againCorrect or remove those rows and deploy again.
@schedule
Schedules a job and flows to run at specific intervals. The argument is a cron expression or natural language description that defines the schedule. More information on the scheduling expressions can be found here.
Usage:
job EmailNewCustomerReport {
@schedule("every monday at 9am")
}Enums
Enums define a set of named constant values.
enum EnumName {
Value1
Value2
// ...
}Example
enum Planet {
Mercury
Venus
Earth
Mars
}Messages
Messages define custom input and output types for actions. They are especially useful when defining custom functions.
message MessageName {
fieldName FieldType [modifiers]
// ...
}Messages can be nested to define more complex structures.
message MessageName {
fieldName FieldType
messageField OtherMessage
// ..
}
message OtherMessage {
fieldName FieldType
// ...
}Example
message MyInput {
id ID
}
message MyOutput {
name Text?
}
message Book {
name Text
}
message AuthorAndBooks {
name Text
books Book[]
}Roles
Roles define access permissions based on domains or specific emails.
role RoleName {
domains {
"example.com"
"anotherdomain.com"
}
emails {
"user@example.com"
"admin@example.com"
}
}Example
role Admin {
domains {
"keel.xyz"
"keel.zyx"
}
emails {
"adam@keel.xyz"
"adam@keel.zyx"
}
}Jobs
Jobs define background tasks that run on a schedule. See the Jobs page for full documentation.
job JobName {
@schedule(<cron_expression>)
}APIs
APIs define how models and their actions are exposed. By default there is an api called API which contains all the models.
Additional APIs can be defined or the default API can be redefined and manually controlled.
api ApiName {
models {
ModelName {
// Optionally filter the actions
actions {
actionName
// ...
}
}
// ...
}
}Example
api Web {
models {
Customer
Order
Product {
actions {
getProduct
listProducts
}
}
}
}
api Admin {
models {
Customer
Order
Product
InventoryItem
StockLocations
}
}