Skip to main content

Guide

pg vs postgres.js vs @neondatabase/serverless 2026

Compare pg (node-postgres), postgres.js, and @neondatabase/serverless for connecting to PostgreSQL in Node.js. Performance, connection pooling, TypeScript.

·PkgPulse Team·
0

TL;DR

postgres.js is the modern default for PostgreSQL in Node.js — faster than pg, built-in connection pooling, TypeScript-native, and a cleaner tagged template literal API. pg (node-postgres) is the battle-tested legacy choice, still heavily used via Prisma, Knex, and other ORMs. @neondatabase/serverless is the specialized choice when running in serverless/edge environments (Vercel Edge, Cloudflare Workers) where you can't use TCP connections — it tunnels queries over HTTP or WebSocket.

Key Takeaways

  • pg: ~9M weekly downloads — the original, used by most ORMs under the hood (Prisma, Knex, Sequelize)
  • postgres: ~2M weekly downloads — faster, cleaner API, TypeScript-native, recommended for greenfield projects
  • @neondatabase/serverless: ~800K weekly downloads — HTTP/WebSocket tunnel to Postgres, required for edge runtimes
  • postgres.js is 2-3x faster than pg in benchmarks, with lower memory usage
  • Use pg if you need maximum ecosystem compatibility or are using an ORM that requires it
  • Serverless environments (edge functions) require @neondatabase/serverless or a similar HTTP driver

PackageWeekly DownloadsTypeScriptPoolingEdge Runtime
pg~9M✅ @types/pg✅ pg-pool❌ TCP only
postgres~2M✅ Native✅ Built-in❌ TCP only
@neondatabase/serverless~800K✅ Native✅ HTTP/WS

pg (node-postgres)

pg is the original PostgreSQL client for Node.js — battle-tested, widely used, and the driver underneath Prisma, Knex, Sequelize, and many other libraries.

Basic queries

import { Pool } from "pg"

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,          // Max connections in pool
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
})

// Simple query:
const { rows } = await pool.query(
  "SELECT * FROM packages WHERE name = $1",
  ["react"]
)

// Typed result:
interface Package {
  id: string
  name: string
  weekly_downloads: number
  version: string
}

const result = await pool.query<Package>(
  "SELECT id, name, weekly_downloads, version FROM packages WHERE name = $1",
  ["react"]
)
const pkg: Package = result.rows[0]

Transactions

const client = await pool.connect()

try {
  await client.query("BEGIN")

  await client.query(
    "INSERT INTO packages (name, weekly_downloads) VALUES ($1, $2)",
    ["new-package", 0]
  )

  await client.query(
    "UPDATE stats SET total_packages = total_packages + 1"
  )

  await client.query("COMMIT")
} catch (err) {
  await client.query("ROLLBACK")
  throw err
} finally {
  client.release()  // Return connection to pool
}

pg with connection pooling (PgBouncer / Supabase)

import { Pool } from "pg"

// For connection poolers (PgBouncer, Supabase Pooler):
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  // Disable prepared statements for PgBouncer transaction mode:
  // These are set per-client, not pool-wide in pg
})

// Avoid prepared statements when using external poolers:
const result = await pool.query({
  text: "SELECT * FROM packages WHERE name = $1",
  values: ["react"],
})

Why pg is still widely used

// pg is the underlying driver for:
// - Prisma (uses @prisma/driver-adapter-pg or internal pg)
// - Knex.js: knex({ client: "pg", connection: DATABASE_URL })
// - Sequelize: dialect: "postgres"
// - Drizzle ORM: drizzle(new Pool({ connectionString }))
// - TypeORM: type: "postgres"

// If your ORM choice is Prisma/Drizzle/Knex, you don't
// interact with pg directly — it's abstracted away

postgres.js

postgres (imported as postgres) is the modern PostgreSQL client with a cleaner API, built-in connection pooling, and native TypeScript types.

Basic queries

import postgres from "postgres"

const sql = postgres(process.env.DATABASE_URL!, {
  max: 20,             // Max pool connections
  idle_timeout: 30,    // Close idle connections after 30s
  connect_timeout: 10, // Connection timeout in seconds
  transform: {
    // Map snake_case columns to camelCase automatically:
    column: postgres.camel,
  },
})

// Tagged template literal syntax (safe from SQL injection):
const packages = await sql<Package[]>`
  SELECT id, name, weekly_downloads, version
  FROM packages
  WHERE name = ${packageName}
  LIMIT 10
`

// Multiple parameters:
const results = await sql<Package[]>`
  SELECT * FROM packages
  WHERE category = ${category}
    AND weekly_downloads > ${minDownloads}
  ORDER BY weekly_downloads DESC
`

Transactions

// Transaction with automatic rollback on error:
await sql.begin(async (tx) => {
  const [pkg] = await tx`
    INSERT INTO packages (name, weekly_downloads)
    VALUES (${name}, ${downloads})
    RETURNING id
  `

  await tx`
    INSERT INTO package_history (package_id, event, created_at)
    VALUES (${pkg.id}, 'created', NOW())
  `
})
// Automatically commits or rolls back — no manual BEGIN/COMMIT/ROLLBACK

Typed queries (postgres.js TypeScript)

// postgres.js infers return types from generics:
interface Package {
  id: string
  name: string
  weeklyDownloads: number  // camelCase with transform.column
}

const [pkg] = await sql<Package[]>`
  SELECT id, name, weekly_downloads
  FROM packages WHERE name = ${name}
`
// pkg is typed as Package

// Dynamic queries (avoid SQL injection with sql() helper):
const conditions = { name: "react", category: "ui" }
const where = Object.entries(conditions).map(([k, v]) => sql`${sql(k)} = ${v}`)

const results = await sql<Package[]>`
  SELECT * FROM packages
  WHERE ${sql.and(...where)}
`

Array and JSON operations

// Bulk insert with postgres.js (much cleaner than pg):
const packages = [
  { name: "react", downloads: 25000000 },
  { name: "vue", downloads: 8000000 },
]

await sql`
  INSERT INTO packages ${sql(packages, "name", "downloads")}
`
// Generates: INSERT INTO packages (name, downloads) VALUES ($1, $2), ($3, $4)

// JSONB columns:
const stats = { score: 92, lastChecked: new Date().toISOString() }
await sql`
  UPDATE packages SET metadata = ${sql.json(stats)}
  WHERE name = ${"react"}
`

// Array parameters:
const names = ["react", "vue", "angular"]
const pkgs = await sql<Package[]>`
  SELECT * FROM packages WHERE name = ANY(${sql.array(names)})
`

postgres.js for serverless (with TCP)

// postgres.js supports serverless with connection reuse between requests:
import postgres from "postgres"

// Singleton pattern (reuse between Lambda/serverless invocations):
let sql: postgres.Sql | null = null

function getDb() {
  if (!sql) {
    sql = postgres(process.env.DATABASE_URL!, {
      max: 1,        // Single connection for serverless
      idle_timeout: 20,  // Close quickly to avoid EMFILE
      fetch_types: false,  // Faster startup
    })
  }
  return sql
}

@neondatabase/serverless

@neondatabase/serverless allows PostgreSQL connections over HTTP or WebSocket — required for edge runtimes (Vercel Edge Functions, Cloudflare Workers) that can't open TCP connections.

Edge runtime setup (Vercel Edge / Cloudflare Workers)

// app/api/packages/route.ts — Vercel Edge Runtime
export const runtime = "edge"

import { neon } from "@neondatabase/serverless"

export async function GET(request: Request) {
  const sql = neon(process.env.DATABASE_URL!)

  const packages = await sql`
    SELECT name, weekly_downloads, version
    FROM packages
    ORDER BY weekly_downloads DESC
    LIMIT 10
  `

  return Response.json(packages)
}

HTTP vs WebSocket mode

// HTTP mode (neon) — one query per request, no connection state:
import { neon } from "@neondatabase/serverless"
const sql = neon(process.env.DATABASE_URL!)

// Each query = one HTTP POST to Neon's HTTP endpoint
const packages = await sql`SELECT * FROM packages LIMIT 10`

// WebSocket mode (neonConfig + Pool) — multiple queries, transactions:
import { Pool, neonConfig } from "@neondatabase/serverless"
import ws from "ws"

// Required for Node.js (not needed in edge runtimes):
neonConfig.webSocketConstructor = ws

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

// Pool API is pg-compatible — drop-in replacement:
const { rows } = await pool.query(
  "SELECT * FROM packages WHERE name = $1",
  ["react"]
)

// Transactions require WebSocket mode:
const client = await pool.connect()
await client.query("BEGIN")
// ...
await client.query("COMMIT")
client.release()

Drizzle ORM with @neondatabase/serverless

// Edge-compatible Drizzle setup:
import { drizzle } from "drizzle-orm/neon-http"
import { neon } from "@neondatabase/serverless"
import * as schema from "./schema"

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

// Now use Drizzle ORM normally — works in edge runtime:
const packages = await db
  .select()
  .from(schema.packages)
  .where(eq(schema.packages.category, "ui"))
  .orderBy(desc(schema.packages.weeklyDownloads))
  .limit(10)

Works with any Neon-compatible PostgreSQL

// @neondatabase/serverless works with:
// 1. Neon (neon.tech) — designed for it
// 2. Supabase — use Supabase's connection string
// 3. Any PostgreSQL via HTTP proxy

// For non-edge environments, prefer pg or postgres.js —
// @neondatabase/serverless adds latency due to HTTP overhead

Feature Comparison

Featurepgpostgres.js@neondatabase/serverless
Weekly downloads~9M~2M~800K
TypeScript✅ @types/pg✅ Native✅ Native
Tagged template API✅ (neon mode)
Built-in pooling✅ pg-pool✅ Native
Transactions✅ Auto rollback✅ WS mode
Edge runtimes❌ TCP only❌ TCP only✅ HTTP/WS
Auto camelCase✅ transform
Bulk insertVerbose✅ sql() helper
Prepared statements❌ HTTP mode
pg API compatible✅ Pool mode
PerformanceBaseline2-3x faster~10ms overhead

When to Use Each

Choose postgres.js if:

  • Starting a new project with direct database access (no ORM)
  • TypeScript is a priority — native types, no @types package needed
  • You want auto-camelCase column mapping
  • Performance matters — 2-3x faster than pg in benchmarks

Choose pg if:

  • Your ORM uses pg under the hood (Knex, Sequelize, Drizzle with pg adapter)
  • You're working on a legacy codebase already using pg
  • You need the widest ecosystem compatibility
  • You're using PgBouncer or a connection pooler that has pg-specific considerations

Choose @neondatabase/serverless if:

  • Running in Vercel Edge Functions, Cloudflare Workers, or any edge runtime
  • Using Neon.tech as your hosted Postgres provider
  • You need HTTP/WebSocket PostgreSQL access (TCP not available)
  • Using Drizzle with drizzle-orm/neon-http adapter

Connection Pooling in Production

Connection pooling is one of the most important operational decisions for PostgreSQL at scale. PostgreSQL has a fixed connection limit (default 100 on most hosted plans), and each connection consumes server memory — typically 5-10MB per idle connection, more under load. A Node.js application under traffic can exhaust the connection limit quickly if each serverless function invocation opens its own connection without pooling. All three drivers support connection pooling differently. pg uses pg-pool, which is mature and well-understood but requires manual configuration of pool size, idle timeout, and connection timeout. postgres.js has built-in pooling with sensible defaults — the max option controls pool size, and connections are automatically closed after idle_timeout seconds. For serverless deployments, @neondatabase/serverless is unique in that it can operate over HTTP (the neon() function), which is effectively connection-less — each query opens and closes a connection at the HTTP level, making it suitable for environments where persistent TCP connections are impossible. For high-traffic traditional servers, pairing pg or postgres.js with PgBouncer in transaction mode (where connections are returned to the pool between queries rather than between sessions) allows 10-20x higher concurrency than direct connections.

Handling Edge Runtime Constraints

The shift toward edge runtimes — Vercel Edge Functions, Cloudflare Workers, Deno Deploy — has created a new class of database connectivity problems. These runtimes run in V8 isolates without TCP socket access, which rules out traditional PostgreSQL drivers that speak the wire protocol over a persistent TCP connection. @neondatabase/serverless solves this specifically for Neon and compatible PostgreSQL databases by tunneling queries over HTTP or WebSocket, both of which are available in edge runtimes. The HTTP mode (one query per HTTP POST) is the simplest but has the highest latency overhead — roughly 5-15ms per query for round-trip to Neon's HTTP endpoint, on top of the underlying PostgreSQL query time. The WebSocket mode allows a persistent connection over the WebSocket protocol, which edge runtimes support, enabling transactions and prepared statement reuse. For Cloudflare Workers specifically, Hyperdrive (Cloudflare's connection pooler for databases) can proxy TCP connections from Workers to any PostgreSQL database, meaning standard pg or postgres.js can work in Workers when Hyperdrive is configured as the connection target.

Security Best Practices for Database Connections

Database connection strings are one of the most sensitive secrets in any application. A leaked DATABASE_URL with write access gives an attacker full control over application data. The best practices apply regardless of which driver you use: store connection strings in environment variables (never in source code), use platform secret managers (Vercel Environment Variables, Railway secrets, AWS Secrets Manager) rather than .env files committed to repositories, and prefer connection strings with least-privilege credentials — read-only database users for read-only API endpoints, restricted schemas for application users rather than superuser access. Beyond credential management, enable SSL in all non-localhost connections: pg supports ssl: { rejectUnauthorized: false } for self-signed certificates or ssl: 'require' for production. postgres.js accepts ssl: 'require' in the connection options. @neondatabase/serverless connections to Neon are always SSL by default since they go over HTTPS. For applications handling personally identifiable information, also consider enabling row-level security in PostgreSQL itself — this is independent of the driver and prevents application-level bugs from exposing data across tenant boundaries.

TypeScript Type Safety End-to-End

Raw SQL drivers sit below the ORM layer and therefore require manual type annotation for query results. postgres.js's generic approach — sql<Package[]>`SELECT...`— lets you annotate return types at the call site, which TypeScript then propagates to all subsequent usage. The type safety is as strong as your annotations are accurate: postgres.js cannot verify that your schema matches your TypeScript interface. pg'spool.query()` generic works similarly. This is where type-safe query builders like Kysely (which generates TypeScript types from your schema) or ORMs like Drizzle (schema-first, generates types from the table definitions) add value — they eliminate the gap between your database schema and your TypeScript types. For teams comfortable writing raw SQL but wanting end-to-end type safety, the combination of postgres.js plus pgtyped (which parses SQL files and generates TypeScript types from them by introspecting the live database) is increasingly popular in 2026.

ORM Compatibility and Ecosystem Integration

The choice of PostgreSQL driver is often not made directly — it is inherited from the ORM or query builder you select. Prisma abstracts the driver entirely; its internal connection management uses pg under the hood but exposes none of the driver API. Drizzle ORM explicitly supports both pg (via drizzle-orm/node-postgres) and postgres.js (via drizzle-orm/postgres-js) and @neondatabase/serverless (via drizzle-orm/neon-http and drizzle-orm/neon-serverless), making driver selection a config-time decision rather than an architecture commitment. Knex.js is tightly coupled to pg — switching to postgres.js with Knex is not supported. For teams starting a new project and planning to use a query builder or ORM, the driver choice should follow the ORM recommendation: use whatever the ORM's docs specify as the primary driver for your environment. Fighting the ORM's preferred driver adds complexity without benefit. Only teams writing raw SQL or using Drizzle's explicit driver selection have a meaningful choice between pg and postgres.js in practice. The broader takeaway is that for greenfield projects with TypeScript and Drizzle, postgres.js is the superior raw driver and pairs cleanly with the drizzle-orm/postgres-js adapter. For existing projects using Prisma, Sequelize, or Knex, the driver is already determined and no migration is warranted.

Monitoring and Query Performance Observability

Production PostgreSQL applications need query performance observability beyond what the driver provides. All three drivers expose query execution time through timing wrappers or middleware patterns, but the actual query plan analysis happens at the PostgreSQL level via EXPLAIN ANALYZE. For identifying slow queries in production, enabling PostgreSQL's pg_stat_statements extension surfaces cumulative statistics on every query type executed, which is the most reliable way to find queries that need indexes. Monitoring tools like PgHero (a Rails gem that works with any PostgreSQL) and Supabase's built-in query stats surface this data with a UI. For distributed tracing that connects HTTP requests to database queries, OpenTelemetry has instrumentation packages for both pg (@opentelemetry/instrumentation-pg) and postgres.js (community packages). The @neondatabase/serverless driver benefits from Neon's built-in query insights in the Neon console, which shows slow queries and connection pool utilization without requiring additional instrumentation on the application side.

Migration Paths and Adoption Considerations

Teams evaluating a switch between these drivers should understand the relative migration cost. Moving from pg to postgres.js is straightforward for code that uses raw queries, but requires rewriting the query syntax from parameterized placeholders to tagged template literals, and updating transaction management from explicit BEGIN/COMMIT/ROLLBACK calls to the cleaner auto-rollback pattern. For large codebases, this migration can be done incrementally by introducing postgres.js for new query modules while keeping pg for existing ones, since both drivers can operate concurrently against the same database. Moving to @neondatabase/serverless from either pg or postgres.js is primarily driven by deployment target requirements rather than API preference — it is the correct choice when the deployment environment requires HTTP-based database access, not a performance or ergonomics optimization. The initial investment in learning postgres.js's tagged template API pays dividends in code clarity, particularly for complex queries with multiple parameters where the positional placeholder numbering in pg's dollar-sign syntax becomes difficult to maintain.

Methodology

Download data from npm registry (weekly average, February 2026). Performance benchmarks from postgres.js repository. Feature comparison based on pg v8.x, postgres.js v3.x, and @neondatabase/serverless v0.9.x.

Compare database and PostgreSQL packages on PkgPulse →

See also: Prisma Pulse vs Supabase Realtime vs Debezium and ioredis vs node-redis vs Upstash Redis, Drizzle-Kit vs Atlas vs dbmate Migrations 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.