Skip to main content

Problem

Schema changes during development can corrupt or pollute your main database. You need isolated environments to test migrations without affecting production data.

Solution

Neon’s branching feature creates instant copy-on-write database branches from your main database. Each branch is isolated, cheap (only stores diffs), and can be created/deleted in seconds.

Implementation

Setup

Install and authenticate the Neon CLI:
# Install Neon CLI
npm install -g neonctl

# Authenticate
neonctl auth
Create a .neon file in your project root with your project ID (find it in your Neon dashboard):
.neon
{
  "projectId": "<your-project-id>"
}
Alternatively, you can set the context via CLI:
neonctl set-context --project-id <your-project-id>

Helper Scripts

Add these scripts to your package.json:
package.json
{
  "scripts": {
    "db:branch:start": "node scripts/neon-branch.js start",
    "db:branch:stop": "node scripts/neon-branch.js stop",
    "db:branch:use": "node scripts/neon-branch.js use",
    "db:branch:create": "node scripts/neon-branch.js create",
    "db:branch:delete": "node scripts/neon-branch.js delete",
    "db:branch:list": "neonctl branches list"
  }
}

Branch Script

scripts/neon-branch.js
#!/usr/bin/env node
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");

const DEFAULT_BRANCH = "dev-local";
const ENV_FILE = ".env.local";

function exec(cmd) {
  return execSync(cmd, { encoding: "utf-8" }).trim();
}

function getBranchConnectionString(branchName) {
  const output = exec(`neonctl branches get ${branchName} --output json`);
  const branch = JSON.parse(output);

  // Get the connection string for the branch
  const connOutput = exec(
    `neonctl connection-string ${branchName} --output json`
  );
  const conn = JSON.parse(connOutput);
  return conn.connection_string;
}

function updateEnvFile(connectionString) {
  const envPath = path.join(process.cwd(), ENV_FILE);
  let content = "";

  if (fs.existsSync(envPath)) {
    content = fs.readFileSync(envPath, "utf-8");
  }

  // Update or add DATABASE_URL
  if (content.includes("DATABASE_URL=")) {
    content = content.replace(
      /DATABASE_URL=.*/,
      `DATABASE_URL=${connectionString}`
    );
  } else {
    content += `\nDATABASE_URL=${connectionString}\n`;
  }

  fs.writeFileSync(envPath, content);
  console.log(`Updated ${ENV_FILE} with new DATABASE_URL`);
}

const [, , command, branchName = DEFAULT_BRANCH] = process.argv;

switch (command) {
  case "create":
    exec(`neonctl branches create --name ${branchName}`);
    console.log(`Created branch: ${branchName}`);
    break;

  case "delete":
    exec(`neonctl branches delete ${branchName}`);
    console.log(`Deleted branch: ${branchName}`);
    break;

  case "use":
    const connString = getBranchConnectionString(branchName);
    updateEnvFile(connString);
    console.log(`Switched to branch: ${branchName}`);
    break;

  case "start":
    // Create and switch to branch
    try {
      exec(`neonctl branches create --name ${branchName}`);
      console.log(`Created branch: ${branchName}`);
    } catch {
      console.log(`Branch ${branchName} already exists`);
    }
    const startConn = getBranchConnectionString(branchName);
    updateEnvFile(startConn);
    console.log(`Switched to branch: ${branchName}`);
    break;

  case "stop":
    // Delete branch and switch to main
    const mainConn = getBranchConnectionString("main");
    updateEnvFile(mainConn);
    try {
      exec(`neonctl branches delete ${branchName}`);
      console.log(`Deleted branch: ${branchName}`);
    } catch {
      console.log(`Branch ${branchName} not found`);
    }
    console.log("Switched to main branch");
    break;

  default:
    console.log(`
Usage: node neon-branch.js <command> [branch-name]

Commands:
  start [name]   Create branch and switch to it (default: dev-local)
  stop [name]    Delete branch and switch to main
  use [name]     Switch to existing branch
  create [name]  Create branch only
  delete [name]  Delete branch only
    `);
}

Usage

Development Workflow

# Start working on schema changes
npm run db:branch:start

# Your app now uses the dev branch
npm run dev

# Make schema changes
npm run db:generate   # Generate migration
npm run db:migrate    # Apply migration

# When done, merge to main and clean up
npm run db:branch:stop

Commands Reference

CommandDescription
npm run db:branch:start [name]Create and switch to branch
npm run db:branch:stop [name]Delete branch, switch to main
npm run db:branch:use [name]Switch to branch
npm run db:branch:create [name]Create branch only
npm run db:branch:delete [name]Delete branch only
npm run db:branch:listList all branches
Default branch name is dev-local if not specified.

Considerations

Benefits:
  • Instant branch creation (copy-on-write, no data duplication)
  • Test destructive migrations safely
  • Multiple developers can have separate branches
  • Branches inherit data from parent at creation time
Trade-offs:
  • Requires Neon PostgreSQL (not compatible with other providers)
  • Branch data becomes stale relative to main over time
  • Free tier has limited branch count
Alternatives:
  • Docker-based local PostgreSQL for full isolation
  • Schema-only branches (no data) for simpler cases
  • Supabase branching for Supabase users