Skip to main content

slonik vs pg-promise vs pg: PostgreSQL Query Builders for Node.js (2026)

·PkgPulse Team

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 sql tagged 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

Featureslonikpg-promisepg
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.

Compare PostgreSQL and database tooling on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.