PostgreSQL for Node.js Developers: A Practical Guide

PostgreSQL is the go-to relational database for serious projects. It is ACID-compliant, supports JSON alongside traditional relational data, has one of the richest SQL feature sets of any open-source database, and the community has maintained it reliably for over 25 years. If you are reaching for a database in a Node.js project, Postgres is rarely the wrong choice.
This post covers the essentials: running it locally, core SQL patterns you will use every day, indexing basics, transactions, and connecting from Node.js with the
package.1postgres
Running Postgres Locally
The fastest path is Docker:
1docker run --name pg-dev \ 2 -e POSTGRES_USER=dev \ 3 -e POSTGRES_PASSWORD=secret \ 4 -e POSTGRES_DB=myapp \ 5 -p 5432:5432 \ 6 -d postgres:16 7
Connect with
:1psql
1psql postgres://dev:secret@localhost:5432/myapp 2
Core SQL Patterns
Creating a Table
1CREATE TABLE users ( 2 id SERIAL PRIMARY KEY, 3 email TEXT NOT NULL UNIQUE, 4 name TEXT NOT NULL, 5 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 6); 7
is shorthand for an auto-incrementing integer.1SERIAL
stores timestamps with a time zone, which is almost always what you want over plain1TIMESTAMPTZ
.1TIMESTAMP
Inserting Rows
1INSERT INTO users (email, name) 2VALUES ('alice@example.com', 'Alice') 3RETURNING id, created_at; 4
hands back the generated values immediately — no need for a second query to get the new1RETURNING
.1id
Querying
1-- All users created in the past week 2SELECT id, email, name 3FROM users 4WHERE created_at > NOW() - INTERVAL '7 days' 5ORDER BY created_at DESC; 6
Updating and Deleting
1UPDATE users SET name = 'Alicia' WHERE id = 1 RETURNING *; 2 3DELETE FROM users WHERE id = 1; 4
JOINs
1CREATE TABLE posts ( 2 id SERIAL PRIMARY KEY, 3 user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 4 title TEXT NOT NULL, 5 body TEXT NOT NULL 6); 7 8-- Posts with their author's email 9SELECT p.id, p.title, u.email 10FROM posts p 11JOIN users u ON u.id = p.user_id 12WHERE u.email = 'alice@example.com'; 13
means deleting a user automatically deletes their posts — you declare the relationship once in the schema and the database enforces it everywhere.1ON DELETE CASCADE
Indexes
Postgres creates an index automatically on primary keys and
columns. For any other column you filter or join on frequently, add one explicitly:1UNIQUE
1CREATE INDEX idx_posts_user_id ON posts(user_id); 2
Partial indexes are one of Postgres's killer features — they index only the rows matching a condition, keeping them small and fast:
1-- Only index published posts; unpublished rows are excluded 2CREATE INDEX idx_posts_published ON posts(created_at) 3WHERE published = TRUE; 4
Check whether your queries are actually using indexes with
:1EXPLAIN ANALYZE
1EXPLAIN ANALYZE 2SELECT * FROM posts WHERE user_id = 42; 3
Look for
in the output.1Index Scan
on a large table is a sign you might need an index.1Seq Scan
Transactions
Wrap multiple operations in a transaction so they either all succeed or all roll back together:
1BEGIN; 2 3UPDATE accounts SET balance = balance - 100 WHERE id = 1; 4UPDATE accounts SET balance = balance + 100 WHERE id = 2; 5 6COMMIT; 7
If anything goes wrong between
and1BEGIN
, issue1COMMIT
instead and the database reverts both changes as if neither happened.1ROLLBACK
Connecting from Node.js
Install the
package — a lightweight client that uses tagged template literals:1postgres
1npm install postgres 2
Create a single connection module and re-use it across your app:
1// db.js 2import postgres from 'postgres'; 3 4const sql = postgres(process.env.DATABASE_URL, { 5 max: 10, // connection pool size 6 idle_timeout: 20, // close idle connections after 20 seconds 7}); 8 9export default sql; 10
The tagged template literal syntax automatically parameterizes every interpolated value, so SQL injection is not possible:
1import sql from './db.js'; 2 3export async function getUserByEmail(email) { 4 const [user] = await sql` 5 SELECT id, name, email 6 FROM users 7 WHERE email = ${email} 8 `; 9 return user ?? null; 10} 11 12export async function createPost(userId, title, body) { 13 const [post] = await sql` 14 INSERT INTO posts (user_id, title, body) 15 VALUES (${userId}, ${title}, ${body}) 16 RETURNING * 17 `; 18 return post; 19} 20
Transactions use a callback that receives a scoped
client. If any query inside throws, the whole transaction rolls back automatically:1tx
1export async function transferBalance(fromId, toId, amount) { 2 await sql.begin(async (tx) => { 3 await tx`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${fromId}`; 4 await tx`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${toId}`; 5 }); 6} 7
Environment Variables
Keep connection details out of your source code. A minimal
:1.env
1DATABASE_URL=postgres://dev:secret@localhost:5432/myapp 2
Load it with
in development, or provide it through your hosting platform's secret manager in production. Vercel, Railway, Supabase, and Render all have first-class PostgreSQL support with one-click provisioning.1dotenv
PostgreSQL rewards you the more of it you learn. Once you are comfortable with the patterns here, the next things worth exploring are full-text search with
,1tsvector
columns for semi-structured data, window functions, and1JSONB
for bulk data imports.1COPY
