We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide25 min read

Neon, serverless PostgreSQL with database branching

Neon is usage billed PostgreSQL with git style database branching. The Databricks acquisition, pricing, scale to zero, and common mistakes.

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

  1. Database branching - Create database copies in seconds, without physically copying data
  2. Scale to zero - No charges when the application is not in use
  3. Instant wake - Cold start under 500ms
  4. Pay per use - Only pay for actual compute hours
  5. Full PostgreSQL compatibility - Support for pgvector, PostGIS, and other extensions
  6. Secure connections - All connections encrypted with TLS

Neon vs traditional databases

FeatureNeonRDS/Cloud SQLSupabase
Scale to zeroYesNoNo
BranchingNativeSnapshotsNo
Cold start~500msN/AN/A
Pricing modelPer computePer hourFlat + usage
Free tier storage512MBNone500MB
Connection poolingBuilt-inExtra costBuilt-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

Code
TEXT
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        Neon Cloud                            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚   Compute    β”‚    β”‚   Compute    β”‚    β”‚   Compute    β”‚   β”‚
β”‚  β”‚   (main)     β”‚    β”‚   (dev)      β”‚    β”‚   (pr-123)   β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚         β”‚                    β”‚                   β”‚           β”‚
β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚
β”‚                              β”‚                               β”‚
β”‚                      β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”                       β”‚
β”‚                      β”‚   Pageserver   β”‚                       β”‚
β”‚                      β”‚   (Storage)    β”‚                       β”‚
β”‚                      β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                       β”‚
β”‚                              β”‚                               β”‚
β”‚                      β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”                       β”‚
β”‚                      β”‚    Safekeepers β”‚                       β”‚
β”‚                      β”‚   (WAL/Durability)                    β”‚
β”‚                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                       β”‚
β”‚                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

How does branching work?

Code
TEXT
main branch
    β”‚
    β”œβ”€β”€ commit 1
    β”‚
    β”œβ”€β”€ commit 2
    β”‚       β”‚
    β”‚       └──► dev branch (copy-on-write)
    β”‚                 β”‚
    β”‚                 β”œβ”€β”€ dev commit 1
    β”‚                 β”‚
    β”‚                 └── dev commit 2
    β”‚
    β”œβ”€β”€ commit 3
    β”‚       β”‚
    β”‚       └──► pr-123 branch
    β”‚
    └── commit 4

A 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

  1. Sign up at console.neon.tech
  2. Create a new project
  3. Choose a region (available: US East, US West, Europe, Asia)
  4. Copy the connection string

Connection String

Code
TEXT
postgresql://[user]:[password]@[host]/[database]?sslmode=require

Example:

Code
TEXT
postgresql://neonuser:password123@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require

CLI Installation

Code
Bash
# npm
npm install -g neonctl

# Homebrew (macOS)
brew install neonctl

# Authorization
neonctl auth

Branching - Git for your database

Creating branches via CLI

Code
Bash
# 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-456

Branching in CI/CD

.github/workflows/preview.yml
YAML
# .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
YAML
# .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:

Code
Bash
npm install @neondatabase/serverless
Code
TypeScript
import { 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)

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit
TSdb/schema.ts
TypeScript
// 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'),
})
TSdb/index.ts
TypeScript
// 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))
TSdrizzle.config.ts
TypeScript
// 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
Code
Bash
# Generate migrations
npx drizzle-kit generate:pg

# Run migrations
npx drizzle-kit push:pg

# Studio (GUI)
npx drizzle-kit studio

Prisma

Code
Bash
npm install prisma @prisma/client
npx prisma init
prisma/schema.prisma
Prisma
// 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?
}
TSlib/prisma.ts
TypeScript
// 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...',
      },
    },
  },
})
Code
Bash
# Migrations
npx prisma migrate dev --name init
npx prisma migrate deploy

# Studio
npx prisma studio

Kysely

Code
Bash
npm install kysely @neondatabase/serverless
TSdb/types.ts
TypeScript
// 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>
TSdb/index.ts
TypeScript
// 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

TSapp/actions/users.ts
TypeScript
// 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')
}
TSapp/users/page.tsx
TypeScript
// 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>
  )
}
TSapp/users/user-form.tsx
TypeScript
// 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

TSapp/api/users/route.ts
TypeScript
// 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 }
    )
  }
}
TSapp/api/users/[id]/route.ts
TypeScript
// 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

TSapp/api/edge/route.ts
TypeScript
// 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

TSsrc/index.ts
TypeScript
// 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
TOML
# 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

Code
SQL
-- In Neon Console or via SQL
CREATE EXTENSION IF NOT EXISTS vector;

Schema with vectors

TSdb/schema.ts
TypeScript
// 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

Code
TypeScript
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

TSlib/rag.ts
TypeScript
// 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

Code
Bash
# Via CLI
neonctl branches create \
  --name recovery-branch \
  --parent main \
  --point-in-time "2024-01-15T14:30:00Z"

Via API

Code
TypeScript
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):

Code
TEXT
# 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=require

Application configuration

Code
TypeScript
// 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
Code
ENV
# .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

Code
TypeScript
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

Code
SQL
-- 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

Code
SQL
-- 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);
Code
TypeScript
// 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

PlanPriceStorageComputeProjectsBranches per project
Free0 USD0.5 GB per project100 CU-hours per project10010
Launchusage based0.35 USD per GB-month0.106 USD per CU-hour10010
Scaleusage based0.35 USD per GB-month0.222 USD per CU-hour1,00025

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

Code
Bash
# 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

Code
TypeScript
// Always use a pooled connection in serverless
const sql = neon(process.env.DATABASE_URL!) // pooled URL

3. Optimize cold starts

Code
TypeScript
// 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

Code
SQL
-- 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

Code
SQL
-- 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

Code
TypeScript
// 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

Code
TypeScript
// 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.tech

Branch sync issues

Code
Bash
# Problem: Branch outdated relative to main

# Solution: Create a new branch
neonctl branches delete dev-old
neonctl branches create --name dev-new --parent main

FAQ - 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?

  1. Export data: pg_dump
  2. Create a Neon project
  3. Import: psql < dump.sql
  4. 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

FeatureNeonSupabasePlanetScaleTurso
EnginePostgreSQLPostgreSQLMySQLSQLite compatible
Database branchingyes, a strengthlimitedyesyes
Scale to zeroyespartialnonot applicable
Application layernone, database onlyauth, storage, functionsnonenone
Billingusage basedflat plans and usageusage basedper row
OwnerDatabricksindependentindependentindependent

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.