🔌 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
ClientandPooland choose correctly - Run parameterized SELECT, INSERT, UPDATE, and DELETE queries with
async/await - Read the fields of a
pgresult 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.
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.
Client | Pool | |
|---|---|---|
| Connections | One, that you open and close | A managed set, reused across requests |
| Lifecycle | Manual connect() / end() | Automatic checkout & return |
| Use for | Scripts, migrations, one-offs | Web 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.
| Property | What it holds |
|---|---|
rows | Array of row objects (keys are column names) |
rowCount | Number of rows affected/returned — great for "did anything change?" |
command | The command type: SELECT, INSERT, UPDATE, DELETE |
fields | Metadata 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.
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 type | Arrives in JS as | Watch out for |
|---|---|---|
INTEGER, SMALLINT | Number | Fine within JS safe-integer range |
BIGINT | String | Returned as string to avoid precision loss |
NUMERIC, DECIMAL | String | String preserves exact precision — parse deliberately |
BOOLEAN | Boolean | Direct |
VARCHAR, TEXT | String | Direct |
TIMESTAMP, DATE | Date | A JS Date object |
JSON, JSONB | Object / Array | Already parsed — don't JSON.parse again |
ARRAY | Array | Direct |
💡 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.
| Code | Meaning |
|---|---|
23505 | Unique violation (duplicate email, etc.) |
23503 | Foreign key violation |
23502 | NOT NULL violation |
42P01 | Undefined table |
42703 | Undefined 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. finally → client.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/awaitwithtry/catcharound database calls - Return created/updated rows with
RETURNING *to avoid extra queries - For transactions, check out a client and release it in
finally - Check
rowCountto 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
COUNTorBIGINTis 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
Poolin servers; a bareClientonly for short scripts - Every query is
db.query(text, params)with parameterized$1placeholders - Read results from
rowsandrowCount; useRETURNING *to get changed rows back - Transactions run on one checked-out client with
BEGIN/COMMIT/ROLLBACKand afinallyrelease() - Know the type surprises:
BIGINT/NUMERICcome back as strings; JSONB comes back already parsed - Branch on
err.codeto handle database errors gracefully
📚 Additional Resources
- node-postgres — Queries & parameterization
- node-postgres — Transactions
- PostgreSQL — Error codes appendix (SQLSTATE)
🚀 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.