Zig 0.17.0

PostgreSQL

Database shows migrations and queries with SQLite, which is what a new project uses. This page is about a project on PostgreSQL: what changes, and what the database can then do for the app.

The code is from Bookings, the example app in examples/bookings (source): people book meeting rooms by the half hour, and a room cannot have two bookings at the same time.

A project on PostgreSQL

bash
spider new bookings --pg
cd bookings
docker compose up -d db

The project comes with a docker-compose.yml that runs PostgreSQL for development, and a .env that says where it is:

text
PG_HOST=localhost
PG_PORT=5562
PG_USER=postgres
PG_PASSWORD=postgres
PG_DB=bookings_development

main opens the connections and applies the migrations before it serves:

zig
pub fn main(init: std.process.Init) !void {
    const allocator = init.arena.allocator();
    const io = init.io;

    try db.init(allocator, io, .{});
    defer db.deinit();

    // Applies the migrations that have not run yet: a new database, or a new
    // container, is ready when the server starts. `spider migrate`
    // (SPIDER_MIGRATE=1) only migrates.
    const applied = try migrate(allocator);
    if (spider.migrating()) {
        std.log.info("{d} migration(s) applied", .{applied});
        return;
    }

    try serve(allocator, io);
}

/// Applies the migrations that have not run yet, in order, each in its own
/// transaction with its row in schema_migrations, under a lock: several
/// copies of the app starting at once apply each one once. Returns how many
/// ran. A migration edited after it ran stops it (error.MigrationChanged):
/// add a new one instead. main() calls it, and so can the tests, on a test
/// database.
pub fn migrate(allocator: std.mem.Allocator) !usize {
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    var migrator = spider.app(.{});
    defer migrator.deinit();
    return migrator.migrations(migrations).migrate(arena.allocator());
}

db.init opens a pool of connections, ten unless .pool_size says otherwise. Every query takes one from the pool and gives it back. What .{} leaves out comes from the PG_* variables.

The migrations are the files of src/core/db/migrations/, built into the binary: build.zig has a line for it, and there is no list to keep. migrate applies the ones that have not run, each in a transaction of its own, so a new database, or a new container, is ready when the server starts. With SPIDER_MIGRATE=1 the app only migrates and exits, which is also what spider migrate runs. Migrations in production is about running them on a server.

A rule the database keeps

A migration is a SQL file, as with SQLite, and the files run in the order of their names. This one makes the bookings, and with them the rule of the whole app:

sql
-- migrate:up
-- Needed by the rule below: it lets one constraint compare a plain column
-- (the room) and a range (the time) together.
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE bookings (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    room_id BIGINT NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
    day DATE NOT NULL,
    starts TIME NOT NULL,
    ends TIME NOT NULL,
    title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 80),
    note TEXT CHECK (char_length(note) <= 500),
    booked_by TEXT NOT NULL,
    -- held: waiting to be confirmed, until held_until. confirmed: kept.
    -- cancelled and expired: over; the time is free again.
    state TEXT NOT NULL DEFAULT 'held' CHECK (state IN ('held', 'confirmed', 'cancelled', 'expired')),
    held_until TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- The day runs from 08:00 to 20:00, in half hours.
    CONSTRAINT bookings_within_the_day CHECK (starts >= '08:00' AND ends <= '20:00' AND starts < ends),
    CONSTRAINT bookings_on_the_half_hour CHECK (
        extract(minute FROM starts) IN (0, 30) AND extract(minute FROM ends) IN (0, 30)
        AND extract(second FROM starts) = 0 AND extract(second FROM ends) = 0
    ),

    -- The rule of the whole app: a room cannot have two bookings at the same
    -- time. Only bookings that hold the room count (held or confirmed), and
    -- two that touch (one ends at 10:00, the next starts at 10:00) do not
    -- overlap. The database refuses the second one whoever asks and however
    -- close together the two requests arrive.
    CONSTRAINT bookings_do_not_overlap EXCLUDE USING gist (
        room_id WITH =,
        tsrange(day + starts, day + ends) WITH &&
    ) WHERE (state IN ('held', 'confirmed'))
);

CREATE INDEX bookings_by_day ON bookings (day, room_id);

-- migrate:down
DROP TABLE IF EXISTS bookings;

The last constraint is the rule: no two rows of the same room whose times overlap, counting only bookings that still hold the room. It is in the database on purpose. A check in the app ("is the room free? then insert") has a gap between the question and the insert, and two requests can both get through it. The constraint has no gap.

The CHECKs say the rest: a day runs from 08:00 to 20:00, in half hours, and a title is not empty.

Rows as structs

A row comes back as a struct: each column goes to the field of the same name.

zig
/// Where a booking is in its life.
pub const State = enum {
    /// Made, and waiting for its owner to confirm it. It holds the room
    /// until `held_until`.
    held,
    /// Kept: the room is taken.
    confirmed,
    /// Given up by its owner. The time is free again.
    cancelled,
    /// Never confirmed. The time is free again.
    expired,
};

/// One booking, as the pages show it.
pub const Booking = struct {
    id: i64,
    room_id: i64,
    /// The room's name.
    room: []const u8,
    /// "2026-10-12"
    day: []const u8,
    /// "09:00"
    starts: []const u8,
    /// "10:30"
    ends: []const u8,
    title: []const u8,
    note: ?[]const u8 = null,
    booked_by: []const u8,
    /// The account that made it; null for one made before accounts existed.
    user_id: ?i64 = null,
    state: State,
    /// For a held booking: whole minutes until the hold ends, 0 once it
    /// has. null in every other state.
    minutes_left: ?i32 = null,
    /// Made in the last few seconds: the day view points it out.
    fresh: bool = false,
};
zig
const columns =
    \\b.id, b.room_id, r.name AS room,
    \\b.day::text AS day,
    \\to_char(b.starts, 'HH24:MI') AS starts,
    \\to_char(b.ends, 'HH24:MI') AS ends,
    \\b.title, b.note, b.booked_by, b.user_id, b.state,
    \\CASE WHEN b.state = 'held'
    \\     THEN greatest(0, ceil(extract(epoch FROM b.held_until - now()) / 60))::integer
    \\END AS minutes_left,
    \\b.state IN ('held', 'confirmed') AND now() - b.created_at < interval '8 seconds' AS fresh
;
const from = " FROM bookings b JOIN rooms r ON r.id = b.room_id ";

/// The bookings that take a room on `day` ("2026-10-12"): held and
/// confirmed ones, by room and by time.
pub fn forDay(arena: std.mem.Allocator, day: []const u8) ![]model.Booking {
    const sql = "SELECT " ++ columns ++ from ++
        "WHERE b.day = $1::date AND b.state IN ('held', 'confirmed') ORDER BY r.name, b.starts";
    return db.query(model.Booking, arena, sql, .{day});
}

const from = " FROM bookings b JOIN rooms r ON r.id = b.room_id ";

/// The bookings that take a room on `day` ("2026-10-12"): held and
/// confirmed ones, by room and by time.
pub fn forDay(arena: std.mem.Allocator, day: []const u8) ![]model.Booking {
    const sql = "SELECT " ++ columns ++ from ++
        "WHERE b.day = $1::date AND b.state IN ('held', 'confirmed') ORDER BY r.name, b.starts";
    return db.query(model.Booking, arena, sql, .{day});
}

What to know about the types:

  • A []const u8 field takes any column as text, so a date or a time is asked for the way the page shows it: b.day::text, to_char(b.starts, 'HH24:MI').
  • An enum field takes a text column (or a PostgreSQL enum) whose value is one of its names: state is 'held', the field is State.held.
  • A column that can be NULL needs an optional field. note: ?[]const u8 takes a missing note as null; a NULL into a field that is not optional is error.UnexpectedNull.
  • A field with no column of its name is error.ColumnMissing, unless the field has a default.

Values go in the last argument and the SQL says where with $1, $2. A value is never written into the SQL text.

Lists

PostgreSQL has arrays, and a room's features are one: a TEXT[] column. In the struct it is a slice, and the row fills it:

zig
/// A room people book.
pub const Room = struct {
    id: i64,
    name: []const u8,
    /// How many people fit.
    capacity: i32,
    /// What the room has: "projector", "whiteboard". Empty for a room with
    /// nothing.
    features: []const []const u8,
};

A slice of text takes a TEXT[], a slice of i64 a BIGINT[], and so on. An empty array is an empty slice.

Going the other way, a list is one parameter, however many values it has. "Every room with a projector and a whiteboard" is one comparison:

zig
const columns = "id, name, capacity, features";

/// The rooms that have every one of `wanted` ("projector", "whiteboard").
/// An empty list is every room.
pub fn withFeatures(arena: std.mem.Allocator, wanted: []const []const u8) ![]model.Room {
    // `@>` is "contains": the room's list has all of the wanted ones. The
    // list goes in as ONE parameter, whatever its length.
    const sql = "SELECT " ++ columns ++ " FROM rooms WHERE features @> $1 ORDER BY name";
    return db.query(model.Room, arena, sql, .{db.array([]const u8, wanted)});
}

db.array also works with = ANY($1): WHERE id = ANY($1) with a list of ids.

Transactions

Each query may run on a different connection of the pool, so BEGIN in one query and COMMIT in another would not be one transaction: Spider refuses them with error.UseBeginForTransactions. A transaction is db.transaction: it takes one connection, runs the function, commits when it returns and rolls back when it fails.

Making a booking is two statements that must both happen or neither. A hold that ran out is released, then the new booking takes the time:

zig
/// Why a booking could not be made.
pub const CreateError = error{
    /// The room already has a booking in that time.
    SlotTaken,
    /// Outside 08:00-20:00, not on the half hour, the end before the
    /// start, or a title that is empty or too long.
    InvalidBooking,
    /// No room with that id.
    NoSuchRoom,
};

/// Makes a booking, held for `model.hold_minutes`. error.SlotTaken when the
/// room is not free then: the database decides, so two requests for the
/// same time cannot both get it.
pub fn create(arena: std.mem.Allocator, input: model.Input) !model.Booking {
    const Work = struct { arena: std.mem.Allocator, input: model.Input };
    // Two statements that must both happen or neither: a hold that ran out
    // is released, then the new booking takes the time. One transaction,
    // on one connection.
    const id = db.transaction(i64, Work{ .arena = arena, .input = input }, struct {
        fn run(tx: *db.Transaction, work: Work) !i64 {
            const in = work.input;
            try tx.query(void, work.arena,
                \\UPDATE bookings SET state = 'expired'
                \\WHERE room_id = $1 AND day = $2::date AND state = 'held' AND held_until < now()
            , .{ in.room_id, in.day });
            return tx.query(i64, work.arena,
                \\INSERT INTO bookings (room_id, day, starts, ends, title, note, booked_by, user_id, held_until)
                \\VALUES ($1, $2::date, $3::time, $4::time, $5, $6, $7, $8, now() + make_interval(mins => $9))
                \\RETURNING id
            , .{ in.room_id, in.day, in.starts, in.ends, in.title, in.note, in.booked_by, in.user_id, @as(i32, model.hold_minutes) });
        }
    }.run) catch |err| switch (err) {
        // What the database said, in the app's words. The names are the
        // rules written in the migration: the EXCLUDE constraint, the
        // CHECKs, the reference to the room.
        error.ExclusionViolation => return error.SlotTaken,
        error.CheckViolation, error.InvalidDatetimeFormat, error.DatetimeFieldOverflow => return error.InvalidBooking,
        error.ForeignKeyViolation => return error.NoSuchRoom,
        else => return err,
    };
    return (try find(arena, id)).?;
}

Inside the function, queries go through tx, not db. db.begin() gives the same thing by hand: a Transaction with query, queryOne, commit and rollback.

Errors by name

When PostgreSQL refuses a statement, the error says why by name. The ones an app meets most:

  • error.UniqueViolation: a value that must be unique is there already.
  • error.ForeignKeyViolation: the row points at one that does not exist, or is pointed at by one.
  • error.NotNullViolation: a required column was left out.
  • error.CheckViolation: a CHECK said no.
  • error.ExclusionViolation: an EXCLUDE constraint said no.

The catch in create above turns them into the app's own errors: the EXCLUDE constraint refusing a row is error.SlotTaken, a CHECK is error.InvalidBooking. The handler then answers a visitor in the app's words, without knowing a constraint exists:

zig
/// Makes the booking. Refused, the form comes back as it was typed, with
/// what is wrong beside the field it is about.
pub fn create(c: *spider.Ctx) !Response {
    const sent = try c.parseForm(Form);
    const form: Form = .{
        .room = sent.room,
        .date = sent.date,
        .start = sent.start,
        .end = sent.end,
        .title = std.mem.trim(u8, sent.title, " \t\r\n"),
        .note = std.mem.trim(u8, sent.note, " \t\r\n"),
    };

    var errors: Errors = .{};
    if (form.title.len == 0) errors.title = "Give the booking a title.";
    if (form.title.len > 80) errors.title = "The title can have up to 80 characters.";
    if (form.note.len > 500) errors.note = "The note can have up to 500 characters.";
    const start = grid.slotOf(form.start);
    const end = grid.slotOf(form.end);
    if (start == null or end == null) {
        errors.time = "Pick a start and an end between 08:00 and 20:00.";
    } else if (end.? <= start.?) {
        errors.time = "The end has to be after the start.";
    }
    if (errors.any()) return formPage(c, form, errors, .unprocessable_entity);

    const made = repository.create(c.arena, .{
        .room_id = form.room,
        .day = form.date,
        .starts = form.start,
        .ends = form.end,
        .title = form.title,
        .note = if (form.note.len > 0) form.note else null,
        .booked_by = c.params.get("_auth_name") orelse "Someone",
        .user_id = viewerId(c),
    }) catch |err| switch (err) {
        error.SlotTaken => {
            errors.conflict = true;
            errors.time = try conflictMessage(c, form);
            return formPage(c, form, errors, .unprocessable_entity);
        },
        error.InvalidBooking => {
            errors.time = "That day and time cannot be booked.";
            return formPage(c, form, errors, .unprocessable_entity);
        },
        error.NoSuchRoom => {
            errors.time = "Pick a room.";
            return formPage(c, form, errors, .unprocessable_entity);
        },
        else => return err,
    };
    day_view.announce(c, made.day);
    // A form htmx posted gets the address in a header, any other a redirect.
    return c.htmxRedirect(try std.fmt.allocPrint(c.arena, "/bookings/{d}", .{made.id}));
}

db.lastError() has the rest of what PostgreSQL said about the last failure: its code, its message, the constraint and the column. Its detail can hold values of the row: log it, do not show it.

The same rule, twenty at once

A test asks for the same room and time from twenty threads. One gets it, nineteen are refused, and none fails any other way:

zig
test "twenty requests for the same time: exactly one gets it" {
    _ = try app_test.running();
    var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const birch = try roomId(arena, "Birch");

    const Race = struct {
        room: i64,
        won: std.atomic.Value(u32) = .init(0),
        refused: std.atomic.Value(u32) = .init(0),
        other: std.atomic.Value(u32) = .init(0),

        fn ask(race: *@This()) void {
            var mine: std.heap.ArenaAllocator = .init(std.heap.smp_allocator);
            defer mine.deinit();
            if (bookings.create(mine.allocator(), booking(race.room, "2031-03-17", "09:00", "10:00"))) |_| {
                _ = race.won.fetchAdd(1, .seq_cst);
            } else |err| {
                _ = (if (err == error.SlotTaken) &race.refused else &race.other).fetchAdd(1, .seq_cst);
            }
        }
    };
    var race: Race = .{ .room = birch };
    var threads: [20]std.Thread = undefined;
    for (&threads) |*t| t.* = try std.Thread.spawn(.{}, Race.ask, .{&race});
    for (threads) |t| t.join();

    try std.testing.expectEqual(@as(u32, 1), race.won.load(.seq_cst));
    try std.testing.expectEqual(@as(u32, 19), race.refused.load(.seq_cst));
    try std.testing.expectEqual(@as(u32, 0), race.other.load(.seq_cst));
    try std.testing.expectEqual(@as(usize, 1), (try bookings.forDay(arena, "2031-03-17")).len);
}

Tests need a database too

The tests write to the database they are given, so it is never the development one. Bookings starts a PostgreSQL that exists for one run: its data is in memory, and the container is removed when the tests end, whether they pass or not.

yaml
# A PostgreSQL for the tests only: it keeps its data in memory and is thrown
# away after each run. `./test.sh` starts it, runs the tests and removes it.
# TEST_PG_PORT picks another port on this machine (5563 unless it is set).
services:
  db-test:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=bookings_test
    ports:
      - "${TEST_PG_PORT:-5563}:5432"
    tmpfs:
      - /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d bookings_test"]
      interval: 1s
      timeout: 3s
      retries: 30
bash
#!/usr/bin/env bash
# Runs the tests against a PostgreSQL that exists only for this run.
#
#     ./test.sh            every test
#     ./test.sh --summary all   (anything after it goes to `zig build test`)
#
# The tests write to the database they are given, so it is never the
# development one: a container is started with its data in memory, and
# removed when the tests end, whether they pass or not. Needs Docker.
#
# TEST_PG_PORT (5563) and TEST_PROJECT (bookings-test) change the port and
# the name of the containers, when something else uses them.
set -euo pipefail
cd "$(dirname "$0")"

export TEST_PG_PORT="${TEST_PG_PORT:-5563}"
compose() { docker compose -f docker-compose.test.yml -p "${TEST_PROJECT:-bookings-test}" "$@"; }
trap 'compose down --volumes >/dev/null 2>&1' EXIT

compose up -d --wait >/dev/null

PG_HOST=127.0.0.1 PG_PORT="$TEST_PG_PORT" PG_USER=postgres PG_PASSWORD=postgres PG_DB=bookings_test \
    zig build test "$@"

The tests connect to it the way main does, then ask the repositories and the pages:

zig
/// What main() does to serve, on the database the tests are given.
fn run() !void {
    const gpa = std.heap.smp_allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    const io = threaded.io();

    // The tests write to the database, so it is never the development one.
    // `./test.sh` starts a PostgreSQL that lives for one run and points the
    // PG_* variables at it; the tables are made here, as main() makes them.
    try spider.pg.init(gpa, io, .{ .database = "bookings_test" });
    _ = try main.migrate(gpa);

    try main.serve(gpa, io);
}

One thing to know: zig build test does not run a test again when nothing it was built from changed, and the database is not part of that. After changing only the database, change a source file or pass --summary all to see whether the tests ran.

Let the database do the calendar

PostgreSQL knows weekdays, time zones and how to add a day. The day view asks it for everything it says about a day in one query, instead of doing that arithmetic in the app:

zig
/// `day` ("2026-10-12") as the pages talk about it, or today when it is
/// null or not a date. PostgreSQL does the calendar: the weekday, the day
/// before, the day after, and what time it is where the office is.
pub fn dayInfo(arena: std.mem.Allocator, day: ?[]const u8) !model.Day {
    const sql =
        \\SELECT d::text AS day,
        \\       to_char(d, 'FMDay, FMDD FMMonth') AS label,
        \\       (d - 1)::text AS prev, (d + 1)::text AS next,
        \\       d = t AS is_today, d < t AS is_past,
        \\       to_char(n, 'HH24:MI') AS now,
        \\       (extract(hour FROM n) * 60 + extract(minute FROM n))::integer AS now_minutes
        \\FROM (SELECT coalesce($1::date, (now() AT TIME ZONE $2)::date) AS d,
        \\             (now() AT TIME ZONE $2)::date AS t,
        \\             now() AT TIME ZONE $2 AS n) AS clock
    ;
    const asked = db.queryOne(model.Day, arena, sql, .{ day, timeZone() }) catch |err| switch (err) {
        // Not a date: the page shows today.
        error.InvalidDatetimeFormat, error.DatetimeFieldOverflow => null,
        else => return err,
    };
    return asked orelse (try db.queryOne(model.Day, arena, sql, .{ @as(?[]const u8, null), timeZone() })).?;
}