Skip to content

Tutorial

This walkthrough takes you from an empty folder to a running app backed by PostgreSQL — using Axel for the schema, the migrations, the queries, and the generated client code. By the end you’ll have:

  • a schema written in ASL and applied to a real database as a migration,
  • two queries written in AQL, and
  • typed TypeScript and Go code generated from them, called from a small program.

We’ll build a tiny blog: Users who write Posts.


  • The axel CLI — see Installation. Verify with axel version.
  • A PostgreSQL database. Any Postgres works; this tutorial uses a throwaway one in Docker (below), so you don’t touch a real database.
  • One of a Bun or Go toolchain, for the final “run it” step.

There is no axel init — an Axel project is just a config file, a schema file, and a folder of queries that you create yourself. That’s what we do in Step 1.


Make a folder and lay out these files:

blog/
axel.yaml # project config
schema.asl # your schema (ASL)
queries/ # your queries (AQL) — we add files here later

axel.yaml tells Axel where your schema and migrations live, and how to reach the database:

database-url: postgres://user:password@localhost:5432/db?sslmode=disable
schema-path: ./schema.asl
migrations-dir: ./migrations

When axel.yaml is in the current directory, Axel discovers it automatically. You can run axel <command> directly from your project root. (You only need -d <path> or --dir <path> if your config lives in a different folder).


Put this in schema.asl. It defines an abstract Base type (shared columns), then User, Post, and Comment that extend it:

use extension 'pgcrypto';
abstract type Base {
required id: uuid {
default := gen_uuid();
constraint exclusive;
constraint pk;
};
required created_at: datetime { default := datetime_current(); };
required updated_at: datetime {
default := datetime_current();
rewrite update := datetime_current();
};
}
type User extends Base {
required email: str {
constraint exclusive;
constraint min_length(10);
constraint max_length(100);
};
name: str { default := 'n/a'; };
required age: int32;
required health: int32;
active: bool { default := true };
}
type Post extends Base {
required title: str;
required content: str;
required link author: User; # single link → FK column "author"
multi link likes: User; # multi link → junction table "post_likes"
}
type Comment extends Base {
required content: str;
required link post: Post;
required link author: User;
}

A few things worth noticing, each covered in the ASL reference:

  • use extension 'pgcrypto'; enables the pgcrypto PostgreSQL extension, which provides cryptographic functions like gen_random_uuid() (or gen_uuid()).
  • abstract type Base is never a table on its own; its fields are inlined into every type that extends it.
  • constraints (exclusive, pk, min_length) compile to SQL UNIQUE / PRIMARY KEY / CHECK clauses.
  • required link author: User is a one-to-many foreign key; multi link likes: User is a many-to-many that Axel backs with a junction table.

Before touching the database, check the schema parses and type-checks:

Terminal window
axel validate
schema "schema.asl" is valid (4 types)

validate never connects to a database, so it’s the fastest feedback loop while you’re editing the schema.


Any Postgres will do. To spin up a disposable one matching the URL in axel.yaml, drop this docker-compose.yaml next to it and start it:

services:
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=db
ports:
- 5432:5432
Terminal window
docker compose up -d

Step 5 — Generate and apply the first migration

Section titled “Step 5 — Generate and apply the first migration”

axel diff diffs your schema against the last migration and writes a new one. On a fresh project that diff is “create everything”:

Terminal window
axel diff -n "initial schema"

This writes a versioned migration folder:

migrations/
0001/
up.sql # forward migration
down.sql # rollback
metadata.json # version, checksum, schema snapshot

Open migrations/0001/up.sql to see the SQL Axel produced — abbreviated here:

CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE "user" (
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
"email" TEXT NOT NULL UNIQUE,
"name" TEXT DEFAULT 'n/a',
"age" INTEGER NOT NULL,
"health" INTEGER NOT NULL,
"active" BOOLEAN DEFAULT true,
"id" UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE PRIMARY KEY
);
CREATE TABLE "post" (
"content" TEXT NOT NULL,
"id" UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE PRIMARY KEY,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
"title" TEXT NOT NULL,
"author" UUID NOT NULL,
FOREIGN KEY ("author") REFERENCES "user"("id") ON DELETE CASCADE
);
-- plus "post_likes" (junction for multi link likes) and "comment"

Apply it:

Terminal window
axel up

Axel records applied migrations in a _axel_migrations table it creates on first run. Check the state any time with:

Terminal window
axel status
0001 applied

Queries live in .aql files. Create two under queries/.

An insert that takes one parameter (queries/create_user.aql):

insert User {
email := $email,
age := 100,
health := 100
};

A nested read — every user with their posts, pulled in a single query (queries/list_users_with_post.aql):

multi select User {
id,
email,
posts := (select Post { id, title } filter .author.id = User.id)
}

The posts := (…) shape is the important bit: Axel compiles it to a json_agg sub-select, so related rows come back as a nested JSON array with no N+1 queries. See the AQL reference for filters, ordering, and the other statement types.


Step 7 — See the compiled SQL (optional)

Section titled “Step 7 — See the compiled SQL (optional)”

Before generating code, you can inspect exactly what a query compiles to. axel compile needs no database:

Terminal window
axel compile --file queries/list_users_with_post.aql
SELECT
u.id AS id,
u.email AS email,
(SELECT json_agg(row_to_json(p_posts_sub)) FROM (SELECT p.id AS id, p.title AS title FROM "post" p WHERE p.author = u.id) p_posts_sub) AS posts
FROM "user" u;

Code generation (next) runs this compiler for you, so this step is purely for seeing under the hood.


Point codegen at the project and pick a generator. It compiles every .aql file and emits a typed client:

Terminal window
axel codegen -g ts -o ./gen

You get one models file, one file per query, and a runner that ties them together:

gen/
models.{ts,go} # one interface/struct per concrete type
create_user.{ts,go} # typed params + row + function
list_users_with_post.{ts,go}
runner.{ts,go} # Runner with typed query methods

models is one type per concrete ASL type (abstract Base is inlined, not emitted):

// Code generated by axel codegen --generator ts. DO NOT EDIT.
export interface User {
active: boolean | null;
age: number;
createdAt: Date;
email: string;
health: number;
id: string;
name: string | null;
updatedAt: Date;
}
// … Post, Comment

Each query becomes a fully-typed function. Here’s the nested read — note how the posts shape produced a nested Posts type:

// Code generated by axel codegen --generator ts. DO NOT EDIT.
import type { DB } from "./runner.ts";
export interface ListUsersWithPostRow {
id: string;
email: string;
posts: ListUsersWithPostRowPosts[];
}
export interface ListUsersWithPostRowPosts {
id: string;
title: string;
}
export async function listUsersWithPost(db: DB): Promise<ListUsersWithPostRow[]> {
const query = `SELECT
u.id AS id,
u.email AS email,
(SELECT json_agg(row_to_json(p_posts_sub)) FROM (SELECT p.id AS id, p.title AS title FROM "post" p WHERE p.author = u.id) p_posts_sub) AS posts
FROM "user" u;`;
return db.unsafe<ListUsersWithPostRow>(query);
}

The full generator options — the TypeScript client (bun vs pg), the Go package, enum handling, and the @name / @request / @response directives — are in the Code Generation guide.


The generated Runner exposes every query as a typed method under query (TypeScript) / Query (Go). Wire it to a database connection and call them.

import { SQL } from "bun";
import { Runner } from "./gen/runner.ts";
const sql = new SQL({
url: "postgres://user:password@localhost:5432/db?sslmode=disable",
});
const runner = new Runner(sql);
// INSERT — typed params in, typed row out
const alice = await runner.query.createUser({ email: "alice@example.com" });
console.log("created", alice?.id);
// Nested read — users with their posts, in a single round-trip
const users = await runner.query.listUsersWithPost();
for (const u of users) {
console.log(u.email, u.posts.map((p) => p.title));
}

Run it:

Terminal window
bun run app.ts

You went from an empty folder to a working, type-safe data layer:

  1. axel.yaml + schema.asl — declared the project and its types.
  2. axel validate — checked the schema with no database.
  3. axel diff + axel up — turned the schema into migration SQL and applied it.
  4. .aql files — wrote queries, including a nested shape that avoids N+1.
  5. axel codegen — got typed TypeScript and Go clients.
  6. Called the generated Runner from a real program.

The whole loop — edit schema → diffup, edit queries → codegen — is what you repeat as the project grows.