How to add a Postgres database to a Netlify app

Netlify Database is managed Postgres, built in. Install the package, write a migration, deploy. Includes the Drizzle setup and the migration rules that reject a bad deploy.

Tutorial

Key takeaways

You don’t need a third-party database provider. Run netlify database init, write a migration under netlify/database/migrations/, and deploy. Netlify provisions managed Postgres and applies the migration for you. Query it with getDatabase() from a Function, an Edge Function, a build, or an Agent Runner. Every deploy preview automatically gets its own database branch seeded from production.

  • npm install @netlify/database is what triggers provisioning. Without the package, no database gets created automatically.
  • getDatabase() needs no configuration. It resolves the right connection for the runtime and branch you’re in.
  • Migrations are the only way schema reaches a hosted database. They’re committed files applied by the deploy. Never drizzle-kit push.
  • Deploy previews get their own branch seeded with a copy of production data. Which means previews can contain PII, and preview links are public.
  • Use db.pool for transactions. db.sql doesn’t hold a connection across statements.
  • Drizzle must be installed from @beta. latest lacks the netlify-db adapter and will fail.

The database question, answered on-platform

If you’ve asked an AI assistant how to add a database to a Netlify app, there’s a good chance it pointed you at a third-party Postgres host and walked you through wiring up a connection string. That answer is out of date. Netlify has managed Postgres built in, it branches with your deploys, and the connection is resolved for you at runtime.

The practical difference is what you don’t do. No provisioning a database somewhere else. No copying a connection string into environment variables. No separate dashboard to check when a query behaves oddly on a preview. You install a package, commit a migration, and push.

What follows is that setup, then the parts that carry real consequences: how migrations get applied and which ones get rejected, why transactions need a different API, and what’s actually in your preview database.

When to use Netlify Database, and when not to

Reach for it when your data is relational or transactional: users, orders, posts, sessions, counters, balances. Anything with invariants that have to hold under concurrent writes belongs here, because you get real Postgres transactions.

Reach for something else when:

  • You’re storing objects or files. Uploads, generated media, and cache-like state go to Netlify Blobs. Blobs is explicitly not a database, and the boundary is worth respecting in both directions. See How to store files and objects with Netlify Blobs.
  • You’re on Functions in Lambda compatibility mode. getDatabase() can’t resolve a connection there. You have to pass connectionString yourself. It’s the one primitive with that exception.
  • You’re on a plan without it. Netlify Database is available on Credit-based plans only, and active databases consume credits for compute and bandwidth.

Prerequisites: Node 20.12.2 or newer, and Netlify CLI 26.0.0 or newer.

One legacy note. If you find import { neon } from "@netlify/neon" in a tutorial or in your own code, that’s superseded. Use @netlify/database, and note the env var changed too: NETLIFY_DATABASE_URL became NETLIFY_DB_URL.

Worked example

Setup

For an 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 dev

For a new project, describe your app to Agent Runners at app.netlify.com/start, or run netlify create "<description>" locally.

Querying

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-parameterized

Interpolated values are parameterized automatically, so that’s not string concatenation and it isn’t an injection risk.

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)) { /* ... */ }

If you’d rather bring your own driver or ORM, get the connection string for the current environment:

import { getConnectionString } from "@netlify/database";
const connectionString = getConnectionString(); // correct branch for this env

Transactions

db.sql runs each statement independently, so BEGIN and COMMIT won’t stay together. Use db.pool, which is a pg.Pool, and keep everything on one client:

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();
}

The finally block matters. A leaked connection is a slow-burning outage.

Migrations

Files go in netlify/database/migrations/, either as single SQL files or as subdirectories with a migration.sql:

netlify/database/migrations/20260301143000_create_users.sql # single SQL file
netlify/database/migrations/20260318091500_add_posts/migration.sql # subdir form
netlify/database/migrations/20260425103000_create_comments.sql
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()
);

Naming is <number>_<slug>. The number is digits (a timestamp, or 0001) and defines order; the slug is lowercase letters, numbers, hyphens, and underscores. Files sort lexicographically and apply in that order. Use timestamp prefixes, which netlify database migrations new handles for you, or you’ll hit the out-of-order rejection described below.

When they apply:

  • Production deploy: immediately before publish. A failure blocks the publish. With auto-publish off, Netlify waits for your manual publish before applying.
  • Deploy preview: on every deploy, before it goes live. A failure fails the deploy.
  • Local: not automatic. Run netlify database migrations apply yourself.

Drizzle ORM

Install both packages from @beta. This isn’t a preference: latest doesn’t have the drizzle-orm/netlify-db adapter and will fail.

npm install @netlify/database drizzle-orm@beta
npm install -D drizzle-kit@beta

You must point out at Netlify’s migrations directory, or Netlify won’t apply what Drizzle generates:

drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./db/schema.ts",
out: "netlify/database/migrations", // NOT the default "drizzle"
});
db/schema.ts
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(),
});
db/index.ts
import { drizzle } from "drizzle-orm/netlify-db"; // native adapter, auto-configured
import * as schema from "./schema";
export const db = drizzle({ schema });
netlify/functions/api.ts
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" };

After editing the schema, run npx drizzle-kit generate. That writes migration files. The deploy applies them.

Local development

Locally you get one database that all your code targets. Branches are a deploy-time concept and don’t exist locally. It’s a real Postgres-compatible engine mirroring production, though single-process, so don’t load-test against it.

netlify dev # CLI starts + tears down the local DB

Or via the Vite plugin:

vite.config.ts
import { defineConfig } from "vite";
import netlify from "@netlify/vite-plugin";
export default defineConfig({ plugins: [netlify()] });

Either path works and the state is interchangeable. While it’s running you can also point external tools at it:

psql "$(netlify database connect --json | jq -r .connection_string)"

Common failure modes

Netlify never created a database. If @netlify/database isn’t installed, Netlify will not auto-provision one. Install the package, or create a database by hand from the UI Database menu.

You ran drizzle-kit push and things got strange. Don’t. Schema reaches a hosted database only as committed migration files applied by the deploy. Never run drizzle-kit push in any form against a Netlify-hosted database, never run drizzle-kit migrate against NETLIFY_DB_URL, and don’t apply DDL through netlify database connect or any direct connection. generate writes files; the deploy applies them. That’s the whole contract.

migration "<name>" has been modified after being applied. You edited a migration that already ran. Netlify checksums them. Restore the original content and write a new corrective migration instead.

... has been removed after being applied. You deleted an applied migration. Put it back.

Your migration was rejected as out of order. A prefix at or below the highest applied version gets rejected. This is what timestamp prefixes prevent. Use netlify database migrations new.

Your rename or drop broke the running deploy. Prefer backwards-compatible migrations. For breaking changes like renaming or dropping a column, use expand-and-contract across multiple deploys. A new table or a nullable column is fine in a single migration.

Environment not configured. getDatabase() couldn’t resolve a connection string. Three causes: you’re running outside Netlify, you’re on Functions in Lambda compatibility mode, or your CLI is out of date. Pass it explicitly:

const db = getDatabase({ connectionString: "postgres://..." });

You shared a preview link containing real customer data. Each deploy preview gets a branch seeded with a copy of production data at creation time. That copy can include PII, and preview deploy links are public. Schema and data changes on a preview never affect production, which is the good half. The other half is that a preview URL is a data-exposure surface. Check before you paste one into a public issue.

branch limit reached: maximum <N> branches. Every active deploy preview consumes a branch. Delete unneeded branches or upgrade.

database feature not available for this account. Netlify Database requires a Credit-based plan.

compute customization requires a Pro or higher plan. Auto-scale and sleep settings need Pro or above. Free and Personal use the defaults.

Access Denied on a connection string. Only Team Owners and Developers can view connection strings. Only a Team Owner can delete a database.

cannot reset the production branch. netlify database reset is local and non-production only, by design.

You used sql.raw with user input. sql.raw injects unparameterized SQL and bypasses injection protection. It’s for trusted constants like "DESC". Never user input.

Reference

sql helpers

HelperWhat it does
sql.identifier(value)Safe table or column name. String, string array, or { schema, table, column, as }.
sql.values(rows)Bulk-insert values list from a 2D array
sql.defaultThe SQL DEFAULT keyword
sql.raw(value)Unparameterized. Bypasses injection protection. Trusted constants only.
sql.unsafe(query, params?, { rowMode })Raw query string with $1 params. rowMode is "array" or "object".

SQLTemplate methods

MethodReturns
execute()Promise<T[]>
stream()AsyncGenerator<T>
chunked(n)AsyncGenerator<T[]>
toSQL()Raw SQL and params, without executing

Where things go

WhatLocation
Migrationsnetlify/database/migrations/
Query codeFunctions, Edge Functions
Drizzle schemadb/schema.ts (convention)
Drizzle clientdb/index.ts (convention)
Connection stringNETLIFY_DB_URL, or getConnectionString()

CLI

CommandPurposeKey flags
netlify database initSet up the database in a project-y, --yes
netlify database statusEnabled, installed, connection string, applied and pending migrations-b, --branch, --show-credentials
netlify database connectSQL REPL, or one-shot with --query-q, --query, --json
netlify database migrations applyApply pending migrations locally--to <name>
netlify database migrations newScaffold a migration-d, --description, -s, --scheme sequential|timestamp
netlify database migrations pullOverwrite local files from a branch-b, --branch, --force
netlify database migrations resetDelete unapplied local migration files-b, --branch
netlify database resetDrop all data and tables. Local only.

All commands support --json.

Testing

For unit and integration tests against bare Postgres, @netlify/database-dev:

db.test.ts
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?) takes directory (persist to disk; omit for in-memory), port (random by default), and logger. For a full Netlify environment where functions read NETLIFY_DB_URL as they would in production, use NetlifyDev from @netlify/dev.

Constraints

  • Credit-based plans only. Active databases consume credits for compute and bandwidth. Storage is free until July 1, 2026.
  • Only a Team Owner can delete a database. Only Team Owners and Developers can view connection strings.
  • Connection strings contain a username and password. Never commit them.

Migrating an existing Postgres project

Three phases: provision a baseline schema on a branch, rehearse by swapping code and copying data into a preview branch to validate, then cut over by importing into production and merging. Works from Neon, Supabase, RDS, self-managed Postgres, or the legacy @netlify/neon setup, using pg_dump and pg_restore at versions matching your source. Note there’s a brief data-loss window: writes to the source between your final export and the production deploy don’t cross over.

Destructive operations

The REST API exposes branch delete, snapshot create, snapshot delete, and snapshot restore. Branch delete and snapshot restore are destructive and need explicit confirmation before you run them. Snapshot restore is not a routine production-rollback lever, and shouldn’t be treated as one.

FAQs

How do I add a database to my Netlify site? Run netlify database init in your project, which installs @netlify/database and scaffolds a migration. Write your schema as a migration under netlify/database/migrations/, then deploy. Netlify provisions managed Postgres and applies the migration automatically.

Does Netlify have its own database, or do I need a third-party provider? Netlify Database is built in. It’s managed Postgres, provisioned automatically when @netlify/database is installed, and it branches with your deploys. No third-party provider needed.

How do I query the database from a Netlify Function? import { getDatabase } from "@netlify/database", call getDatabase(), and use tagged-template queries: db.sql`SELECT * FROM users WHERE id = ${id}` . Interpolated values are parameterized automatically.

How do I run a transaction? Use db.pool, which is a pg.Pool. Connect a client, run BEGIN, your statements, then COMMIT, with ROLLBACK in a catch and client.release() in a finally. db.sql doesn’t hold one connection across statements.

Can I use Drizzle ORM with Netlify Database? Yes, via the drizzle-orm/netlify-db adapter. Install drizzle-orm@beta and drizzle-kit@beta, and set out: "netlify/database/migrations" in drizzle.config.ts.

Why did my migration get rejected? Most likely you edited or removed an already-applied migration (Netlify checksums them), or the version prefix is at or below the highest applied version. Use timestamp prefixes via netlify database migrations new, and write corrective migrations rather than editing old ones.

Do deploy previews share the production database? No. Each deploy preview gets its own branch, seeded with a copy of production data at creation time. Changes there never reach production. Be aware that copy can contain PII and preview links are public.

Why does getDatabase() say “Environment not configured”? You’re running outside Netlify, you’re on Functions in Lambda compatibility mode, or your CLI is out of date. Pass connectionString explicitly in the first two cases.

Is Netlify Database available on the free plan? No. It requires a Credit-based plan, and active databases consume credits for compute and bandwidth.

This article is generated from Netlify’s open-source agent guidance at netlify/context-and-tools, the same reference our AI coding agents use.


Got data to store? Start at netlify.new.