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.jsis 2-3x faster thanpgin benchmarks, with lower memory usage- Use
pgif you need maximum ecosystem compatibility or are using an ORM that requires it - Serverless environments (edge functions) require
@neondatabase/serverlessor a similar HTTP driver
Download Trends
| Package | Weekly Downloads | TypeScript | Pooling | Edge 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
| Feature | pg | postgres.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 insert | Verbose | ✅ sql() helper | ✅ |
| Prepared statements | ✅ | ✅ | ❌ HTTP mode |
| pg API compatible | ✅ | ❌ | ✅ Pool mode |
| Performance | Baseline | 2-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-httpadapter
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
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.