A Fast GraphQL API With Fastify and Yoga · Part 2 of 8 in the series
Part 2: A Database With PostgreSQL and Drizzle
Start PostgreSQL in Docker, describe the tables of the guild board in TypeScript with Drizzle, create them with a migration and fill them with sample data.

Quest objectives
- Run PostgreSQL with Docker Compose
- Write tables in TypeScript with Drizzle ORM
- Generate a migration and apply it
- Fill the database with guilds, adventurers and quests
The guild board has three kinds of things. A guild has a name and a motto. An adventurer belongs to a guild and has a class and a level. A quest is posted by a guild, pays a reward, and goes from OPEN to ACCEPTED once an adventurer takes it, and to DONE when it is finished.
They live in PostgreSQL. You describe the tables with Drizzle, an ORM whose queries look like SQL and whose results are typed from the table definitions. And because it shows every query it sends, you will see exactly what your GraphQL API asks the database.
PostgreSQL in Docker
Docker runs PostgreSQL for you, so there is nothing to install. Check that it is there:
$ docker --versionDocker version 29.9.2, build a82e528
Create compose.yml in the project:
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: guild
POSTGRES_PASSWORD: guild
POSTGRES_DB: guild
ports:
- 127.0.0.1:5432:5432
volumes:
- postgres-data:/var/lib/postgresql
volumes:
postgres-data:The port is bound to 127.0.0.1, so only your own computer can reach the database. The data lives in the volume postgres-data and survives restarts. Start it:
$ docker compose up -d Network guild-api_default Created Volume guild-api_postgres-data Created Container guild-api-postgres-1 Started$ docker compose psNAME IMAGE COMMAND SERVICE CREATED STATUS PORTSguild-api-postgres-1 postgres:18-alpine "docker-entrypoint.s…" postgres 1 second ago Up Less than a second 127.0.0.1:5432->5432/tcp
Add the address of the database to .env:
PORT=4000
DATABASE_URL=postgres://guild:guild@localhost:5432/guildDrizzle
Install Drizzle with pg, the PostgreSQL driver, and drizzle-kit, the tool that writes migrations:
$ pnpm add drizzle-orm pgPackages: +14Progress: resolved 14, reused 25, downloaded 14, added 14, donedependencies:+ drizzle-orm 0.45.4+ pg 8.23.1Done in 260ms using pnpm v12.16.1$ pnpm add -D drizzle-kit @types/pg[WARN] 2 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5Packages: +18 -1Progress: resolved 18, reused 38, downloaded 17, added 18, donedevDependencies:+ @types/pg 8.23.1+ drizzle-kit 0.31.11Error: ERR_PNPM_IGNORED_BUILDS × adding a new package ╰─▶ Ignored build scripts: esbuild@0.18.20, esbuild@0.25.12, esbuild@0.28.2 help: Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
The packages are installed, but pnpm stops with an error. pnpm runs no install scripts that you have not allowed, and drizzle-kit depends on esbuild, which uses one to set up its binary. Allow it:
$ pnpm approve-builds esbuild.../esbuild@0.25.12/node_modules/esbuild postinstall$ node install.js.../esbuild@0.18.20/node_modules/esbuild postinstall$ node install.js.../esbuild@0.28.2/node_modules/esbuild postinstall$ node install.js.../esbuild@0.25.12/node_modules/esbuild postinstall: Done.../esbuild@0.28.2/node_modules/esbuild postinstall: Done.../esbuild@0.18.20/node_modules/esbuild postinstall: Done
pnpm writes your choice to pnpm-workspace.yaml. Commit that file, so every install of the project allows the same scripts:
allowBuilds:
esbuild: trueThe tables
Create src/db/schema.ts:
import { index, integer, pgEnum, pgTable, text } from 'drizzle-orm/pg-core';
export const adventurerClass = pgEnum('adventurer_class', ['WARRIOR', 'MAGE', 'ROGUE', 'RANGER']);
export const questStatus = pgEnum('quest_status', ['OPEN', 'ACCEPTED', 'DONE']);
export const guilds = pgTable('guilds', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: text().notNull(),
motto: text(),
});
export const adventurers = pgTable(
'adventurers',
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: text().notNull(),
class: adventurerClass().notNull(),
level: integer().notNull().default(1),
guildId: integer()
.notNull()
.references(() => guilds.id),
},
(table) => [index().on(table.guildId)],
);
export const quests = pgTable(
'quests',
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: text().notNull(),
reward: integer().notNull(),
status: questStatus().notNull().default('OPEN'),
guildId: integer()
.notNull()
.references(() => guilds.id),
assigneeId: integer().references(() => adventurers.id),
},
(table) => [index().on(table.guildId), index().on(table.assigneeId)],
);
export type GuildRow = typeof guilds.$inferSelect;
export type AdventurerRow = typeof adventurers.$inferSelect;
export type QuestRow = typeof quests.$inferSelect;pgEnumcreates PostgreSQL enums for the classes and the status, so the database itself refuses anything else.generatedAlwaysAsIdentity()numbers the rows. The database picks the ids, never the client.references()makes foreign keys: an adventurer cannot belong to a guild that does not exist.The
index()lines index the foreign keys. PostgreSQL does not do that by itself, and in part 4 every list of adventurers and quests is loaded by exactly these columns.$inferSelectturns each table into the type of one of its rows. You will meetGuildRow,AdventurerRowandQuestRowagain in the next part, where they are what the GraphQL resolvers work with.
The columns have camel-case names like guildId, and the database gets guild_id: that is the casing option below. Create the connection in src/db/index.ts:
import { drizzle } from 'drizzle-orm/node-postgres';
export const db = drizzle(process.env.DATABASE_URL!, {
casing: 'snake_case',
logger: process.env.LOG_SQL === 'true',
});
export type Database = typeof db;Given an address, Drizzle opens a pool of up to ten connections, which the whole process shares. logger prints every SQL query when LOG_SQL is true. You switch that on in the next part.
The migration
drizzle-kit compares the tables in your code with its last migration and writes the SQL for the difference. Tell it where everything is in drizzle.config.ts:
import { defineConfig } from 'drizzle-kit';
process.loadEnvFile();
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
casing: 'snake_case',
dbCredentials: { url: process.env.DATABASE_URL! },
});drizzle-kit does not read .env itself, so process.loadEnvFile() does it for it. Add three scripts to package.json:
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "node --env-file=.env src/db/seed.ts"Generate the first migration:
$ pnpm db:generate --name initNo config path provided, using default 'drizzle.config.ts'Reading config file '/home/robin/projects/guild-api/drizzle.config.ts'3 tablesadventurers 5 columns 1 indexes 1 fksguilds 3 columns 0 indexes 0 fksquests 6 columns 2 indexes 2 fks[✓] Your SQL migration file ➜ drizzle/0000_init.sql 🚀
It is plain SQL in drizzle/0000_init.sql, and you can read it before it runs. This is the quests table:
CREATE TABLE "quests" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "quests_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"title" text NOT NULL,
"reward" integer NOT NULL,
"status" "quest_status" DEFAULT 'OPEN' NOT NULL,
"guild_id" integer NOT NULL,
"assignee_id" integer
);Apply it:
$ pnpm db:migrateNo config path provided, using default 'drizzle.config.ts'Reading config file '/home/robin/projects/guild-api/drizzle.config.ts'Using 'pg' driver for database querying[✓] migrations applied successfully!
Sample data
Create src/db/seed.ts:
import { sql } from 'drizzle-orm';
import { db } from './index.ts';
import { adventurers, guilds, quests } from './schema.ts';
await db.execute(sql`truncate quests, adventurers, guilds restart identity cascade`);
const [ember, tide, moss] = await db
.insert(guilds)
.values([
{ name: 'Ember Lions', motto: 'First into the fire.' },
{ name: 'Tidecallers', motto: 'The sea remembers.' },
{ name: 'Moss Wardens', motto: 'Slow roots, strong trees.' },
])
.returning();
const heroes = await db
.insert(adventurers)
.values([
{ name: 'Brakka', class: 'WARRIOR', level: 7, guildId: ember.id },
{ name: 'Ilse', class: 'MAGE', level: 5, guildId: ember.id },
{ name: 'Corin', class: 'ROGUE', level: 3, guildId: ember.id },
{ name: 'Nerys', class: 'MAGE', level: 8, guildId: tide.id },
{ name: 'Tamsin', class: 'RANGER', level: 4, guildId: tide.id },
{ name: 'Odo', class: 'WARRIOR', level: 2, guildId: tide.id },
{ name: 'Fen', class: 'RANGER', level: 6, guildId: moss.id },
{ name: 'Wren', class: 'ROGUE', level: 5, guildId: moss.id },
{ name: 'Hollis', class: 'MAGE', level: 3, guildId: moss.id },
])
.returning();
const [brakka, ilse, , nerys, tamsin, , fen] = heroes;
await db.insert(quests).values([
{ title: 'Slay the cellar rats', reward: 20, guildId: ember.id, status: 'DONE', assigneeId: brakka.id },
{ title: 'Escort the spice caravan', reward: 120, guildId: ember.id, status: 'ACCEPTED', assigneeId: ilse.id },
{ title: 'Find the lost forge hammer', reward: 80, guildId: ember.id },
{ title: 'Tame the volcano drake', reward: 500, guildId: ember.id },
{ title: 'Chart the sunken reef', reward: 90, guildId: tide.id, status: 'ACCEPTED', assigneeId: tamsin.id },
{ title: 'Calm the storm spirit', reward: 300, guildId: tide.id, status: 'DONE', assigneeId: nerys.id },
{ title: 'Recover the pearl crown', reward: 250, guildId: tide.id },
{ title: 'Clear the harbour of krakenlings', reward: 150, guildId: tide.id },
{ title: 'Guard the seed vault', reward: 60, guildId: moss.id, status: 'ACCEPTED', assigneeId: fen.id },
{ title: 'Wake the old treant', reward: 400, guildId: moss.id },
]);
console.log('Seeded 3 guilds, 9 adventurers and 10 quests.');
await db.$client.end();The script first empties the three tables and restarts their ids, so you can run it as often as you like and always get the same board. returning() hands back the inserted rows with their new ids, which the adventurers and quests need. Run it and look into the database:
$ pnpm db:seedSeeded 3 guilds, 9 adventurers and 10 quests.$ docker compose exec postgres psql -U guild -c 'select g.name, count(a.id) as adventurers from guilds g join adventurers a on a.guild_id = g.id group by g.id order by g.id;' name | adventurers--------------+------------- Ember Lions | 3 Tidecallers | 3 Moss Wardens | 3(3 rows)
Three guilds with three adventurers each, and ten quests between them. In the next part you write the GraphQL schema for this data and let GraphQL Codegen type your resolvers.