Connecting to PostgreSQL with node-postgres, parameterized queries, SQL injection prevention, and a first look at Prisma ORM.
Module F-7 — Working with Databases from Node.js
What this module covers: Almost every Node.js application talks to a database. This module covers the two dominant approaches: using
node-postgres(pg) directly for full control with raw SQL, and using Prisma ORM for a type-safe, schema-driven workflow. You will learn how to connect, query, parameterize inputs to prevent SQL injection, manage connection pooling, and handle errors correctly. By the end you will have a working database layer you can drop into the Express API from F-6.
Two Approaches: Raw SQL vs ORM
Before writing code, choose your approach:
Raw SQL with node-postgres
- Full control over every query
- No abstraction layer — what you write is what runs
- Best when queries are complex, performance-critical, or team is SQL-proficient
- Requires manual migration management
Prisma ORM
- Type-safe queries with autocomplete
- Schema-first: define your models in
schema.prisma, Prisma generates the client - Auto-generated migrations
- Faster to get started, excellent for standard CRUD
- Less control over complex queries (though raw SQL escape hatch exists)
Most production codebases use one or the other, or a mix (Prisma for standard queries, raw SQL for complex reports). This module teaches both.
Option 1: node-postgres (Raw SQL)
Installing and connecting
npm install pg
npm install -D @types/pg # if using TypeScript
// src/db/pool.js
import pg from 'pg';
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// OR specify individually:
// host: process.env.DB_HOST,
// port: parseInt(process.env.DB_PORT || '5432'),
// database: process.env.DB_NAME,
// user: process.env.DB_USER,
// password: process.env.DB_PASSWORD,
max: 10, // max pool size (default: 10)
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 2000, // fail fast if can't connect in 2s
});
// Test connection on startup
pool.connect()
.then(client => {
console.log('Database connected');
client.release();
})
.catch(err => {
console.error('Database connection failed:', err.message);
process.exit(1);
});
# .env
DATABASE_URL=postgres://username:password@localhost:5432/myapp_dev
Why a Pool, not a single connection?
A database connection is expensive to open (~5–50ms). A connection pool keeps a set of connections open and reuses them. When your code needs a connection, it borrows one from the pool, uses it, and returns it. Under high load (100 concurrent requests), each request gets a connection instantly rather than waiting to open a new one.
A connection pool is a taxi rank, not a car dealership — instead of manufacturing a brand-new car for every passenger, you keep a small fleet idling and hand off whichever one is free.
pg.Pool handles this automatically. Never use new pg.Client() for a server — that creates a single connection with no pooling.
Production story: a UPI settlement service running at roughly 20K TPS across 12 pods had pool.max: 20 on each pod — 240 connections combined, against a Postgres instance capped at max_connections = 200. A traffic spike pushed the fleet to its combined ceiling. Every pod's health check queried the database as part of its readiness probe; every one of those queries failed identically because the pool was exhausted, every pod reported unhealthy at once, and the orchestrator restarted the entire fleet simultaneously — turning a capacity spike into a full outage. The fix wasn't a bigger Postgres instance, it was arithmetic: pool.max × running instances has to stay comfortably under max_connections, with headroom for migrations, admin connections, and any other service sharing the database.
A shorter, second story from the same class of bug: a blockchain indexer stored block_number as a Postgres BIGINT. When pg returned it, blockNumber + 1 silently produced string concatenation instead of incrementing the number — see BigInt Handling below for why.
Pool Sizing at Fleet Scale
pool.max is a per-instance setting, not a global one. If you run 12 replicas of your service and each opens a pool of 20 connections, you are asking Postgres for 240 simultaneous connections, not 20. Postgres has a hard max_connections limit (default 100, commonly raised to 200–500) shared across every client connecting to it — your app fleet, migration jobs, admin consoles, read replicas, all of it.
The sizing rule of thumb:
(pool.max × number_of_running_instances) + headroom < postgres max_connections
Getting this wrong doesn't fail gracefully — it fails all at once, the way the story above played out. When scaling out horizontally (more pods/instances), scale pool.max down proportionally, or put a connection pooler like PgBouncer in front of Postgres so the fleet can grow without the connection count growing linearly with it.
Basic queries
// src/db/users.js
import { pool } from './pool.js';
// SELECT — returns multiple rows
export async function findAllUsers() {
const result = await pool.query('SELECT id, name, email, role FROM users ORDER BY created_at DESC');
return result.rows; // array of row objects
}
// SELECT with a filter
export async function findUserById(id) {
const result = await pool.query(
'SELECT id, name, email, role FROM users WHERE id = $1',
[id] // parameterized — $1 is replaced with id safely
);
return result.rows[0] ?? null; // undefined → null if not found
}
// INSERT
export async function createUser({ name, email, role = 'user' }) {
const result = await pool.query(
`INSERT INTO users (name, email, role, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id, name, email, role, created_at`,
[name, email, role]
);
return result.rows[0];
}
// UPDATE
export async function updateUser(id, { name, email, role }) {
const result = await pool.query(
`UPDATE users
SET name = COALESCE($2, name),
email = COALESCE($3, email),
role = COALESCE($4, role),
updated_at = NOW()
WHERE id = $1
RETURNING id, name, email, role`,
[id, name, email, role]
);
return result.rows[0] ?? null;
}
// DELETE
export async function deleteUser(id) {
const result = await pool.query(
'DELETE FROM users WHERE id = $1',
[id]
);
return result.rowCount > 0; // true if a row was deleted
}
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion