Zero-config Postgres for Netlify apps via @netlify/database — querying data from Functions/Edge Functions, writing schema migrations, setting up Drizzle ORM, local dev with netlify dev, database branches for deploy previews, and migrating an existing Postgres project onto Netlify. Use when adding a database, building a contact form or CRUD API, writing SQL migrations, wiring up Drizzle, running netlify database commands, testing with a local Postgres, or switching from Neon/Supabase/RDS to Netlify Database.
75
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Zero-config managed Postgres. Install @netlify/database, write migrations under netlify/database/migrations/, deploy — Netlify provisions the DB and applies migrations automatically. Queryable from Functions, Edge Functions, Builds, and Agent Runners.
import { getDatabase } from "@netlify/database";
const db = getDatabase(); // auto-selects connection for the runtime
const userId = 42;
const users = await db.sql`SELECT * FROM users WHERE id = ${userId}`; // auto-parameterizedOwn driver / ORM instead:
import { getConnectionString } from "@netlify/database";
const connectionString = getConnectionString(); // correct branch for this envLegacy — do NOT use for new code: import { neon } from "@netlify/neon". Superseded by @netlify/database. Replace neon() calls with the Drizzle netlify-db adapter or a Postgres driver via getConnectionString(). The legacy env var NETLIFY_DATABASE_URL is replaced by NETLIFY_DB_URL.
| What | Location |
|---|---|
| Migrations | netlify/database/migrations/ (SQL files or subdirs with migration.sql) |
| Query code | Functions (netlify/functions/), Edge Functions |
| Drizzle schema | db/schema.ts (convention) |
| Drizzle client | db/index.ts (convention) |
| Connection string | NETLIFY_DB_URL env var, or getConnectionString() |
getDatabase(options?) returns a client with sql and pool. options.connectionString overrides the auto-provisioned one; options.debug enables logging.
const db = getDatabase();
const active = await db.sql`SELECT * FROM users WHERE active = ${true}`;
await db.sql`INSERT INTO users (name, email) VALUES (${"Ada"}, ${"ada@example.com"})`;
await db.sql`UPDATE users SET name = ${"Ada Lovelace"} WHERE id = ${1}`;
await db.sql`DELETE FROM users WHERE id = ${1}`;
// Type the rows
interface User { id: number; name: string; email: string; }
const typed = await db.sql<User>`SELECT * FROM users`;
// Stream
for await (const row of db.sql`SELECT * FROM users`.stream()) { /* ... */ }
for await (const chunk of db.sql`SELECT * FROM users`.chunked(100)) { /* ... */ }SQLTemplate methods: execute() → Promise<T[]>, stream() → AsyncGenerator<T>, chunked(n) → AsyncGenerator<T[]>, toSQL() → raw SQL + params without executing.
sql helpers:
sql.identifier(value) — safe table/column name. String, string[], or { schema, table, column, as }.sql.values(rows) — bulk-insert values list from a 2D array.sql.default — the SQL DEFAULT keyword.sql.raw(value) — injects unparameterized SQL; bypasses injection protection. Only for trusted constants (e.g. "DESC"), never user input.sql.unsafe(query, params?, { rowMode }) — raw query string with $1 params; rowMode is "array" or "object".pooldb.pool is a pg.Pool. BEGIN/queries/COMMIT must run on the same connection:
const client = await db.pool.connect();
try {
await client.query("BEGIN");
await client.query("INSERT INTO users (name, email) VALUES ($1, $2)", ["Ada", "ada@example.com"]);
await client.query("INSERT INTO posts (author_id, title) VALUES ($1, $2)", [1, "First post"]);
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}Own drivers:
import { getConnectionString } from "@netlify/database";
import pg from "pg";
const pool = new pg.Pool({ connectionString: getConnectionString() });
// or the `postgres` driver via env var
import postgres from "postgres";
const sql = postgres(process.env.NETLIFY_DB_URL);Install both packages from @beta — required. latest lacks the drizzle-orm/netlify-db adapter and will fail.
npm install @netlify/database drizzle-orm@beta
npm install -D drizzle-kit@betadrizzle.config.ts — you MUST set out to the Netlify migrations directory or Netlify won't apply generated migrations:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./db/schema.ts",
out: "netlify/database/migrations", // NOT the default "drizzle"
});import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial().primaryKey(),
name: text().notNull(),
email: text().notNull().unique(),
createdAt: timestamp().defaultNow(),
});import { drizzle } from "drizzle-orm/netlify-db"; // native adapter, auto-configured
import * as schema from "./schema";
export const db = drizzle({ schema });import { desc } from "drizzle-orm";
import type { Config, Context } from "@netlify/functions";
import { db } from "../../db";
import { users } from "../../db/schema";
export default async (req: Request, context: Context) => {
if (req.method === "GET") {
const allUsers = await db.select().from(users).orderBy(desc(users.createdAt));
return Response.json(allUsers);
}
if (req.method === "POST") {
const { name, email } = await req.json();
const [user] = await db.insert(users).values({ name, email }).returning();
return Response.json(user, { status: 201 });
}
return new Response("Method not allowed", { status: 405 });
};
export const config: Config = { path: "/api/users" };Generate migrations after editing the schema: npx drizzle-kit generate.
Never run drizzle-kit push against a Netlify-hosted database, and never run drizzle-kit migrate against NETLIFY_DB_URL. Schema reaches hosted DBs only as committed migration files applied by the deploy. generate writes files; the deploy applies them.
Files live in netlify/database/migrations/. Two formats:
netlify/database/migrations/20260301143000_create_users.sql # single SQL file
netlify/database/migrations/20260318091500_add_posts/migration.sql # subdir formNaming: <number>_<slug> — number is digits (timestamp or 0001…) defining order; slug is lowercase letters/numbers/hyphens/underscores. Sorted lexicographically, applied in order. Use timestamp prefixes (netlify database migrations new handles this) to avoid out-of-order rejection.
CREATE TABLE comments (
id SERIAL PRIMARY KEY,
post_id INTEGER NOT NULL REFERENCES posts(id),
author_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);When applied:
netlify database migrations apply yourself.Migration footguns (all detected as drift / rejected):
migration "<name>" has been modified after being applied. Write a new corrective migration.... has been removed after being applied. Restore it.Bring-your-own migration system: pick a directory other than netlify/database/migrations to avoid automatic detection, and you own applying to preview branches and production.
See references/migrations.md.
Local is one database that all code targets — branches are a deploy-time concept and don't exist locally. It's a real Postgres-compatible engine mirroring production, but single-process (not for load testing); auto-scale/sleep settings don't apply.
Start it — either path, state is interchangeable:
netlify dev # CLI starts + tears down the local DBOr the Vite plugin:
import { defineConfig } from "vite";
import netlify from "@netlify/vite-plugin";
export default defineConfig({ plugins: [netlify()] });Common commands (while local DB is running):
netlify database migrations apply # apply pending locally
netlify database migrations new -d "add users table" # scaffold new migration
netlify database migrations pull # overwrite local migrations from remote
netlify database status # enabled? installed? applied/pending migrations
netlify database connect # interactive SQL REPL
netlify database connect --query "SELECT * FROM users LIMIT 10"
netlify database reset # drop all schemas/tables — LOCAL ONLY
netlify database migrations reset # delete unapplied local migration filesExternal tools (works while netlify dev runs):
psql "$(netlify database connect --json | jq -r .connection_string)"See references/local-dev.md.
New project: describe your app to Agent Runners at https://app.netlify.com/start, or netlify create "<description>" locally.
Existing project:
netlify database init # installs @netlify/database, picks Drizzle or raw SQL, scaffolds a migration
netlify database init --yes # non-interactive (CI / agents)
netlify devManual: npm install @netlify/database, write a migration under netlify/database/migrations/, write a function, netlify dev, deploy.
If @netlify/database is NOT installed, Netlify will NOT auto-provision a database — you'd have to create one manually from the UI Database menu. Install the package.
netlify database)Prereqs: Node ≥ 20.12.2, Netlify CLI ≥ 26.0.0 (npm install -g netlify-cli). All commands support --json.
| Command | Purpose | Key flags |
|---|---|---|
init | Set up DB in project | -y, --yes |
status | State: enabled, installed, connection string, applied/pending migrations | -b, --branch, --show-credentials |
connect | SQL REPL, or --query one-shot | -q, --query, --json |
migrations apply | Apply pending to local DB | --to <name> |
migrations new | Scaffold a migration | -d, --description, -s, --scheme sequential|timestamp |
migrations pull | Overwrite local files from a branch | -b, --branch, --force |
migrations reset | Delete unapplied local migration files | -b, --branch |
reset | Drop all data/tables — local only | — |
See references/cli-commands.md.
Scoped to a site, rooted at https://api.netlify.com/api/v1, OAuth 2. Full reference: https://open-api.netlify.com.
| Method + path | Purpose |
|---|---|
POST /sites/{site_id}/database | Create DB (returns existing conn string if present); region optional |
GET /sites/{site_id}/database | Get connection string |
POST /sites/{site_id}/database/branch | Create branch; body deploy_id (req), parent_branch_id (opt, defaults to production) |
GET /sites/{site_id}/database/branch/{deploy_id} | Get branch conn string (404 if none) |
DELETE /sites/{site_id}/database/branch/{deploy_id} | Delete a deploy's branch |
POST /sites/{site_id}/database/snapshot | Snapshot a branch (defaults production) |
GET /sites/{site_id}/database/snapshots | List snapshots |
DELETE /sites/{site_id}/database/snapshot/{snapshot_id} | Delete a snapshot |
POST /sites/{site_id}/database/snapshot/{snapshot_id}/restore | Restore snapshot to a branch (defaults production) |
Branch delete and snapshot restore are destructive and require explicit user confirmation first. Snapshot restore is not a routine production-rollback lever.
Bare Postgres for unit/integration tests (no functions):
import { NetlifyDB } from "@netlify/database-dev"; // npm i -D @netlify/database-dev
import { Client } from "pg";
import { afterAll, beforeAll, expect, test } from "vitest";
let db: NetlifyDB, connectionString: string;
beforeAll(async () => {
db = new NetlifyDB();
connectionString = await db.start();
await db.applyMigrations("./netlify/database/migrations");
});
afterAll(async () => { await db.stop(); });
test("inserts and reads a user", async () => {
const client = new Client({ connectionString });
await client.connect();
await client.query("INSERT INTO users (name) VALUES ($1)", ["Ada"]);
const { rows } = await client.query("SELECT name FROM users");
expect(rows).toEqual([{ name: "Ada" }]);
await client.end();
});NetlifyDB(options?): directory (persist to disk; omit = in-memory), port (default random), logger.
Full Netlify environment (functions/edge functions read NETLIFY_DB_URL as in production):
import { NetlifyDev } from "@netlify/dev"; // npm i -D @netlify/dev
const netlifyDev = new NetlifyDev({ projectRoot: "./fixtures/my-project" });
await netlifyDev.start(); // sets NETLIFY_DB_URL in the runtime
// ...tests...
await netlifyDev.stop();Production deploys are the only deploys that touch the production database. Each deploy preview gets its own branch, seeded with a copy of production data at preview-creation time; schema/data changes there never affect production. Wired up automatically, no code changes.
Preview branches can contain production data, including PII — and preview deploy links are public. Warn the user before sharing a preview link.
Environment not configured (getDatabase() can't resolve a connection string): running outside Netlify, on Functions in Lambda compatibility mode, or an outdated CLI. Fix: pass connectionString explicitly.
const db = getDatabase({ connectionString: "postgres://..." });connectionString yourself.database feature not available for this account — requires a Credit-based plan.compute customization requires a Pro or higher plan — auto-scale / sleep settings need Pro+; Free/Personal use defaults.branch limit reached: maximum <N> branches... — each active deploy preview consumes a branch; delete unneeded branches or upgrade.database not found — no DB provisioned; run netlify database init.cannot reset the production branch — reset is non-production only.Access Denied = insufficient role).Three phases: provision (baseline schema on a branch), rehearse (swap code, copy data into a preview branch, validate), cut over (import data into production, merge). Works from any Postgres source (Neon, Supabase, RDS, self-managed, legacy @netlify/neon). Uses pg_dump/pg_restore (versions matching the source). There is a brief data-loss window — writes to the source between final export and production deploy don't cross over.
Phase 2/3 code swap (Drizzle):
import { drizzle } from "drizzle-orm/netlify-db";
import * as schema from "./schema";
export const db = drizzle({ schema });Full step-by-step (dump flags, rollback, cleanup): references/migration-from-extension.md and references/legacy-extension.md.
These are org conventions, not docs facts — merged into the rendered skill by ctx-gen and never generated. Owned by the skills maintainer.
netlify api scraping, no reading tokens from local CLI config files.references/operational-footguns.md,
references/migrations.md, references/local-dev.md,
references/cli-commands.md, references/migration-from-extension.md,
references/legacy-extension.md.drizzle-kit push in any form against a
Netlify-hosted database, never run drizzle-kit migrate against
NETLIFY_DB_URL, and never apply DDL via netlify database connect or
any direct connection.netlify command or a deploy fails, surface the exact error, the
deploy log URL, and the affected site/branch to the user and stop — do
not invent recovery commands or escalate to lower-level tools.401 Access Denied on createSiteDatabase: if it happened
on a --prod-first deploy, retry preview-first (netlify deploy, no
--prod); if a preview also fails, report and stop. Never curl
api.netlify.com, run netlify api createSiteDatabase, or pull tokens
from local CLI config to work around it.drizzle-orm and drizzle-kit must be installed from @beta —
latest lacks the drizzle-orm/netlify-db adapter and will fail. The
body may not soften this to a recommendation.47848e2
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.