Module F-7·16 min read

Connecting to PostgreSQL with node-postgres, parameterized queries, SQL injection prevention, and a first look at Prisma ORM.

Written by Jatin Jain Saraf · Architect of SupraScan

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 & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.