Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
22 minLesson 33 of 40
Databases

PostgreSQL Fundamentals

PostgreSQL Fundamentals

PostgreSQL is the go-to relational database for production web applications. It's fast, reliable, ACID-compliant, and supports advanced features like JSON columns, full-text search, and window functions. Understanding SQL is a foundational skill for any backend developer.

Setup

macOS:

brew install postgresql@16
brew services start postgresql@16

Windows: Download the installer from postgresql.org or use WSL2 with Ubuntu.

Docker (recommended for development):

# Start a PostgreSQL container
docker run --name postgres-dev \
    -e POSTGRES_PASSWORD=password \
    -e POSTGRES_DB=myapp \
    -p 5432:5432 \
    -d postgres:16

Connect to your database:

psql -h localhost -U postgres -d myapp
# or with docker:
docker exec -it postgres-dev psql -U postgres -d myapp

Creating Tables

-- Create a users table
CREATE TABLE users (
    id          SERIAL PRIMARY KEY,
    email       VARCHAR(255) UNIQUE NOT NULL,
    name        VARCHAR(255) NOT NULL,
    password    VARCHAR(255) NOT NULL,
    role        VARCHAR(50) DEFAULT 'user',
    created_at  TIMESTAMP DEFAULT NOW(),
    updated_at  TIMESTAMP DEFAULT NOW()
);

-- Create a courses table
CREATE TABLE courses (
    id          SERIAL PRIMARY KEY,
    slug        VARCHAR(255) UNIQUE NOT NULL,
    title       VARCHAR(500) NOT NULL,
    description TEXT,
    price       DECIMAL(10, 2) DEFAULT 0,
    published   BOOLEAN DEFAULT false,
    author_id   INTEGER REFERENCES users(id) ON DELETE SET NULL,
    created_at  TIMESTAMP DEFAULT NOW()
);

-- Create an enrollments junction table (many-to-many)
CREATE TABLE enrollments (
    id          SERIAL PRIMARY KEY,
    user_id     INTEGER REFERENCES users(id) ON DELETE CASCADE,
    course_id   INTEGER REFERENCES courses(id) ON DELETE CASCADE,
    enrolled_at TIMESTAMP DEFAULT NOW(),
    completed   BOOLEAN DEFAULT false,
    UNIQUE(user_id, course_id)    -- prevent duplicate enrollments
);

Basic CRUD

INSERT

-- Insert a single row
INSERT INTO users (email, name, password)
VALUES ('alice@example.com', 'Alice Johnson', 'hashed_password_here');

-- Insert and return the created record
INSERT INTO users (email, name, password)
VALUES ('bob@example.com', 'Bob Smith', 'hashed_password')
RETURNING id, email, name, created_at;

-- Insert multiple rows
INSERT INTO courses (slug, title, price) VALUES
    ('react-complete', 'React Complete Course', 49.00),
    ('python-2026', 'Python Complete 2026', 39.00),
    ('javascript', 'JavaScript Complete', 44.00);

SELECT

-- Get all users
SELECT * FROM users;

-- Select specific columns
SELECT id, email, name, created_at FROM users;

-- Filter with WHERE
SELECT * FROM users WHERE role = 'admin';
SELECT * FROM courses WHERE published = true AND price < 50;

-- Pattern matching
SELECT * FROM users WHERE email LIKE '%@gmail.com';
SELECT * FROM courses WHERE title ILIKE '%react%';  -- case insensitive

-- NULL checks
SELECT * FROM courses WHERE author_id IS NOT NULL;

-- Sort
SELECT * FROM courses ORDER BY price ASC, created_at DESC;

-- Limit and offset (pagination)
SELECT * FROM courses ORDER BY created_at DESC LIMIT 20 OFFSET 40;

-- Aggregate functions
SELECT COUNT(*) FROM users;
SELECT AVG(price), MIN(price), MAX(price) FROM courses;
SELECT role, COUNT(*) AS count FROM users GROUP BY role;

UPDATE

-- Update a specific row
UPDATE users SET name = 'Alice Williams' WHERE id = 1;

-- Update with RETURNING to get the updated record
UPDATE courses
SET published = true, updated_at = NOW()
WHERE id = 5
RETURNING *;

-- Update based on a condition
UPDATE enrollments SET completed = true WHERE user_id = 1 AND course_id = 3;

DELETE

-- Delete a row
DELETE FROM users WHERE id = 1;

-- Delete with condition
DELETE FROM enrollments WHERE enrolled_at < NOW() - INTERVAL '1 year';

-- Delete and return deleted rows
DELETE FROM courses WHERE published = false RETURNING id, title;

Joins

Joins combine data from multiple tables. This is where the "relational" in relational databases pays off:

-- INNER JOIN — only rows matching in both tables
SELECT 
    u.name,
    u.email,
    c.title AS course_title,
    e.enrolled_at
FROM enrollments e
INNER JOIN users u ON e.user_id = u.id
INNER JOIN courses c ON e.course_id = c.id
ORDER BY e.enrolled_at DESC;

-- LEFT JOIN — all users, even those with no enrollments
SELECT 
    u.name,
    COUNT(e.id) AS enrollment_count
FROM users u
LEFT JOIN enrollments e ON u.id = e.user_id
GROUP BY u.id, u.name
ORDER BY enrollment_count DESC;

-- Get a course with its author details
SELECT 
    c.title,
    c.price,
    c.slug,
    u.name AS author_name,
    u.email AS author_email
FROM courses c
LEFT JOIN users u ON c.author_id = u.id
WHERE c.slug = 'react-complete';

Subqueries and CTEs

-- Subquery: users who have at least one enrollment
SELECT * FROM users
WHERE id IN (SELECT DISTINCT user_id FROM enrollments);

-- CTE (Common Table Expression) — cleaner complex queries
WITH active_users AS (
    SELECT DISTINCT user_id FROM enrollments
    WHERE enrolled_at > NOW() - INTERVAL '30 days'
),
course_stats AS (
    SELECT course_id, COUNT(*) AS student_count
    FROM enrollments
    GROUP BY course_id
)
SELECT 
    c.title,
    cs.student_count
FROM courses c
JOIN course_stats cs ON c.id = cs.course_id
WHERE cs.student_count > 10
ORDER BY cs.student_count DESC;

Indexes

Indexes dramatically speed up queries on large tables. Without them, every query does a full table scan:

-- Index on a frequently searched column
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_courses_slug ON courses(slug);
CREATE INDEX idx_enrollments_user_id ON enrollments(user_id);
CREATE INDEX idx_enrollments_course_id ON enrollments(course_id);

-- Composite index for queries that filter on multiple columns
CREATE INDEX idx_courses_published_price ON courses(published, price);

-- See query plan (use EXPLAIN ANALYZE before a slow query)
EXPLAIN ANALYZE SELECT * FROM courses WHERE slug = 'react-complete';

Rule of thumb: index any column you WHERE, JOIN, or ORDER BY frequently. Don't index everything — indexes speed up reads but slow down writes.

Transactions

Transactions group multiple operations — either all succeed or all fail:

BEGIN;

INSERT INTO enrollments (user_id, course_id) VALUES (1, 5);
UPDATE users SET enrollment_count = enrollment_count + 1 WHERE id = 1;
UPDATE courses SET student_count = student_count + 1 WHERE id = 5;

COMMIT;

-- If anything goes wrong:
ROLLBACK;

In Node.js with pg:

import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function enrollUser(userId: number, courseId: number) {
    const client = await pool.connect();
    
    try {
        await client.query("BEGIN");
        
        await client.query(
            "INSERT INTO enrollments (user_id, course_id) VALUES ($1, $2)",
            [userId, courseId]
        );
        
        await client.query(
            "UPDATE users SET enrollment_count = enrollment_count + 1 WHERE id = $1",
            [userId]
        );
        
        await client.query("COMMIT");
    } catch (error) {
        await client.query("ROLLBACK");
        throw error;
    } finally {
        client.release();
    }
}

JSON Columns

PostgreSQL handles JSON natively — useful for flexible data like settings, metadata, or varying attributes:

-- Create a table with a JSON column
CREATE TABLE products (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(255),
    metadata JSONB    -- JSONB is binary JSON, faster and indexable
);

-- Insert with JSON
INSERT INTO products (name, metadata)
VALUES ('React Course', '{"level": "beginner", "tags": ["react", "hooks"], "rating": 4.8}');

-- Query inside JSON
SELECT name, metadata->>'level' AS level FROM products;
SELECT * FROM products WHERE metadata->>'level' = 'beginner';
SELECT * FROM products WHERE metadata @> '{"tags": ["react"]}';

Connecting from Node.js

npm install pg
npm install -D @types/pg
import { Pool } from "pg";

const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    // or
    host: process.env.DB_HOST,
    port: 5432,
    database: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    max: 20,   // connection pool size
});

// Query with parameters (always use parameterized queries to prevent SQL injection)
const result = await pool.query(
    "SELECT * FROM courses WHERE slug = $1",
    [slug]
);

const course = result.rows[0];

// Always use $1, $2, $3 placeholders — never string interpolation
// ❌ NEVER: `SELECT * FROM courses WHERE slug = '${slug}'`  — SQL injection!
// ✅ ALWAYS: "SELECT * FROM courses WHERE slug = $1", [slug]

Next lesson: Prisma ORM — a type-safe way to work with your database in TypeScript.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!