DEV Community

Alex Chen
Alex Chen

Posted on

SQL Basics Every Developer Should Know (2026)

SQL Basics Every Developer Should Know (2026)

You don't need to be a DBA, but you do need to write queries that don't suck.

Why SQL Still Matters in 2026

ORMs are great for CRUD.
But when things get complex:
→ ORM generates terrible queries
→ You need joins, aggregations, window functions
→ Performance debugging requires reading actual SQL
→ The database is usually your bottleneck

Learn SQL once, use it everywhere.
Every "NoSQL" database eventually adds SQL-like querying.
Enter fullscreen mode Exit fullscreen mode

The Essential Queries

SELECT: Read Data

-- Basic
SELECT * FROM users;

-- Specific columns (always prefer this over *)
SELECT id, email, name FROM users;

-- With alias
SELECT 
  u.id AS user_id,
  u.email,
  COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
Enter fullscreen mode Exit fullscreen mode

WHERE: Filter Data

-- Basic conditions
SELECT * FROM users WHERE active = true;
SELECT * FROM products WHERE price > 100;

-- Multiple conditions
SELECT * FROM orders 
WHERE status = 'completed' 
  AND created_at >= '2026-01-01'
  AND total_amount > 50;

-- Pattern matching
SELECT * FROM users WHERE email LIKE '%@gmail.com';
SELECT * FROM users WHERE name LIKE 'J%';       -- Starts with J
SELECT * FROM users WHERE phone LIKE '555-____'; -- Wildcard for digits

-- IN clause (cleaner than multiple OR)
SELECT * FROM users WHERE role IN ('admin', 'moderator');

-- NULL handling (⚠️ NULL != NULL)
SELECT * FROM users WHERE email IS NOT NULL;
SELECT * FROM products WHERE description IS NULL OR description = '';

-- BETWEEN (inclusive)
SELECT * FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-03-31';
Enter fullscreen mode Exit fullscreen mode

JOINs: Combine Tables

-- INNER JOIN: Only matching rows
SELECT u.name, o.total_amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;

-- LEFT JOIN: All from left, matching from right (NULL if no match)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;

-- Multiple JOINs
SELECT 
  u.name,
  p.name AS product_name,
  oi.quantity,
  oi.unit_price
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE u.id = 123;
Enter fullscreen mode Exit fullscreen mode

ORDER BY + LIMIT: Sort & Paginate

-- Basic sorting
SELECT * FROM products ORDER BY price ASC;
SELECT * FROM products ORDER BY created_at DESC;

-- Multi-column sort
SELECT * FROM users ORDER BY last_login DESC, name ASC;

-- Pagination (crucial!)
-- Page 1 (items 1-20):
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 0;

-- Page 2 (items 21-40):
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 20;

-- Page N:
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET ((N - 1) * 20);
Enter fullscreen mode Exit fullscreen mode

Aggregations & Grouping

-- Basic aggregates
SELECT 
  COUNT(*) AS total_orders,
  SUM(total_amount) AS revenue,
  AVG(total_amount) AS avg_order_value,
  MIN(total_amount) AS min_order,
  MAX(total_amount) AS max_order
FROM orders 
WHERE status = 'completed';

-- GROUP BY with HAVING
SELECT 
  status,
  COUNT(*) AS count,
  SUM(total_amount) AS revenue
FROM orders
GROUP BY status
HAVING COUNT(*) > 10;  -- Filter AFTER grouping (unlike WHERE)

-- Common pattern: Top N per category
SELECT 
  category,
  product_name,
  sales_count
FROM (
  SELECT 
    c.name AS category,
    p.name AS product_name,
    SUM(oi.quantity) AS sales_count,
    ROW_NUMBER() OVER (PARTITION BY c.id ORDER BY SUM(oi.quantity) DESC) AS rank
  FROM categories c
  JOIN products p ON c.id = p.category_id
  JOIN order_items oi ON p.id = oi.product_id
  GROUP BY c.id, p.id
) ranked
WHERE rank <= 3;  -- Top 3 products per category
Enter fullscreen mode Exit fullscreen mode

INSERT, UPDATE, DELETE

-- Insert single row
INSERT INTO users (email, name, role) VALUES ('test@example.com', 'Test User', 'user');

-- Insert multiple rows (faster than individual inserts)
INSERT INTO products (name, price, category_id) VALUES
  ('Widget A', 29.99, 1),
  ('Widget B', 49.99, 1),
  ('Gadget C', 99.99, 2);

-- Upsert (insert or update if exists)
INSERT INTO user_settings (user_id, theme, notifications)
VALUES (123, 'dark', true)
ON CONFLICT (user_id) DO UPDATE SET
  theme = EXCLUDED.theme,
  notifications = EXCLUDED.notifications;

-- Update
UPDATE products SET price = 34.99 WHERE id = 42;

-- Conditional update
UPDATE users SET 
  login_count = login_count + 1,
  last_login = NOW()
WHERE id = 123;

-- Delete (use with caution!)
DELETE FROM sessions WHERE expires_at < NOW();

-- Soft delete (preferred in production)
UPDATE users SET deleted_at = NOW(), active = false WHERE id = 123;
Enter fullscreen mode Exit fullscreen mode

Subqueries vs CTEs

-- Subquery (harder to read)
SELECT * FROM products 
WHERE price > (SELECT AVG(price) FROM products);

-- CTE — same thing, much clearer
WITH avg_price AS (
  SELECT AVG(price) AS value FROM products
)
SELECT * FROM products, avg_price
WHERE products.price > avg_price.value;

-- Complex example using CTEs
WITH monthly_revenue AS (
  SELECT 
    DATE_TRUNC('month', created_at) AS month,
    SUM(total_amount) AS revenue
  FROM orders
  WHERE status = 'completed'
  GROUP BY month
),
monthly_target AS (
  SELECT 
    month,
    LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
    revenue - LAG(revenue) OVER (ORDER BY month) AS growth
  FROM monthly_revenue
)
SELECT 
  month::text,
  revenue,
  prev_month_revenue,
  ROUND((growth / prev_month_revenue * 100), 1) || '%' AS growth_pct
FROM monthly_target
ORDER BY month;
Enter fullscreen mode Exit fullscreen mode

Indexing Essentials

-- Create index (basic)
CREATE INDEX idx_users_email ON users(email);

-- Composite index (order matters!)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);

-- Unique index (also enforces uniqueness)
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);

-- Partial index (index only a subset of rows)
CREATE INDEX idx_active_users ON users(id) WHERE active = true;

-- Check what indexes exist
\di table_name   -- In psql
-- Or:
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'users';

-- When to add an index:
-- → WHERE clause columns used frequently
-- → JOIN columns (foreign keys)
-- → Columns in ORDER BY combined with WHERE
-- → Columns with high cardinality (many unique values)

-- When NOT to add an index:
-- → Small tables (< 100 rows)
-- → Columns rarely queried
-- → Columns with low cardinality (e.g., boolean flags)
-- → Tables that are written much more than read
Enter fullscreen mode Exit fullscreen mode

Performance Anti-Patterns

-- ❌ SELECT * (wastes bandwidth, prevents index-only scans)
SELECT * FROM users WHERE email = 'x';

-- ✅ Select only needed columns
SELECT id, name, email FROM users WHERE email = 'x';

-- ❌ N+1 problem (1 query for list + N queries for each item)
-- First query gets 100 users
-- Then loop: SELECT * FROM orders WHERE user_id = ? (100 more queries!)

-- ✅ Use JOIN instead
SELECT u.*, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;

-- ❌ Missing WHERE clause on large tables
SELECT COUNT(*) FROM events; -- Scans millions of rows

-- ✅ Always filter
SELECT COUNT(*) FROM events WHERE created_at >= NOW() - INTERVAL '30 days';

-- ❌ LIKE '%term' (can't use index — leading wildcard)
SELECT * FROM products WHERE name '%widget';

-- ✅ Use full-text search or trigram index instead
SELECT * FROM products WHERE name ILIKE 'widget%'; -- Leading literal can use index

-- ❌ ORDER BY RAND() (slow on large tables)
SELECT * FROM products ORDER BY RANDOM() LIMIT 5;

-- ✅ Alternative approaches
-- For sequential IDs: random ID range then LIMIT
SELECT * FROM products 
WHERE id >= (RANDOM() * (SELECT MAX(id) FROM products))
LIMIT 5;

-- ❌ No LIMIT on unbounded queries
SELECT * FROM logs; -- Returns ALL rows

-- ✅ Always paginate
SELECT * FROM logs ORDER BY created_at DESC LIMIT 100 OFFSET 0;
Enter fullscreen mode Exit fullscreen mode

Transactions

BEGIN;

-- Transfer money between accounts
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

INSERT INTO transactions (from_account, to_account, amount)
VALUES (1, 2, 100);

COMMIT;  -- Both updates apply atomically

-- If something goes wrong:
ROLLBACK;  -- Everything reverts
Enter fullscreen mode Exit fullscreen mode

In Node.js:

const client = await pool.connect();
try {
  await client.query('BEGIN');

  await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, fromId]);
  await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, toId]);

  await client.query('COMMIT');
} catch (err) {
  await client.query('ROLLBACK');
  throw err;
} finally {
  client.release();
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Task Syntax
Get all SELECT * FROM t
Filter WHERE col = val
Pattern match WHERE col LIKE '%term'
Combine tables FROM a JOIN b ON a.id = b.a_id
Group & aggregate GROUP BY col HAVING condition
Sort ORDER BY col DESC
Paginate LIMIT n OFFSET m
Insert row INSERT INTO t (cols) VALUES (vals)
Update UPDATE t SET col = val WHERE cond
Delete DELETE FROM t WHERE cond
Aggregate functions COUNT(), SUM(), AVG(), MIN(), MAX()
Combine results UNION (dedupes) / UNION ALL (keeps all)
Check existence EXISTS (subquery) or IN (...)
Null coalesce COALESCE(col, default)

What's your SQL level? Comfortable with basics or diving into query optimization?

Follow @armorbreak for more practical developer guides.

Top comments (0)