Web Engineering ·

Choosing a Database for Your SaaS Startup: Postgres, PlanetScale, Neon, and Turso Compared

A practical comparison of database options for modern SaaS startups. Covers Postgres (self-managed and managed), PlanetScale, Neon, Turso, and Supabase across connection models, serverless compatibility, scaling characteristics, pricing at startup scale, and developer experience, with TypeScript examples using Drizzle ORM.

Choosing a Database for Your SaaS Startup: Postgres, PlanetScale, Neon, and Turso Compared

The database decision at the start of a SaaS project shapes almost everything that comes later: deployment topology, ORM choice, migration workflows, cost structure at scale, and how painful the 3am incident will be when you exceed connection limits under traffic.

Most comparisons stop at “just use Postgres.” That is often correct advice, but it glosses over real tradeoffs that matter at startup scale. Serverless deployments running hundreds of Lambda functions or Cloudflare Workers cannot hold persistent connections. Teams on a tight budget need to model pricing before they commit. Engineers targeting edge deployments need to think about latency before they pick a global sync strategy.

This guide walks through the realistic options in 2026 for a TypeScript SaaS on a modern stack. It covers connection models, serverless behavior, pricing at startup volumes, developer experience, and migration paths, with Drizzle ORM examples throughout because that is the ORM most commonly paired with these options today.

The Problem Space

A typical early-stage SaaS has a few characteristics that do not fit the textbook database setup:

  • Serverless or edge compute (Vercel, Cloudflare Workers, AWS Lambda), where persistent connections are a liability
  • Unpredictable traffic with long idle periods, making always-on reserved capacity wasteful
  • A team of one to five engineers who cannot afford to operate infrastructure
  • Need to move fast on schema without coordinated deploys
  • Cost sensitivity: $0-50/month until you have paying customers, then linear growth

Self-managed Postgres on a VPS is the most capable option technically, but it puts operational burden on a small team. Managed options trade control for convenience. Serverless databases trade a connection model for scale-to-zero. Edge databases trade consistency guarantees for latency.

None of these are the right answer in isolation. The right answer depends on your deployment target, team size, and traffic shape.

Postgres: Self-Managed and Managed

Postgres is the default for a reason. It is stable, capable, and every tool in the ecosystem supports it. If you can operate it, nothing beats the combination of features, community, and SQL compliance.

Self-managed (Fly.io, Railway, Render)

For a startup, self-managed does not mean bare metal. Services like Fly.io Postgres, Railway, and Render managed Postgres give you a real Postgres instance with automated backups, without managing the OS.

Connection limits are the main operational concern. Postgres has a fixed max_connections ceiling (typically 25-100 on small instances), and each idle connection holds memory. In a serverless deployment, every function invocation tries to open a connection. At 100 concurrent invocations, you exhaust the pool immediately.

The solution is PgBouncer in transaction mode, or using a hosted proxy like Supabase Pooler or Neon’s pooler. This is a solvable problem, but it requires deliberate setup.

Supabase

Supabase is managed Postgres with a layered API surface: REST (PostgREST), realtime (pg_listen-based), auth, storage, and edge functions. The database itself is standard Postgres, which means every Postgres tool works against it.

The opinionated auth layer and REST API are useful for prototyping but often become obstacles at production scale when you need custom query patterns. The good news: you can ignore those layers entirely and use Supabase purely as managed Postgres with a connection pooler.

Supabase’s connection pooler (Supavisor) sits in front of the database and handles the serverless connection problem. Configure it to use transaction mode when connecting from Lambda or Workers functions.

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    // Use the pooler URL for serverless, direct URL for migrations
    url: process.env.DATABASE_URL!,
  },
});
// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

// For serverless: use pooler URL with max: 1
// For long-running servers: use direct URL with a larger pool
const connectionString = process.env.DATABASE_URL!;

const client = postgres(connectionString, {
  max: process.env.NODE_ENV === "production" ? 1 : 10,
  idle_timeout: 20,
  connect_timeout: 10,
});

export const db = drizzle(client, { schema });

The max: 1 setting for serverless is intentional. Each function instance holds exactly one connection, and the pooler aggregates them. This prevents connection exhaustion without giving up standard SQL.

Neon: Serverless Postgres

Neon is Postgres with a separation of compute and storage. The compute layer can scale to zero (no running instances when idle) and spin up in roughly 500ms. Storage is replicated and billed per gigabyte. Branches are a first-class concept: you can create a database branch for every pull request the same way you branch Git.

The native Neon driver uses WebSockets and HTTP instead of the traditional TCP connection, which makes it work in environments that block persistent TCP (Cloudflare Workers is the main example).

// src/db/index.ts (Neon with Drizzle)
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

For Cloudflare Workers specifically, the HTTP driver avoids the WebSocket connection limit Workers enforces per isolate:

// For Cloudflare Workers
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const sql = neon(env.DATABASE_URL);
    const db = drizzle(sql);

    const users = await db.select().from(schema.users).limit(10);
    return Response.json(users);
  },
};

The scale-to-zero property makes Neon genuinely free for development workloads and very cheap for early-stage apps with intermittent traffic. The cold start latency (roughly 400-700ms for the first query after a period of inactivity) matters for user-facing synchronous queries but is invisible for background jobs.

Branch-per-PR is the developer experience win that justifies Neon for teams running preview deployments. Each PR gets a database branch with a copy of the schema, seeded data, and isolated state. No more fighting over a shared staging database.

# Neon CLI: create a branch for a PR
neon branches create --name feature/new-billing --parent main
# Returns a connection string you can inject into the preview deployment

PlanetScale: Vitess-Backed MySQL

PlanetScale runs Vitess, the MySQL sharding layer originally built at YouTube. The selling point at startup scale is not sharding, which you do not need yet. It is the branching and deploy request workflow for schema changes.

PlanetScale enforces no foreign key constraints at the database level (a Vitess limitation). This is a meaningful tradeoff: application-level integrity checks, no cascade deletes. For some teams this is dealbreaker; for others it is acceptable given the scale-out guarantees.

The deploy request flow works like a pull request for schema changes. You apply a schema migration on a development branch, open a deploy request, and PlanetScale runs the migration using online DDL (gh-ost under the hood) that is non-blocking even on large tables. This is the strongest zero-downtime migration story of any option in this comparison.

// PlanetScale with Drizzle (mysql2 driver)
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import * as schema from "./schema";

// PlanetScale requires SSL
const connection = await mysql.createConnection({
  host: process.env.DATABASE_HOST,
  user: process.env.DATABASE_USERNAME,
  password: process.env.DATABASE_PASSWORD,
  database: process.env.DATABASE_NAME,
  ssl: { rejectUnauthorized: true },
});

export const db = drizzle(connection, { schema, mode: "planetscale" });

Note the mode: "planetscale" flag in Drizzle. This disables foreign key checks in the Drizzle schema layer to match PlanetScale’s behavior. You model relations but Drizzle does not generate FK constraints.

PlanetScale’s pricing model changed significantly in 2024. The free tier was removed. At startup scale you are looking at $39/month minimum for the Scaler plan. That is a real cost for a pre-revenue product, and it makes Neon or Supabase more attractive if schema branching is not your primary concern.

Turso: libSQL at the Edge

Turso uses libSQL, a fork of SQLite with replication and multi-tenancy extensions. The architecture is different from anything else in this list: your data lives close to your users in edge locations (Fly.io regions), and each read is served from the nearest replica.

This gives genuine low-latency reads globally without the complexity of setting up a read replica topology yourself. The write path still goes through a primary, so write latency is not improved and write throughput is single-threaded like SQLite.

The multi-tenant model Turso is built for (thousands of isolated databases, one per tenant) is interesting for certain SaaS patterns where tenant isolation at the database level is desirable:

// Turso with Drizzle
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";

// Each tenant can have their own database
function getTenantDb(tenantId: string) {
  const client = createClient({
    url: `libsql://${tenantId}-myapp.turso.io`,
    authToken: process.env.TURSO_AUTH_TOKEN,
  });
  return drizzle(client, { schema });
}

// Or a single shared database
const sharedClient = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
});
export const db = drizzle(sharedClient, { schema });

The SQLite constraint set applies: no full outer joins, limited ALTER TABLE support, no stored procedures. Running migrations on Turso requires the same libSQL client, and schema changes across thousands of tenant databases require custom orchestration.

Turso’s free tier is generous (500 databases, 9GB total storage), which makes it an easy experiment. The production pricing scales per database instance plus data transfer, which can compound quickly if you spin up a database per tenant.

Pricing Comparison at Startup Scale

Approximate monthly costs for a small SaaS (10GB data, 50M row reads/month, 500K row writes/month):

OptionFree TierEst. $50/mo ScaleNotes
Supabase500MB, 2 projects$25 (Pro plan)Most predictable pricing
Neon0.5GB, 1 project$19 (Launch plan)Storage + compute units
PlanetScaleNone$39 (Scaler)Minimum paid tier
Turso9GB, 500 DBs$29 (Starter)+ data transfer costs
Railway Postgres$5 credit~$20Compute-based billing
Fly.io PostgresShared CPU free~$15-30You manage it

These numbers shift with usage patterns. Neon’s compute billing rewards apps with high idle time (weekends, nights). Supabase Pro is flat until you hit bandwidth or CPU limits. PlanetScale bills by row reads, which can spike unexpectedly on analytics queries.

Decision Framework

The right database choice depends on three variables: deployment target, team operational capacity, and traffic pattern.

Deploy to Cloudflare Workers or edge runtime?

Use Neon with the HTTP driver or Turso. Both support HTTP-based queries; standard Postgres drivers require TCP, which Workers does not support. Neon fits relational data models. Turso fits global low-latency reads if you can live with SQLite semantics.

Deploy to Lambda, Vercel Functions, or similar serverless?

Neon, Supabase with Supavisor pooling, or PlanetScale all work. Set max: 1 in your Drizzle connection and use the pooler URL. Connection management is the only serverless-specific concern, and all three handle it.

Small team (1-3 engineers), no dedicated ops?

Supabase or Neon. Both have zero-ops paths to a production database. Supabase adds auth, storage, and realtime. Neon adds branch-per-PR for preview deployments.

Frequent schema changes on a growing table?

PlanetScale’s deploy request workflow is the most production-safe. Online DDL through gh-ost means you can alter a 100M row table without a maintenance window. Every other option requires manual orchestration (shadow tables, pt-online-schema-change, or careful ADD COLUMN ... DEFAULT NULL discipline).

Multi-tenant SaaS with one database per tenant?

Turso was designed for this pattern. Creating 10,000 SQLite databases is far cheaper than running 10,000 Postgres instances. If your tenant data model is simple and write throughput per tenant is low, this is worth exploring.

Pre-revenue, need $0/month?

Neon (0.5GB, scale-to-zero) and Supabase (500MB, two projects) are both viable for pre-launch. Both impose limits that require upgrading before production scale. Turso’s free tier is the most generous at 9GB and 500 databases.

Production Considerations

Connection pooling is not optional in serverless. Every function invocation using a standard Postgres driver opens a connection. At 200 concurrent requests, you exhaust a small instance’s ceiling. Use the HTTP driver (Neon, PlanetScale, and Turso all have one) or configure transaction-mode pooling.

Read replicas add complexity before they add value. A single primary is the right topology until read-heavy workloads visibly degrade write performance. Defer replicas until the metrics signal you need them.

Migrations need a strategy regardless of database. Drizzle’s migration tooling generates SQL migrations from schema diffs. Run drizzle-kit generate in CI and apply with drizzle-kit migrate. For Neon, use the direct URL (not the pooler) for migrations, because the pooler in transaction mode does not support SET statements that migration libraries use:

// drizzle.config.ts: separate URLs for app vs migrations
export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    // DIRECT_URL for migrations (bypasses pooler)
    url: process.env.DIRECT_DATABASE_URL!,
  },
});
// In your application code, use the pooler URL
const db = drizzle(neon(process.env.DATABASE_URL!));
// DATABASE_URL points to the pooler or HTTP endpoint
// DIRECT_DATABASE_URL points to the direct Postgres connection

Vendor lock-in is lower than it appears. Neon, Supabase, and Railway all run standard Postgres: migrating between them is pg_dump and pg_restore. PlanetScale uses MySQL, so moving to Postgres requires schema translation. Turso uses libSQL, which is SQLite-compatible but not Postgres-compatible. Factor reversibility into the decision.

Tradeoffs at a Glance

DimensionSupabaseNeonPlanetScaleTursoSelf-Managed Postgres
Serverless-nativePooler requiredHTTP driverHTTP driverHTTP driverPooler required
Cold startsNone400-700msNone~200msNone
Edge runtime supportWith poolerYes (HTTP)Yes (HTTP)YesNo (TCP only)
Schema branchingNoYesYes (deploy requests)NoNo
FK constraintsYesYesNo (Vitess)No (SQLite)Yes
Free tier500MB0.5GBNone9GBN/A
MySQL vs PostgresPostgresPostgresMySQLSQLitePostgres
Migration storyDrizzle/FlywayDrizzle/FlywayDeploy requestsDrizzleFull control

Where to Start

For most TypeScript SaaS startups in 2026, the default recommendation is Neon if you are deploying to serverless or edge, Supabase if you want a bundled auth and storage layer alongside your database, and self-managed Postgres on Fly.io or Railway if you prefer direct control with minimal overhead.

PlanetScale earns consideration specifically when zero-downtime schema migrations on large tables are a first-class requirement, and you can accept the absence of foreign keys and the minimum $39/month commitment.

Turso earns consideration specifically when per-tenant database isolation is your data model or when edge-latency reads are the primary performance concern.

The database is not the part of your startup to optimize prematurely, but it is also not a decision you want to reverse at scale. Pick the option whose operational model matches your team’s actual capacity and whose constraints you can live with for the next two years. Then move on and build the product.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.