Skip to main content

🔌 Using the pg Library

The pg library is the translator between your JavaScript and PostgreSQL. You hand it SQL and a few values; it hands back plain JS objects. This lesson turns you from "I can connect" into "I can run any query safely" — SELECT, INSERT, UPDATE, DELETE, transactions, and the type mappings that trip people up.

Week 8 · Day 2 (Tuesday: PostgreSQL with Node.js) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the difference between Client and Pool and choose correctly
  • Run parameterized SELECT, INSERT, UPDATE, and DELETE queries with async/await
  • Read the fields of a pg result object (rows, rowCount, command)
  • Wrap multi-step work in a transaction using a checked-out client with try/catch/finally
  • Predict how PostgreSQL types arrive in JavaScript (dates, JSON, arrays, numerics)
  • Handle common database errors by their SQLSTATE code

Estimated Time: 75 minutes

Project: Build a small repository module with full CRUD and one transactional operation.

In This Lesson

The pg Library

pg — often written node-postgres — is the de facto PostgreSQL driver for Node.js. It has been actively maintained since 2010, is written in pure JavaScript (no native compile step), and underpins a huge slice of production Node apps. When you type npm install pg, this is what you get.

Its job is narrow and honest: send SQL to Postgres over the wire, and turn the response into JavaScript. Every row comes back as an object whose keys are your column names.

Your app sends SQL and params to pg, which queries Postgres and returns JavaScript objects Your Code async/await pg the driver PostgreSQL the database SQL + [params] rows as JS objects
The round trip: you send SQL text plus a values array, and rows come back as ordinary JavaScript objects.

This lesson assumes the db.js module and .env setup from the previous lesson. If you skipped it, that's where the pool and credentials come from.

Client vs Pool

pg offers two connection objects, and picking the right one matters.

ClientPool
ConnectionsOne, that you open and closeA managed set, reused across requests
LifecycleManual connect() / end()Automatic checkout & return
Use forScripts, migrations, one-offsWeb servers & APIs

A single Client (scripts / CLIs)

const { Client } = require('pg');

async function oneOffTask() {
  const client = new Client({
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    database: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
  });

  try {
    await client.connect();
    const res = await client.query('SELECT NOW()');
    console.log('Server time:', res.rows[0].now);
  } finally {
    await client.end();   // always close a standalone Client
  }
}

A Pool (everything server-side)

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,                       // most connections the pool will open
  idleTimeoutMillis: 30000,      // close idle clients after 30s
  connectionTimeoutMillis: 2000, // give up waiting after 2s
});

// pool.query() checks out a client, runs the query, and returns it for you
const res = await pool.query('SELECT NOW()');

✅ Rule of thumb

If your process handles concurrent requests — any Express/Fastify server — use a Pool. Reach for a bare Client only for a short-lived script that does its work and exits. We build on the pooled db.js module from here on.

Running Queries (CRUD)

Every query goes through the same call: db.query(text, params). The text is your SQL with $1, $2 placeholders; params is the array of values to fill them. The call returns a promise, so we await it.

⚠️ The one rule that never bends

// ❌ NEVER interpolate input into SQL — this is SQL injection
const q = `SELECT * FROM users WHERE email = '${req.body.email}'`;

// ✅ ALWAYS parameterize — the value is sent separately from the SQL
const result = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [req.body.email]
);

A parameter can be a name, an id, a whole JSON object — but it can never become SQL. That is what keeps a value like ' OR 1=1 -- from turning your login check into "return everyone."

SELECT — reading rows

const db = require('./db');

// All rows
async function getAllUsers() {
  const result = await db.query('SELECT * FROM users ORDER BY created_at DESC');
  return result.rows;                 // an array of objects
}

// One row by id — returns undefined if not found
async function getUserById(id) {
  const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  return result.rows[0];
}

// A JOIN across two tables
async function getUserWithPosts(userId) {
  const result = await db.query(
    `SELECT u.id, u.username, p.id AS post_id, p.title
       FROM users u
       LEFT JOIN posts p ON p.user_id = u.id
      WHERE u.id = $1`,
    [userId]
  );
  return result.rows;
}

INSERT — creating rows

Add RETURNING * so PostgreSQL hands back the row it just created, including the generated id and any defaults — no second query needed.

async function createUser({ username, email, passwordHash }) {
  const result = await db.query(
    `INSERT INTO users (username, email, password_hash, created_at)
     VALUES ($1, $2, $3, NOW())
     RETURNING *`,
    [username, email, passwordHash]
  );
  return result.rows[0];              // the brand-new user, with its id
}

UPDATE — changing rows

async function updateUserEmail(id, email) {
  const result = await db.query(
    `UPDATE users
        SET email = $1, updated_at = NOW()
      WHERE id = $2
      RETURNING *`,
    [email, id]
  );
  if (result.rowCount === 0) throw new Error('User not found');
  return result.rows[0];
}

DELETE — removing rows

async function deleteUser(id) {
  const result = await db.query(
    'DELETE FROM users WHERE id = $1 RETURNING *',
    [id]
  );
  if (result.rowCount === 0) throw new Error('User not found');
  return result.rows[0];              // the row that was deleted
}

💡 As an object, if you prefer

Instead of two arguments you can pass a single config object — handy for very long queries: db.query({ text: 'SELECT ... WHERE id = $1', values: [id] }). Same behavior, slightly more readable at scale.

The Result Object

Every successful query resolves to a result object. You'll use rows constantly and rowCount often; the rest is occasionally handy.

PropertyWhat it holds
rowsArray of row objects (keys are column names)
rowCountNumber of rows affected/returned — great for "did anything change?"
commandThe command type: SELECT, INSERT, UPDATE, DELETE
fieldsMetadata about each returned column (name, data type id)
const result = await db.query('SELECT id, name FROM items LIMIT 3');

console.log(result.command);              // "SELECT"
console.log(result.rowCount);             // 3
console.log(result.rows);                 // [{ id: 1, name: '...' }, ...]
console.log(result.fields.map(f => f.name)); // ['id', 'name']

Output

SELECT
3
[ { id: 1, name: 'First item' }, { id: 2, name: 'Second item' }, ... ]
[ 'id', 'name' ]

Transactions

Some operations must happen all together or not at all. Transferring money debits one account and credits another — if the second step fails, you must undo the first, or money vanishes. A transaction gives you that all-or-nothing guarantee with BEGIN, COMMIT, and ROLLBACK.

The catch: all statements in one transaction must run on the same connection. So instead of pool.query() (which may pick a different client each call), you check out one client, run everything on it, and release it when done.

sequenceDiagram participant App as Your Code participant Client as Pooled Client participant DB as PostgreSQL App->>Client: pool.connect() App->>Client: BEGIN Client->>DB: BEGIN App->>Client: UPDATE ... (debit) App->>Client: UPDATE ... (credit) alt all succeed App->>Client: COMMIT Client->>DB: COMMIT else any error App->>Client: ROLLBACK Client->>DB: ROLLBACK end App->>Client: client.release()

The pattern below is the one to memorize. Note the try/catch/finally: commit on success, roll back on any error, and always release the client — even when something throws.

const pool = require('./db').pool;  // export the pool for transactions

async function transferFunds(fromId, toId, amount) {
  const client = await pool.connect();   // check out one dedicated client
  try {
    await client.query('BEGIN');

    // Debit the sender, but only if funds are sufficient
    const debit = await client.query(
      `UPDATE accounts SET balance = balance - $1
        WHERE id = $2 AND balance >= $1
        RETURNING *`,
      [amount, fromId]
    );
    if (debit.rowCount === 0) {
      throw new Error('Insufficient funds or account not found');
    }

    // Credit the recipient
    await client.query(
      'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
      [amount, toId]
    );

    await client.query('COMMIT');          // everything worked → make it permanent
    return { success: true };
  } catch (err) {
    await client.query('ROLLBACK');        // any failure → undo it all
    throw err;
  } finally {
    client.release();                      // ALWAYS return the client to the pool
  }
}

⚠️ Forgetting release() leaks connections

A client that is checked out but never released is gone from the pool forever. Do that a few times and the pool empties, every new request hangs waiting for a connection, and your app freezes. The finally block exists precisely so release happens no matter what.

PostgreSQL & JS Types

pg converts PostgreSQL types into JavaScript for you, mostly intuitively — with a few gotchas worth memorizing.

PostgreSQL typeArrives in JS asWatch out for
INTEGER, SMALLINTNumberFine within JS safe-integer range
BIGINTStringReturned as string to avoid precision loss
NUMERIC, DECIMALStringString preserves exact precision — parse deliberately
BOOLEANBooleanDirect
VARCHAR, TEXTStringDirect
TIMESTAMP, DATEDateA JS Date object
JSON, JSONBObject / ArrayAlready parsed — don't JSON.parse again
ARRAYArrayDirect

💡 The NUMERIC-as-string surprise

Query SELECT COUNT(*) and the count comes back as the string "42", because COUNT returns BIGINT. Wrap it: parseInt(result.rows[0].count, 10). Same for money stored as NUMERIC — the string keeps cents exact, so decide when to convert.

JSON and arrays, both directions

// Storing: a JS object goes straight into a JSONB column, an array into TEXT[]
await db.query(
  'INSERT INTO posts (title, tags, meta) VALUES ($1, $2, $3)',
  ['Hello', ['intro', 'news'], { views: 0, pinned: true }]
);

// Reading: JSONB comes back as a live object, arrays as arrays
const { rows } = await db.query('SELECT tags, meta FROM posts WHERE id = $1', [1]);
console.log(rows[0].tags[0]);       // 'intro'   (real array)
console.log(rows[0].meta.pinned);   // true      (already an object)

// Query inside JSON with the ->> operator (parameterize the value!)
const dark = await db.query(
  `SELECT * FROM users WHERE preferences->>'theme' = $1`,
  ['dark']
);

Error Handling

When a query fails, pg throws an error carrying a PostgreSQL SQLSTATE code on err.code. Branching on that code lets you turn a raw database error into a friendly message.

CodeMeaning
23505Unique violation (duplicate email, etc.)
23503Foreign key violation
23502NOT NULL violation
42P01Undefined table
42703Undefined column
async function createUser(userData) {
  try {
    const result = await db.query(
      `INSERT INTO users (username, email, password_hash)
       VALUES ($1, $2, $3) RETURNING *`,
      [userData.username, userData.email, userData.passwordHash]
    );
    return result.rows[0];
  } catch (err) {
    if (err.code === '23505') {                 // unique violation
      if (err.constraint === 'users_email_key') {
        throw new Error('That email is already registered');
      }
      throw new Error('That value is already taken');
    }
    console.error('DB error:', { code: err.code, detail: err.detail });
    throw new Error('Database error occurred');   // don't leak internals to clients
  }
}

📖 Why not just send the raw error?

Raw PostgreSQL errors can reveal table names, column names, and query structure — a gift to an attacker. Log the details server-side, but return a clean, generic message (or a specific, safe one for known cases like duplicate email) to the client.

Practice & Quiz

🏋️ Exercise 1: A tasks repository

Goal: Write createTask(title) and getTask(id) against a tasks(id, title, done, created_at) table. Both must use parameters, and createTask must return the created row.

💡 Hint

Use INSERT ... RETURNING * and read result.rows[0]. For the lookup, return result.rows[0] (which is undefined when nothing matches).

✅ Solution
const db = require('./db');

async function createTask(title) {
  const result = await db.query(
    'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
    [title]
  );
  return result.rows[0];
}

async function getTask(id) {
  const result = await db.query('SELECT * FROM tasks WHERE id = $1', [id]);
  return result.rows[0];   // undefined if not found
}

🏋️ Exercise 2: A safe two-step transaction

Goal: Write archiveAndLog(taskId) that sets a task's done = true and inserts a row into audit_log — both in one transaction, so a failure on either leaves nothing changed.

💡 Hint

Check out a client with pool.connect(). BEGIN, run both queries, COMMIT. Catch → ROLLBACK. finallyclient.release().

✅ Solution
const pool = require('./db').pool;

async function archiveAndLog(taskId) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const upd = await client.query(
      'UPDATE tasks SET done = TRUE WHERE id = $1 RETURNING *',
      [taskId]
    );
    if (upd.rowCount === 0) throw new Error('Task not found');

    await client.query(
      'INSERT INTO audit_log (task_id, action) VALUES ($1, $2)',
      [taskId, 'archived']
    );

    await client.query('COMMIT');
    return upd.rows[0];
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

🎯 Quick Quiz

Question 1: To get the auto-generated id back from an INSERT in a single query, you add:

Question 2: Why must a transaction use a checked-out client rather than pool.query()?

Question 3: SELECT COUNT(*) FROM users returns the count in rows[0].count as:

Best Practices & Pitfalls

✅ Do

  • Parameterize every external value with $1, $2 — no exceptions
  • Use async/await with try/catch around database calls
  • Return created/updated rows with RETURNING * to avoid extra queries
  • For transactions, check out a client and release it in finally
  • Check rowCount to distinguish "updated" from "nothing matched"

❌ Don't

  • Concatenate user input into SQL strings (SQL injection)
  • Forget client.release() — it leaks connections and stalls the pool
  • Assume COUNT or BIGINT is a number — it's a string
  • Send raw PostgreSQL error text to the client
  • Use pool.query() for the middle of a transaction

⚠️ Placeholders are for values, not identifiers

$1 can stand in for a value, but not for a table or column name: SELECT * FROM $1 won't work. If you must build dynamic column lists, validate them against an allow-list of known names — never against raw user input.

Summary

🎉 Key Takeaways

  • Use a Pool in servers; a bare Client only for short scripts
  • Every query is db.query(text, params) with parameterized $1 placeholders
  • Read results from rows and rowCount; use RETURNING * to get changed rows back
  • Transactions run on one checked-out client with BEGIN/COMMIT/ROLLBACK and a finally release()
  • Know the type surprises: BIGINT/NUMERIC come back as strings; JSONB comes back already parsed
  • Branch on err.code to handle database errors gracefully

📚 Additional Resources

🚀 What's Next?

You've used pool.query() and checked out clients — but why is a pool so much faster than opening connections yourself? Next up, Connection Pooling opens the hood: how checkout/return works, how to size the pool, and how to monitor it under load.

🔌 Fluent in SQL from Node

You can now run any CRUD operation safely, wrap steps in a transaction, and read PostgreSQL's types without surprises. That's the daily bread of back-end work.