slonik vs pg-promise vs pg: PostgreSQL Query Builders for Node.js (2026)
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
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.