Skip to content

PostgreSQL CRUD Operations

SELECT , INSERT , UPDATE , DELETE Four operations. Everything else is just syntax sugar and optimization. Master these and you can build any data layer - fail at them and no amount of ORM abstraction will save you from writing queries that make your DBA cry

SELECT - the one you'll write 90% of the time

-- Basic select all (please don't do this in production with large tables)
SELECT * FROM app.users;

-- Select specific columns - always prefer this over SELECT *
SELECT id, email, username, created_at FROM app.users;

-- Filtering with WHERE
SELECT id, email, username, role
FROM app.users
WHERE is_active = TRUE
  AND created_at >= '2024-01-01'
  AND role IN ('admin', 'moderator');

-- ORDER BY and LIMIT - pagination basics
SELECT id, email, username
FROM app.users
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
-- OFFSET-based pagination gets slow at high offsets
-- Use cursor-based pagination for production at scale

-- LIKE and pattern matching (case-sensitive)
SELECT * FROM app.users
WHERE email LIKE '%@example.com';

-- ILIKE - case-insensitive LIKE
SELECT * FROM app.users
WHERE username ILIKE 'admin%';  -- Matches admin, Admin, ADMIN

-- DISTINCT - unique values only
SELECT DISTINCT role FROM app.users;

INSERT - putting data in

-- Single row insert with RETURNING (saves a second query)
INSERT INTO app.users (email, username, password_hash, role)
VALUES ('omar@example.com', 'omar_hacker', '$2b$12$LJ3m...', 'user')
RETURNING id, created_at;

-- Multiple rows in one statement (much faster than individual inserts)
INSERT INTO app.users (email, username, password_hash, role)
VALUES
    ('ali@example.com', 'ali_sec', '$2b$12$ABC...', 'user'),
    ('khaled@example.com', 'khaled_pwn', '$2b$12$DEF...', 'admin'),
    ('mohsen@example.com', 'mohsen_dev', '$2b$12$GHI...', 'user')
RETURNING id, email;

-- Insert from SELECT - copy data between tables
INSERT INTO app.archived_users (user_id, email, archived_at)
SELECT id, email, NOW()
FROM app.users
WHERE is_active = FALSE
  AND updated_at < NOW() - INTERVAL '1 year';

UPDATE - mutate with caution

-- Basic update
UPDATE app.users
SET last_login = NOW()
WHERE id = '123e4567-e89b-12d3-a456-426614174000';

-- Update multiple columns
UPDATE app.users
SET
    username = 'new_handle',
    updated_at = NOW()
WHERE id = '123e4567-e89b-12d3-a456-426614174000';

-- Conditional update with CASE
UPDATE app.orders
SET status = CASE
    WHEN status = 'pending' AND paid_at IS NOT NULL THEN 'confirmed'
    WHEN status = 'confirmed' AND shipped_at IS NOT NULL THEN 'shipped'
    ELSE status  -- Keep current status if no condition matches
    END,
    updated_at = NOW()
WHERE id = 'order-uuid-here';

-- Always use WHERE in UPDATE
-- Running UPDATE without WHERE updates ALL rows
-- This is how production gets destroyed at 3AM on a Friday
-- "I forgot the WHERE clause" - every developer who's ever worked with databases

DELETE - the dangerous one

-- Delete specific row
DELETE FROM app.users
WHERE id = '123e4567-e89b-12d3-a456-426614174000';

-- Delete with RETURNING - see what you deleted (audit purposes)
DELETE FROM app.sessions
WHERE expires_at < NOW()
RETURNING id, user_id, created_at;

-- TRUNCATE vs DELETE - know the difference
-- TRUNCATE removes ALL rows , resets sequences , can't be filtered
-- DELETE removes rows individually , can be filtered , can be rolled back
-- TRUNCATE is faster but more dangerous

TRUNCATE TABLE app.temp_logs;  -- Nuke the whole table
DELETE FROM app.temp_logs WHERE logged_at < NOW() - INTERVAL '30 days';  -- Selective

WHERE clause - filtering finesse

-- Multiple conditions with AND/OR
SELECT * FROM app.orders
WHERE total_amount > 100
  AND (status = 'pending' OR status = 'confirmed')
  AND user_id IN (SELECT id FROM app.users WHERE is_active = TRUE);

-- BETWEEN for ranges
SELECT * FROM app.orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'
  AND total_amount BETWEEN 50 AND 500;

-- IS NULL / IS NOT NULL (never use = NULL because NULL = NULL is... not true)
SELECT * FROM app.users
WHERE last_login IS NULL  -- Users who've never logged in ... probably abandoned accounts
   OR email IS NOT NULL;  -- Should be all of them

-- EXISTS - more efficient than IN for large subqueries
SELECT * FROM app.users u
WHERE EXISTS (
    SELECT 1 FROM app.orders o
    WHERE o.user_id = u.id
      AND o.total_amount > 1000
);

JOINs - where Postgres earns its keep

-- INNER JOIN - only matching rows from both tables
SELECT u.email, o.id AS order_id, o.total_amount, o.created_at
FROM app.users u
INNER JOIN app.orders o ON o.user_id = u.id
WHERE u.is_active = TRUE;

-- LEFT JOIN - all rows from left , NULLs where right doesn't match
SELECT u.email, COUNT(o.id) AS order_count
FROM app.users u
LEFT JOIN app.orders o ON o.user_id = u.id
GROUP BY u.id, u.email;

-- This is how you find users who never ordered (churn analysis)
SELECT u.email, u.created_at
FROM app.users u
LEFT JOIN app.orders o ON o.user_id = u.id
WHERE o.id IS NULL;

-- JOIN with multiple conditions
SELECT u.email, o.id AS order_id, p.name AS product_name, oi.quantity, oi.price
FROM app.users u
JOIN app.orders o ON o.user_id = u.id
JOIN app.order_items oi ON oi.order_id = o.id
JOIN app.products p ON p.id = oi.product_id
WHERE o.status = 'delivered'
ORDER BY o.created_at DESC
LIMIT 100;

GROUP BY and aggregation

-- Count orders per user
SELECT user_id, COUNT(*) AS order_count, SUM(total_amount) AS total_spent
FROM app.orders
GROUP BY user_id
ORDER BY total_spent DESC;

-- HAVING - filter after aggregation (WHERE filters before)
SELECT user_id, COUNT(*) AS order_count, SUM(total_amount) AS total_spent
FROM app.orders
WHERE status != 'cancelled'  -- WHERE runs before GROUP BY
GROUP BY user_id
HAVING SUM(total_amount) > 500  -- HAVING runs after GROUP BY
ORDER BY total_spent DESC;

-- GROUP BY with multiple columns
SELECT
    EXTRACT(YEAR FROM created_at) AS year,
    status,
    COUNT(*) AS count,
    SUM(total_amount) AS revenue
FROM app.orders
GROUP BY year, status
ORDER BY year, status;

SQL injection - why we parameterize

// NEVER do this - string concatenation is how databases get owned
// Attacker sets username to: '; DROP TABLE users; --
const query = `SELECT * FROM users WHERE username = '${req.body.username}'`;

// ALWAYS do this - parameterized queries
const query = 'SELECT * FROM users WHERE username = $1';
const result = await client.query(query, [req.body.username]);

The first query is how Ali from the forums steals your entire user table in one request. $1 is a parameter placeholder that Postgres handles separately from the SQL syntax - the input can contain quotes, semicolons, or SQL keywords and it will be treated as a literal string value, never as executable SQL

// Node.js pg driver - parameterized INSERT
const insertUser = `
    INSERT INTO app.users (email, username, password_hash, role)
    VALUES ($1, $2, $3, $4)
    RETURNING id, created_at
`;
const values = [email, username, hash, role];
const result = await pool.query(insertUser, values);

prerequisites

db_01_postgres_intro.md - you need Postgres running with a database and a user that can create tables and query them


next → db_03_postgres_indexes.md