Neon, serverless PostgreSQL with database branching
Neon is hosted PostgreSQL built around two ideas: compute is separated from storage, so the database can drop to zero when no traffic arrives, and a copy of the entire database is created in a second without copying data. The second is called branching and behaves much like a branch in version control. The engine is open source under Apache 2.0.
The Databricks acquisition
I will start with the ownership change, because older material omits it and it bears on where this product is heading.
On 14 May 2025 Databricks announced its acquisition of Neon in a transaction reported at around a billion dollars. The stated rationale is more interesting than the figure: the buyer said roughly eighty percent of databases created on the service were being created by AI agents rather than people. That explains why this particular product was worth that money to a data platform company.
The figure stops being surprising once you look at where those databases come from. In tools such as Replit, where an agent builds an application from a description, runs it, and deploys it, connecting a database is not a separate human step but part of the generated project. A database created automatically, queried rarely, and sitting idle between one instruction and the next is exactly the workload shape that usage billing and suspending an idle instance fit.
The consequences for users turned out favourable, at least on price. A new pricing model took effect in August 2025, cutting compute costs by fifteen to twenty five percent and storage from 1.75 to 0.35 dollars per gigabyte month. October 2025 doubled the free compute allowance, and December removed monthly minimums, making paid plans purely usage billed.
Healthy caution remains warranted for long term planning, though. A product acquired by a larger company usually starts being shaped by that company's strategy sooner or later, and Databricks' strategy concerns a data platform rather than hosting databases for web applications. The direction is favourable for now, but that is not a guarantee for years ahead.
Branching, the defining feature
This is the feature that leads people to pick this database over any other, and it is worth understanding why it is fast, because both the possibilities and the limits follow from that.
A branch is not a copy of the data. It is created as a pointer to the storage state at a given moment, with new writes landing in a differential layer. Creating a branch from a hundred gigabyte database therefore takes as long as from a hundred megabyte one, and it does not double the storage bill. You pay only for what changes in that branch.
The practical applications follow by themselves. Every pull request can receive its own database holding a full copy of production data, so migrations are tested against a real dataset rather than a shell with three rows. A staging environment stops being a separate system to maintain. Recovering the state before an incident reduces to creating a branch from a point in time rather than restoring a backup.
There is one limitation, following directly from the construction: a branch shares storage with its parent, so it is not isolated in performance terms the way a separate instance is. For load testing, where the point is measuring performance, this is not the right tool.
Scale to zero and its price
The second attraction is dropping to zero when no traffic arrives. The database sleeps after an idle period and stops generating compute cost, waking on the first query.
The price of that convenience is called a cold start. The first query after waking takes longer than subsequent ones, and although the figures involved are hundreds of milliseconds, in an application answering a user that difference is noticeable. For a side project, an internal panel, or a test environment the trade is obvious. For a product where a user's first visit must feel fast, it is worth disabling sleep and accepting a standing cost.
There is also a trap that surprises people estimating a bill. Scale to zero only works when genuinely nothing queries the database. Monitoring checking availability every minute, a background job refreshing anything, or a connection pool holding an open connection all keep the database awake around the clock. If the compute bill looks suspiciously high against low traffic, look there first.
Why Neon?
Key advantages
- Database branching - Create database copies in seconds, without physically copying data
- Scale to zero - No charges when the application is not in use
- Instant wake - Cold start under 500ms
- Pay per use - Only pay for actual compute hours
- Full PostgreSQL compatibility - Support for pgvector, PostGIS, and other extensions
- Secure connections - All connections encrypted with TLS
Neon vs traditional databases
| Feature | Neon | RDS/Cloud SQL | Supabase |
|---|---|---|---|
| Scale to zero | Yes | No | No |
| Branching | Native | Snapshots | No |
| Cold start | ~500ms | N/A | N/A |
| Pricing model | Per compute | Per hour | Flat + usage |
| Free tier storage | 512MB | None | 500MB |
| Connection pooling | Built-in | Extra cost | Built-in |
When to choose Neon?
- Side projects and MVPs - Free plan with scale to zero
- Preview environments - Separate database for each PR
- CI/CD testing - Isolated test environments
- Serverless backends - Works perfectly with Edge Functions
- Development workflow - Branching for feature development
When to consider alternatives?
- Constant, high load - A dedicated instance may be cheaper
- Very low latency - A self-hosted server may be faster
- Specific PG extensions - Check availability in Neon
Neon Architecture
Compute and Storage Separation
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Neon Cloud β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β Compute β β Compute β β Compute β β
β β (main) β β (dev) β β (pr-123) β β
β ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ β
β β β β β
β ββββββββββββββββββββββΌββββββββββββββββββββ β
β β β
β βββββββββΌββββββββ β
β β Pageserver β β
β β (Storage) β β
β βββββββββ¬ββββββββ β
β β β
β βββββββββΌββββββββ β
β β Safekeepers β β
β β (WAL/Durability) β
β βββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββHow does branching work?
main branch
β
βββ commit 1
β
βββ commit 2
β β
β ββββΊ dev branch (copy-on-write)
β β
β βββ dev commit 1
β β
β βββ dev commit 2
β
βββ commit 3
β β
β ββββΊ pr-123 branch
β
βββ commit 4A branch in Neon is a copy-on-write snapshot - it does not physically copy data, only metadata. That is why creating a branch takes seconds, regardless of the database size.
Getting started
Creating a project
- Sign up at console.neon.tech
- Create a new project
- Choose a region (available: US East, US West, Europe, Asia)
- Copy the connection string
Connection String
postgresql://[user]:[password]@[host]/[database]?sslmode=requireExample:
postgresql://neonuser:password123@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=requireCLI Installation
# npm
npm install -g neonctl
# Homebrew (macOS)
brew install neonctl
# Authorization
neonctl authBranching - Git for your database
Creating branches via CLI
# List branches
neonctl branches list
# Create a branch from main
neonctl branches create --name dev
# Create a branch from a specific point in time
neonctl branches create --name staging --parent main --point-in-time "2024-01-15T10:00:00Z"
# Create a branch for a PR
neonctl branches create --name pr-456 --parent main
# Delete a branch
neonctl branches delete pr-456Branching in CI/CD
# .github/workflows/preview.yml
name: Preview Environment
on:
pull_request:
types: [opened, synchronize]
jobs:
create-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Neon Branch
id: create-branch
uses: neondatabase/create-branch-action@v5
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: pr-${{ github.event.pull_request.number }}
api_key: ${{ secrets.NEON_API_KEY }}
- name: Run Migrations
run: |
DATABASE_URL="${{ steps.create-branch.outputs.db_url }}" npm run migrate
- name: Deploy Preview
run: |
# Deploy with the new DATABASE_URL
vercel --env DATABASE_URL="${{ steps.create-branch.outputs.db_url }}"Automatic branch cleanup
# .github/workflows/cleanup.yml
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete Neon Branch
uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: pr-${{ github.event.pull_request.number }}
api_key: ${{ secrets.NEON_API_KEY }}Connecting to your application
Neon Serverless Driver
The official driver optimized for serverless:
npm install @neondatabase/serverlessimport { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL!)
// Simple query
const users = await sql`SELECT * FROM users WHERE active = true`
// With parameters
const userId = 1
const user = await sql`SELECT * FROM users WHERE id = ${userId}`
// Insert
const newUser = await sql`
INSERT INTO users (name, email)
VALUES (${'John Doe'}, ${'john@example.com'})
RETURNING *
`
// Transaction (single statement)
const result = await sql`
WITH inserted AS (
INSERT INTO orders (user_id, total)
VALUES (${userId}, ${99.99})
RETURNING id
)
INSERT INTO order_items (order_id, product_id, quantity)
SELECT id, ${productId}, ${quantity} FROM inserted
RETURNING *
`Neon with Pool (for long-lived connections)
import { Pool } from '@neondatabase/serverless'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// Using with pool
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('INSERT INTO orders ...', [values])
await client.query('UPDATE inventory ...', [values])
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
// Close pool on shutdown
await pool.end()WebSocket for real-time
import { neon, neonConfig } from '@neondatabase/serverless'
import ws from 'ws'
// For Node.js environments (Vercel Edge, Cloudflare Workers have built-in WS)
neonConfig.webSocketConstructor = ws
const sql = neon(process.env.DATABASE_URL!)ORM Integration
Drizzle ORM
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit// db/schema.ts
import { pgTable, serial, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow(),
isActive: boolean('is_active').default(true),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id').references(() => users.id),
publishedAt: timestamp('published_at'),
})// db/index.ts
import { drizzle } from 'drizzle-orm/neon-http'
import { neon } from '@neondatabase/serverless'
import * as schema from './schema'
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
// Usage
const allUsers = await db.select().from(schema.users)
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
})
// Insert
await db.insert(schema.users).values({
name: 'John Smith',
email: 'john@example.com',
})
// Update
await db.update(schema.users)
.set({ isActive: false })
.where(eq(schema.users.id, 1))
// Delete
await db.delete(schema.users).where(eq(schema.users.id, 1))// drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './db/schema.ts',
out: './drizzle',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config# Generate migrations
npx drizzle-kit generate:pg
# Run migrations
npx drizzle-kit push:pg
# Studio (GUI)
npx drizzle-kit studioPrisma
npm install prisma @prisma/client
npx prisma init// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
posts Post[]
createdAt DateTime @default(now())
isActive Boolean @default(true)
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
author User @relation(fields: [authorId], references: [id])
authorId Int
publishedAt DateTime?
}// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
import { PrismaNeon } from '@prisma/adapter-neon'
import { Pool } from '@neondatabase/serverless'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaNeon(pool)
export const prisma = new PrismaClient({ adapter })
// Usage
const users = await prisma.user.findMany({
include: { posts: true },
})
await prisma.user.create({
data: {
name: 'John Smith',
email: 'john@example.com',
posts: {
create: {
title: 'First post',
content: 'Post content...',
},
},
},
})# Migrations
npx prisma migrate dev --name init
npx prisma migrate deploy
# Studio
npx prisma studioKysely
npm install kysely @neondatabase/serverless// db/types.ts
import { Generated, Insertable, Selectable, Updateable } from 'kysely'
export interface Database {
users: UsersTable
posts: PostsTable
}
interface UsersTable {
id: Generated<number>
name: string
email: string
created_at: Generated<Date>
is_active: Generated<boolean>
}
interface PostsTable {
id: Generated<number>
title: string
content: string | null
author_id: number
published_at: Date | null
}
export type User = Selectable<UsersTable>
export type NewUser = Insertable<UsersTable>
export type UserUpdate = Updateable<UsersTable>// db/index.ts
import { Kysely } from 'kysely'
import { NeonDialect } from 'kysely-neon'
import { Database } from './types'
export const db = new Kysely<Database>({
dialect: new NeonDialect({
connectionString: process.env.DATABASE_URL!,
}),
})
// Usage
const users = await db
.selectFrom('users')
.selectAll()
.where('is_active', '=', true)
.execute()
await db
.insertInto('users')
.values({ name: 'John', email: 'john@example.com' })
.execute()Next.js integration
App Router with Server Actions
// app/actions/users.ts
'use server'
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
export async function getUsers() {
return db.select().from(users)
}
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
await db.insert(users).values({ name, email })
revalidatePath('/users')
}
export async function deleteUser(id: number) {
await db.delete(users).where(eq(users.id, id))
revalidatePath('/users')
}// app/users/page.tsx
import { getUsers } from '@/app/actions/users'
import { UserForm } from './user-form'
import { UserList } from './user-list'
export default async function UsersPage() {
const users = await getUsers()
return (
<div>
<h1>Users</h1>
<UserForm />
<UserList users={users} />
</div>
)
}// app/users/user-form.tsx
'use client'
import { createUser } from '@/app/actions/users'
export function UserForm() {
return (
<form action={createUser}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Add User</button>
</form>
)
}API Routes
// app/api/users/route.ts
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
export async function GET() {
try {
const result = await db.select().from(users)
return NextResponse.json(result)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, email } = body
const result = await db
.insert(users)
.values({ name, email })
.returning()
return NextResponse.json(result[0], { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}// app/api/users/[id]/route.ts
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db
.select()
.from(users)
.where(eq(users.id, parseInt(params.id)))
.limit(1)
if (!user.length) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json(user[0])
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const result = await db
.update(users)
.set(body)
.where(eq(users.id, parseInt(params.id)))
.returning()
return NextResponse.json(result[0])
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.delete(users).where(eq(users.id, parseInt(params.id)))
return new NextResponse(null, { status: 204 })
}Edge Functions (Vercel, Cloudflare)
Vercel Edge Functions
// app/api/edge/route.ts
import { neon } from '@neondatabase/serverless'
export const runtime = 'edge'
export async function GET() {
const sql = neon(process.env.DATABASE_URL!)
const users = await sql`SELECT * FROM users LIMIT 10`
return Response.json(users)
}Cloudflare Workers
// src/index.ts
import { neon } from '@neondatabase/serverless'
export interface Env {
DATABASE_URL: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const sql = neon(env.DATABASE_URL)
const url = new URL(request.url)
if (url.pathname === '/users') {
const users = await sql`SELECT * FROM users`
return Response.json(users)
}
return new Response('Not Found', { status: 404 })
},
}# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[vars]
# Set in Cloudflare Dashboard as a secret
# DATABASE_URL = "..."pgvector - Embeddings and AI
Enabling pgvector
-- In Neon Console or via SQL
CREATE EXTENSION IF NOT EXISTS vector;Schema with vectors
// db/schema.ts
import { pgTable, serial, text, vector } from 'drizzle-orm/pg-core'
export const documents = pgTable('documents', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }), // OpenAI ada-002
})Semantic Search
import { db } from '@/db'
import { documents } from '@/db/schema'
import { sql } from 'drizzle-orm'
import OpenAI from 'openai'
const openai = new OpenAI()
async function getEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input: text,
})
return response.data[0].embedding
}
export async function semanticSearch(query: string, limit = 5) {
const queryEmbedding = await getEmbedding(query)
// Drizzle with raw SQL for vector operations
const results = await db.execute(sql`
SELECT
id,
content,
1 - (embedding <=> ${queryEmbedding}::vector) as similarity
FROM documents
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${limit}
`)
return results.rows
}
// Adding a document with an embedding
export async function addDocument(content: string) {
const embedding = await getEmbedding(content)
await db.insert(documents).values({
content,
embedding,
})
}RAG Pipeline
// lib/rag.ts
import { semanticSearch, addDocument } from './vector-search'
import OpenAI from 'openai'
const openai = new OpenAI()
export async function askQuestion(question: string): Promise<string> {
// 1. Find relevant documents
const relevantDocs = await semanticSearch(question, 3)
// 2. Build context
const context = relevantDocs
.map(doc => doc.content)
.join('\n\n')
// 3. Generate answer with LLM
const response = await openai.chat.completions.create({
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'system',
content: `Answer questions based on the provided context.
If the answer is not in the context, say so.
Context:
${context}`,
},
{
role: 'user',
content: question,
},
],
})
return response.choices[0].message.content || ''
}Point-in-Time Recovery
Restoring to a point in time
# Via CLI
neonctl branches create \
--name recovery-branch \
--parent main \
--point-in-time "2024-01-15T14:30:00Z"Via API
const response = await fetch('https://console.neon.tech/api/v2/projects/{project_id}/branches', {
method: 'POST',
headers: {
'Authorization': `Bearer ${NEON_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
branch: {
name: 'recovery-branch',
parent_id: 'main',
},
endpoints: [
{
type: 'read_write',
},
],
// Restore to a specific moment
parent_timestamp: '2024-01-15T14:30:00Z',
}),
})Connection Pooling
Neon offers a built-in connection pooler (PgBouncer):
# Pooled connection (for serverless)
postgresql://user:pass@ep-xxx.pooler.region.aws.neon.tech/dbname?sslmode=require
# Direct connection (for migrations)
postgresql://user:pass@ep-xxx.region.aws.neon.tech/dbname?sslmode=requireApplication configuration
// For serverless (most use cases)
const pooledUrl = process.env.DATABASE_URL // use pooled
// For migrations (you need a direct connection)
const directUrl = process.env.DIRECT_DATABASE_URL# .env
DATABASE_URL="postgresql://user:pass@ep-xxx.pooler.us-east-2.aws.neon.tech/neondb?sslmode=require"
DIRECT_DATABASE_URL="postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require"Monitoring and metrics
Neon Console
- Compute usage - Active compute units
- Storage - Disk usage per branch
- Connections - Active connections
- Query performance - Query analysis
Custom monitoring
import { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL!)
// Connection statistics
const connectionStats = await sql`
SELECT
count(*) as total_connections,
count(*) FILTER (WHERE state = 'active') as active,
count(*) FILTER (WHERE state = 'idle') as idle
FROM pg_stat_activity
`
// Table sizes
const tableSizes = await sql`
SELECT
relname as table_name,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_size_pretty(pg_relation_size(relid)) as data_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
`
// Slow queries
const slowQueries = await sql`
SELECT
query,
calls,
mean_exec_time,
total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10
`Security
Roles and permissions
-- Create a role for the application
CREATE ROLE app_user WITH LOGIN PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE neondb TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- Read-only role for analytics
CREATE ROLE analytics_user WITH LOGIN PASSWORD 'analytics_pass';
GRANT CONNECT ON DATABASE neondb TO analytics_user;
GRANT USAGE ON SCHEMA public TO analytics_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_user;Row Level Security
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy: user can only see their own posts
CREATE POLICY user_posts ON posts
FOR ALL
USING (author_id = current_setting('app.user_id')::int);
-- Policy: public posts visible to everyone
CREATE POLICY public_posts ON posts
FOR SELECT
USING (is_public = true);// Setting user context
const sql = neon(process.env.DATABASE_URL!)
async function getUserPosts(userId: number) {
await sql`SELECT set_config('app.user_id', ${userId.toString()}, false)`
return sql`SELECT * FROM posts`
}Pricing
| Plan | Price | Storage | Compute | Projects | Branches per project |
|---|---|---|---|---|---|
| Free | 0 USD | 0.5 GB per project | 100 CU-hours per project | 100 | 10 |
| Launch | usage based | 0.35 USD per GB-month | 0.106 USD per CU-hour | 100 | 10 |
| Scale | usage based | 0.35 USD per GB-month | 0.222 USD per CU-hour | 1,000 | 25 |
The billing model changed in 2025 and changed favourably, so older tables listing flat fees such as nineteen dollars a month are out of date. Paid plans now carry no monthly minimum, so zero usage costs zero.
Three things are worth knowing when estimating. A compute unit corresponds roughly to one CPU with four gigabytes of memory, and billing follows the time it runs rather than the time the database exists. Egress is included up to five hundred gigabytes per project on paid plans, ten cents per gigabyte above that. The free plan is not a trial but a permanent tier requiring no payment card.
The difference between paid plans comes down mainly to the compute rate and to quantity limits. The higher Scale rate comes with more projects, more branches, and the option to raise limits on request, so it is not a straight surcharge for the same thing.
Usage based billing
- Compute: 0.106 USD per compute unit hour on Launch, 0.222 USD on Scale
- Storage: 0.35 USD per gigabyte month
- Egress: 500 GB included per project, then 0.10 USD per gigabyte
- Suspended instance: 0 USD, since compute scales to zero
- Extra branches: 1.50 USD per branch month, prorated hourly
One compute unit hour is instance size multiplied by running time, so an instance sized 0.25 running for four hours consumes one unit. The free plan gives 100 such units per project and half a gigabyte of storage.
Best practices
1. Use branching for development
# Each developer has their own branch
neonctl branches create --name dev-john
neonctl branches create --name dev-anna
# Each PR has its own branch
neonctl branches create --name pr-${PR_NUMBER}2. Connection pooling for serverless
// Always use a pooled connection in serverless
const sql = neon(process.env.DATABASE_URL!) // pooled URL3. Optimize cold starts
// Prewarming via a scheduled function
// Run every 4 minutes to keep the compute active
export async function warmup() {
const sql = neon(process.env.DATABASE_URL!)
await sql`SELECT 1`
}4. Indexes for performance
-- Indexes on frequently used columns
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CREATE INDEX CONCURRENTLY idx_posts_author ON posts(author_id);
-- Partial index for active records
CREATE INDEX CONCURRENTLY idx_active_users ON users(id) WHERE is_active = true;
-- Index for full-text search
CREATE INDEX CONCURRENTLY idx_posts_content_gin ON posts USING gin(to_tsvector('english', content));5. Monitor and optimize
-- Find missing indexes
SELECT
relname,
seq_scan,
idx_scan,
seq_scan - idx_scan as diff
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
ORDER BY diff DESC;
-- Query analysis
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';Common problems and solutions
Cold start timeout
// Problem: Query timeout during cold start
// Solution: Increase timeout and add retry
const sql = neon(process.env.DATABASE_URL!, {
fetchOptions: {
timeout: 10000, // 10 seconds
},
})
async function queryWithRetry(query: string, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await sql(query)
} catch (error) {
if (i === retries - 1) throw error
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
}Connection limit exceeded
// Problem: Too many connections
// Solution: Use pooled connection string
// Instead of: ep-xxx.us-east-2.aws.neon.tech
// Use: ep-xxx.pooler.us-east-2.aws.neon.techBranch sync issues
# Problem: Branch outdated relative to main
# Solution: Create a new branch
neonctl branches delete dev-old
neonctl branches create --name dev-new --parent mainFAQ - frequently asked questions
Is Neon production-ready?
Yes. Neon is used by thousands of companies in production. It offers a 99.95% SLA on Business plans.
How long does a cold start take?
Typically 300-500ms. You can avoid it through scheduled warming or paid "always on" compute.
Can I use all PostgreSQL extensions?
Most popular extensions are available: pgvector, PostGIS, pg_trgm, hstore, uuid-ossp. The full list is available in the Neon documentation.
How to migrate from RDS/Supabase?
- Export data:
pg_dump - Create a Neon project
- Import:
psql < dump.sql - Update the connection string in your application
Is the data encrypted?
Yes, connections run over TLS and data at rest is encrypted. Details of certification and compliance are worth checking directly with the vendor, since scope varies by plan and region.
Is Neon still independent?
No. It has belonged to Databricks since May 2025. The product continues and has received price cuts since, but for multi year planning it is worth remembering that its direction is now set by an owner from a different part of the market.
Connection pooling, the most common stumble
This problem affects any PostgreSQL running under on demand functions, and here it returns with extra force, because the database sleeps by default.
The mechanism is simple. Every function instance opens its own connection, and PostgreSQL enforces a hard cap on concurrent connections. At a hundred parallel invocations that cap is reached faster than you notice in logs, and the symptom is a connection exhaustion error appearing only under heavier traffic.
The answer is a pooled connection address. Neon exposes two connection strings, a direct one and one routed through a pooler, recognisable by a marker in the host name. In a stateless environment, always take the second.
There is a caveat easy to forget, though. A pooler in transaction mode does not support everything: prepared statements, temporary tables, and session level advisory locks behave differently or not at all. Run schema migrations and long administrative jobs over a direct connection, and send ordinary application traffic through the pool.
A separate route is a driver communicating over HTTP, sensible in edge runtimes where TCP sockets cannot be opened at all. You then get individual queries with no persistent session, which rules out multi step transactions but removes the connection problem entirely.
Common mistakes
The first is holding one connection in a module level variable. In an environment that sleeps and wakes instances, that connection is often dead on the next invocation, and the error looks random because it appears only after a longer gap.
The second is forgetting to delete branches. They are created with one command and easily left behind after a pull request closes. The pointers themselves cost little, but the differences accumulated inside them do not, and six months later nobody knows which of fifty branches serve any purpose. Deleting them automatically when a pull request closes is worth setting up.
The third is assuming scale to zero works by itself. As noted above, one regular health check is enough to keep the database permanently awake. Check what queries the database outside your application before hunting for savings elsewhere.
The fourth is treating a branch as a performance environment. Branches share storage with their parent, so query timings measured on a branch do not match what production shows under load.
The fifth is overlooking egress cost on large datasets. Pulling millions of rows out for processing looks harmless until you cross the allowance. Aggregating inside the database is cheaper in every respect.
Neon against the alternatives
| Feature | Neon | Supabase | PlanetScale | Turso |
|---|---|---|---|---|
| Engine | PostgreSQL | PostgreSQL | MySQL | SQLite compatible |
| Database branching | yes, a strength | limited | yes | yes |
| Scale to zero | yes | partial | no | not applicable |
| Application layer | none, database only | auth, storage, functions | none | none |
| Billing | usage based | flat plans and usage | usage based | per row |
| Owner | Databricks | independent | independent | independent |
The choice depends on what you are after. If you need a database alone and value branching plus no cost at zero traffic, Neon is the strongest here. If you want auth, file storage, and functions alongside the database, pick Supabase and spare yourself the assembly. If the workload is read dominated and latency matters, look at options offering a replica inside the application.
Current rates are on the Neon pricing page, and the engine source lives in the project repository.