TL;DR
slonik is the type-safe PostgreSQL client — tagged template SQL queries that prevent SQL injection by design, runtime type validation with Zod, connection pool management, and interceptors. pg-promise is the feature-rich PostgreSQL library — promise-based, query formatting, task/transaction helpers, and powerful result parsing. pg (node-postgres) is the low-level driver — the foundation that slonik and pg-promise build on, connection pooling, parameterized queries, direct protocol access. In 2026: slonik for type-safe SQL with injection safety, pg-promise for feature-rich query workflows, raw pg for maximum control.
Key Takeaways
- slonik: ~100K weekly downloads — tagged templates, Zod validation, SQL injection safe by design
- pg-promise: ~500K weekly downloads — promise-based, query formatting, helpers for batch ops
- pg: ~10M weekly downloads — the low-level driver, used by slonik, pg-promise, Prisma, Drizzle
- slonik uses
sqltagged templates — impossible to accidentally concatenate user input - pg-promise adds structured query formatting and transaction helpers on top of pg
- pg is the foundation — direct access to PostgreSQL protocol, connection pooling, LISTEN/NOTIFY
pg (node-postgres)
pg — the foundation:
Basic queries
import { Pool } from "pg"
const pool = new Pool({
connectionString: "postgresql://localhost:5432/pkgpulse",
max: 20,
idleTimeoutMillis: 30_000,
})
// Parameterized query (SQL injection safe):
const result = await pool.query(
"SELECT name, weekly_downloads FROM packages WHERE weekly_downloads > $1 ORDER BY weekly_downloads DESC LIMIT $2",
[1_000_000, 10]
)
console.log(result.rows)
// → [{ name: "react", weekly_downloads: 5000000 }, ...]
// Insert:
const { rows } = await pool.query(
"INSERT INTO packages (name, description, weekly_downloads) VALUES ($1, $2, $3) RETURNING *",
["new-pkg", "A new package", 0]
)
Transactions
const client = await pool.connect()
try {
await client.query("BEGIN")
const pkg = await client.query(
"INSERT INTO packages (name) VALUES ($1) RETURNING id",
["my-package"]
)
await client.query(
"INSERT INTO versions (package_id, number) VALUES ($1, $2)",
[pkg.rows[0].id, "1.0.0"]
)
await client.query("COMMIT")
} catch (error) {
await client.query("ROLLBACK")
throw error
} finally {
client.release()
}
pg limitations
pg gives you raw power but requires:
❌ Manual parameterized queries ($1, $2, $3...)
❌ Manual transaction management (BEGIN/COMMIT/ROLLBACK)
❌ Manual result type assertions
❌ No query building helpers
❌ Easy to accidentally concatenate SQL strings
✅ Maximum control
✅ LISTEN/NOTIFY support
✅ Streaming queries
✅ Copy protocol support
✅ Custom type parsers
slonik
slonik — type-safe PostgreSQL:
Tagged template queries
import { createPool, sql } from "slonik"
const pool = await createPool("postgresql://localhost:5432/pkgpulse")
// sql tagged template — injection safe by design:
const packages = await pool.any(sql.unsafe`
SELECT name, weekly_downloads
FROM packages
WHERE weekly_downloads > ${1_000_000}
ORDER BY weekly_downloads DESC
LIMIT ${10}
`)
// Dynamic values are ALWAYS parameterized:
const name = "react"
const pkg = await pool.one(sql.unsafe`
SELECT * FROM packages WHERE name = ${name}
`)
// Executes: SELECT * FROM packages WHERE name = $1 (params: ["react"])
// This is IMPOSSIBLE with slonik:
// sql`SELECT * FROM packages WHERE name = '${name}'` ← won't work
// The tagged template ensures safe parameterization
Zod type validation
import { createPool, sql } from "slonik"
import { z } from "zod"
const pool = await createPool("postgresql://localhost:5432/pkgpulse")
// Define result schema:
const PackageSchema = z.object({
id: z.number(),
name: z.string(),
weeklyDownloads: z.number(),
createdAt: z.date(),
})
// Query with runtime validation:
const packages = await pool.any(sql.type(PackageSchema)`
SELECT id, name, weekly_downloads AS "weeklyDownloads", created_at AS "createdAt"
FROM packages
WHERE weekly_downloads > ${100_000}
`)
// packages is typed as Array<{ id: number, name: string, weeklyDownloads: number, createdAt: Date }>
// Runtime validation ensures the data matches the schema
Query methods
import { createPool, sql } from "slonik"
const pool = await createPool("postgresql://localhost:5432/pkgpulse")
// one() — exactly one row (throws if 0 or 2+):
const pkg = await pool.one(sql.unsafe`
SELECT * FROM packages WHERE name = ${"react"}
`)
// oneFirst() — one row, first column:
const count = await pool.oneFirst(sql.unsafe`
SELECT COUNT(*) FROM packages
`)
// any() — zero or more rows:
const packages = await pool.any(sql.unsafe`
SELECT * FROM packages WHERE weekly_downloads > ${1_000_000}
`)
// many() — one or more rows (throws if 0):
const trending = await pool.many(sql.unsafe`
SELECT * FROM packages WHERE trending = true
`)
// maybeOne() — zero or one row:
const maybe = await pool.maybeOne(sql.unsafe`
SELECT * FROM packages WHERE name = ${"unknown-pkg"}
`)
// → null or the row
// exists() — boolean check:
const hasReact = await pool.exists(sql.unsafe`
SELECT 1 FROM packages WHERE name = ${"react"}
`)
SQL fragments and composition
import { sql } from "slonik"
// Dynamic column selection:
const columns = sql.join(
[sql.identifier(["name"]), sql.identifier(["weekly_downloads"])],
sql.fragment`, `
)
const result = await pool.any(sql.unsafe`
SELECT ${columns} FROM packages
`)
// Dynamic WHERE conditions:
const conditions = [
sql.fragment`weekly_downloads > ${100_000}`,
sql.fragment`created_at > ${new Date("2026-01-01")}`,
]
const filtered = await pool.any(sql.unsafe`
SELECT * FROM packages
WHERE ${sql.join(conditions, sql.fragment` AND `)}
`)
// Bulk insert:
const values = [
["react", 5_000_000],
["vue", 3_000_000],
["svelte", 500_000],
]
await pool.query(sql.unsafe`
INSERT INTO packages (name, weekly_downloads)
SELECT * FROM ${sql.unnest(values, ["text", "int4"])}
`)
Interceptors
import { createPool } from "slonik"
import { createQueryLoggingInterceptor } from "slonik-interceptor-query-logging"
const pool = await createPool("postgresql://localhost:5432/pkgpulse", {
interceptors: [
createQueryLoggingInterceptor(),
// Custom interceptor:
{
beforeQueryExecution: async (context, query) => {
console.log("Query:", query.sql)
console.log("Params:", query.values)
return null // Continue with query
},
afterQueryExecution: async (context, query, result) => {
console.log(`Returned ${result.rowCount} rows`)
return null
},
},
],
})
pg-promise
pg-promise — feature-rich PostgreSQL:
Setup and basic queries
import pgPromise from "pg-promise"
const pgp = pgPromise()
const db = pgp("postgresql://localhost:5432/pkgpulse")
// Parameterized query:
const packages = await db.any(
"SELECT name, weekly_downloads FROM packages WHERE weekly_downloads > $1 ORDER BY weekly_downloads DESC LIMIT $2",
[1_000_000, 10]
)
// Named parameters:
const pkg = await db.one(
"SELECT * FROM packages WHERE name = ${name}",
{ name: "react" }
)
// Query methods (like slonik):
await db.one(query) // Exactly one row
await db.oneOrNone(query) // Zero or one row
await db.any(query) // Zero or more rows
await db.many(query) // One or more rows
await db.none(query) // No rows expected (INSERT/UPDATE/DELETE)
await db.result(query) // Full result object
Task and transaction helpers
// Task — single connection for multiple queries:
const result = await db.task(async (t) => {
const pkg = await t.one(
"INSERT INTO packages (name) VALUES ($1) RETURNING *",
["my-package"]
)
const version = await t.one(
"INSERT INTO versions (package_id, number) VALUES ($1, $2) RETURNING *",
[pkg.id, "1.0.0"]
)
return { pkg, version }
})
// Transaction — task + automatic COMMIT/ROLLBACK:
const result = await db.tx(async (t) => {
const pkg = await t.one(
"INSERT INTO packages (name) VALUES ($1) RETURNING *",
["my-package"]
)
const version = await t.one(
"INSERT INTO versions (package_id, number) VALUES ($1, $2) RETURNING *",
[pkg.id, "1.0.0"]
)
return { pkg, version }
})
// Commits on success, rolls back on error — no manual BEGIN/COMMIT
Helpers for batch operations
const pgp = pgPromise()
const db = pgp("postgresql://localhost:5432/pkgpulse")
// Multi-row INSERT:
const packages = [
{ name: "react", weekly_downloads: 5_000_000 },
{ name: "vue", weekly_downloads: 3_000_000 },
{ name: "svelte", weekly_downloads: 500_000 },
]
const cs = new pgp.helpers.ColumnSet(["name", "weekly_downloads"], {
table: "packages",
})
// Generate multi-row INSERT:
const insert = pgp.helpers.insert(packages, cs)
await db.none(insert)
// INSERT INTO packages(name,weekly_downloads) VALUES('react',5000000),('vue',3000000),('svelte',500000)
// Multi-row UPDATE:
const updates = [
{ name: "react", weekly_downloads: 5_500_000 },
{ name: "vue", weekly_downloads: 3_200_000 },
]
const update = pgp.helpers.update(updates, cs, null, {
tableAlias: "v",
valueAlias: "u",
}) + " WHERE v.name = u.name"
await db.none(update)
// UPSERT (INSERT ... ON CONFLICT):
const upsert = pgp.helpers.insert(packages, cs) +
" ON CONFLICT(name) DO UPDATE SET weekly_downloads = EXCLUDED.weekly_downloads"
await db.none(upsert)
Feature Comparison
| Feature | slonik | pg-promise | pg |
|---|---|---|---|
| SQL injection safety | ✅ (by design) | ✅ (parameterized) | ⚠️ (manual) |
| Tagged templates | ✅ | ❌ | ❌ |
| Zod validation | ✅ | ❌ | ❌ |
| Query methods | ✅ (one/any/many) | ✅ (one/any/many) | ❌ (query only) |
| Transactions | ✅ | ✅ (tx helper) | ⚠️ (manual) |
| Batch helpers | ✅ (unnest) | ✅ (ColumnSet) | ❌ |
| Interceptors/events | ✅ | ✅ | ✅ |
| Streaming | ✅ | ❌ | ✅ |
| Connection pooling | ✅ | ✅ | ✅ |
| LISTEN/NOTIFY | ❌ | ❌ | ✅ |
| TypeScript | ✅ (native) | ✅ (native) | ✅ (@types) |
| Weekly downloads | ~100K | ~500K | ~10M |
When to Use Each
Use slonik if:
- Want SQL injection safety enforced by the API (tagged templates)
- Need runtime result validation with Zod
- Building a TypeScript-first application
- Want composable SQL fragments
Use pg-promise if:
- Need powerful batch operations (multi-row INSERT/UPDATE/UPSERT)
- Want structured transaction helpers (tx, task)
- Prefer named parameters (
${name}) in queries - Need the richest query formatting options
Use pg (node-postgres) if:
- Need maximum control over the PostgreSQL protocol
- Using LISTEN/NOTIFY for real-time features
- Building on top of pg with your own abstractions
- Need streaming queries for large datasets
- Other libraries (Prisma, Drizzle, Knex) already use pg internally
Real-World Usage Patterns in Production Node.js Apps
The three libraries occupy distinct niches that become clearer once you see them used together. Large Node.js codebases often use pg as a shared pool beneath a combination of higher-level tools: Drizzle or Prisma handling most queries in the application layer, while raw pg or slonik handles performance-critical paths where you need precise SQL control. pg-promise tends to appear in older TypeScript codebases (2018–2022 vintage) that predate Prisma's rise, and in data pipelines where its batch helpers for bulk INSERT and UPDATE operations provide genuine ergonomic benefits.
Slonik's tagged template approach shines in security-sensitive contexts where you cannot afford accidental SQL concatenation. Financial applications, healthcare APIs, and any codebase subject to SOC 2 audits benefit from the API-level guarantee that user input cannot be injected — the sql template literal does not allow string interpolation, only parameterized values. Teams that audit SQL injection risk appreciate the ability to grep for sql.unsafe and know they've found every point where injection is theoretically possible.
pg-promise's ColumnSet and batch helpers are particularly effective for ETL workflows and data migration scripts. When importing thousands of rows from a CSV or synchronizing data between systems, the ability to construct a single multi-row INSERT statement with typed columns — and verify at query-build time that your object fields match the expected columns — prevents the class of subtle bugs where a renamed property silently writes NULL into a database column.
Connection Pooling and Error Handling Differences
All three libraries ultimately use pg's connection pooling under the hood, but they differ in how they surface pool lifecycle events and how they handle query errors. Raw pg requires you to manually call client.release() in a finally block; forgetting this is a common source of pool exhaustion bugs in production. Slonik wraps pool management in its query methods — you never hold a client reference, so releasing is impossible to forget. pg-promise's task and tx helpers similarly manage the connection lifetime automatically within the callback scope.
Error handling has meaningful differences at production scale. pg throws a DatabaseError for PostgreSQL-specific errors, but the error object requires you to know PostgreSQL error codes (e.g., 23505 for unique constraint violation, 23503 for foreign key violation). pg-promise adds no additional structure here. Slonik introduces its own error hierarchy — NotFoundError when pool.one() returns no rows, DataIntegrityError when pool.one() returns multiple rows — making common GraphQL and REST API patterns more ergonomic:
import { NotFoundError } from "slonik"
try {
const pkg = await pool.one(sql.unsafe`SELECT * FROM packages WHERE name = ${"nonexistent"}`)
} catch (err) {
if (err instanceof NotFoundError) {
// Returns 404, not 500
throw new HttpError(404, "Package not found")
}
throw err
}
This structured error hierarchy eliminates the boilerplate of checking result.rows.length before destructuring in every API handler — a pattern that is easy to forget and produces confusing undefined is not an object errors in production.
Migration Path from pg to slonik or pg-promise
Teams that start with raw pg often graduate to a higher-level library as their codebase grows. Both slonik and pg-promise are designed so the migration is incremental rather than a full rewrite.
For pg → pg-promise, the migration is mechanical: wrap the existing pool.query() call sites with pg-promise's db instance, then convert manual transaction blocks to db.tx() callbacks. Named parameters (${name} syntax) can replace positional $1, $2 parameters one file at a time. pg-promise's helpers.ColumnSet adoption is typically driven by specific pain points — identifying the data import scripts that would benefit from bulk INSERT rewrites.
For pg → slonik, the bigger conceptual shift is adopting the sql tagged template. Teams often do a phased migration: add slonik alongside the existing pg pool, use slonik for new code, and progressively convert high-risk query sites (those accepting user input) to slonik's injection-safe templates first. Slonik's query methods (one, many, maybeOne) also require updating the consumption pattern at each call site from result.rows[0] to the direct return value.
TypeScript Integration and Query Result Typing
All three libraries support TypeScript, but they differ significantly in how tightly they integrate types with actual database schema definitions. Raw pg returns QueryResult<any> unless you explicitly provide a type parameter. This means you must manually define TypeScript interfaces that match your PostgreSQL column types, and nothing enforces that the interface stays synchronized with schema changes.
pg-promise adds a generic type parameter to query methods — db.one<PackageRow>('SELECT * FROM packages WHERE id = $1', [id]) — which provides type safety at the consumption site but still relies on manual interface maintenance. slonik's integration with Zod takes this further: the schema validates at runtime that the returned rows match the expected shape, catching column name changes or type mismatches that would otherwise produce silent bugs. For teams using schema migration tools like Drizzle Kit or Prisma Migrate, combining slonik with Zod and the migration tool's type generation produces end-to-end type safety from migration to query result.
For teams using Drizzle ORM as their primary data layer and needing raw pg access for complex queries that Drizzle cannot express, keeping both in the same codebase is common. Drizzle sits on top of pg and exposes the underlying pg client via db.execute() for raw SQL. Slonik is less compatible with this pattern because it manages its own connection pool — mixing slonik and Drizzle pools in the same application requires configuring both to use the same PostgreSQL connection limit, typically by setting Drizzle's pool to 5 connections and slonik's pool to 5 connections and increasing PostgreSQL's max_connections accordingly.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on slonik v46.x, pg-promise v11.x, and pg v8.x.
Compare PostgreSQL and database tooling on PkgPulse →
See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.