Skip to content
runlot

Your first query

Walks through the basics — create a table, store rows, and read them back.

Create a table

Create the file migrations/0001_init.sql.

migrations/0001_init.sql
create table posts (
  id         bigserial primary key,
  title      text not null,
  created_at timestamptz not null default now()
);

Apply the migration.

runlot pg migrate

Use the data from a worker

src/index.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === "POST" && url.pathname === "/posts") {
      const { title } = (await request.json()) as { title: string };
      const rows = await env.db.exec(
        "insert into posts (title) values ($1) returning id, title",
        [title],
      );
      return Response.json(rows[0], { status: 201 });
    }

    const rows = await env.db.exec(
      "select id, title, created_at from posts order by id desc limit 20",
    );
    return Response.json(rows);
  },
};
runlot deploy

Check that it works

curl -X POST https://my-app.me.runlot.app/posts \
  -H 'content-type: application/json' \
  -d '{"title":"hello"}'

curl https://my-app.me.runlot.app/posts

You can also check directly from the CLI.

runlot pg execute -c "select count(*) from posts"

The SQL console in the dashboard

The project's Database tab has a SQL console button. It opens an editor with a list of your tables down the left side; clicking a name writes SELECT * FROM … LIMIT 100 for you. Run with ⌘+Enter or Ctrl+Enter.

It reaches the database the same way psql does — the console holds your project's connection credential and dials the same endpoint — so everything that is true on the wire is true here: the 10-second statement limit, the same errors, the same SQLSTATE.

Read-only is on by default. With it on, the statement runs inside a read-only transaction that is rolled back whether it succeeds or not, so nothing you type can change the data. Turning it off asks once, and then writes take effect and cannot be undone — recovering from one means restoring a backup.

Two things to expect from the results:

  • At most 500 rows are listed. The cap is applied to the response, not added to your statement, so the row count underneath still reports what the statement actually did. Add LIMIT and OFFSET to page through the rest.
  • Values are shown as text, and NULL is drawn differently from the empty string. Numbers are not converted to JavaScript numbers on the way, so a large bigint shows every digit it has.

When a statement is refused, the panel shows PostgreSQL's own message together with its SQLSTATE — the five-character code, such as 23505 for a unique violation. That code is what the PostgreSQL manual is indexed by, and it is the string worth searching for.

Running a statement needs the member role, the same as viewing the connection details — the console runs on that credential. Writes are recorded in the organization's audit log; reads are not.

Next steps

  • To handle errors by SQLSTATE, see tryExec.
  • For an API closer to node-postgres, use @runlot/pg.
  • To use an ORM, see ORMs.

On this page