DEV Community

Alex Chen
Alex Chen

Posted on

SQL for Developers: The Practical Guide (2026)

SQL for Developers: The Practical Guide (2026)

SQL is not scary. It's the most valuable tool in your toolkit. Here's what you actually need to know to be productive.

The Essentials: SELECT

-- Basic query
SELECT name, email FROM users;

-- Select all columns (okay for exploration, avoid in production code)
SELECT * FROM users LIMIT 10;

-- Aliases (make output readable)
SELECT 
  u.name AS user_name,
  u.email,
  COUNT(o.id) AS order_count,
  SUM(o.total) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name, u.email
HAVING COUNT(o.id) > 0
ORDER BY total_spent DESC
LIMIT 20;

-- Filtering with WHERE (the most important clause!)
SELECT * FROM products
WHERE price > 50                          -- Comparison
  AND category = 'electronics'            -- Multiple conditions
  AND (status = 'active' OR status = 'new') -- Grouping with OR
  AND created_at >= '2026-01-01'         -- Date comparison
  AND name LIKE '%wireless%'             -- Pattern matching
  AND id IN (1, 2, 3, 4, 5)              -- List match
  AND description IS NOT NULL             -- NULL check
ORDER BY price DESC;
Enter fullscreen mode Exit fullscreen mode

JOINs: Combining Data from Multiple Tables

-- Visual guide to JOIN types:
/*
   Table A (users)          Table B (orders)
   ┌────┬────────┐        ┌────┬─────────┐
   │ id │  name  │        │ id │ user_id │
   ├────┼────────┤        ├────┼─────────┤
   │  1 │ Alice  │        │  1 │    1    │
   │  2 │ Bob    │        │  2 │    1    │
   │  3 │ Carol  │        │  3 │    2    │
   └────┴────────┘        └────┴─────────┘
*/

-- INNER JOIN: Only matching rows (most common)
-- Result: Alice (2 orders), Bob (1 order). Carol excluded (no orders)
SELECT u.name, o.total 
FROM users u
INNER JOIN orders o ON u.id = o.user_id;

-- LEFT JOIN: All from left + matches from right (NULL if no match)
-- Result: Alice (2), Bob (1), Carol (NULL)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;

-- RIGHT JOIN: All from right + matches from left (rarely used)
-- FULL OUTER JOIN: All rows from both sides (PostgreSQL/MySQL support)

-- Self-Join (hierarchical data: manager → employee)
SELECT 
  e.name AS employee,
  m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

-- Multiple JOINs (real-world query):
SELECT 
  p.name AS product_name,
  c.name AS category_name,
  s.name AS supplier_name,
  COALESCE(SUM(i.quantity), 0) AS stock_quantity
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN suppliers s ON p.supplier_id = s.id
LEFT JOIN inventory i ON p.id = i.product_id
WHERE p.active = true
GROUP BY p.id, p.name, c.name, s.name;
Enter fullscreen mode Exit fullscreen mode

Aggregation & Grouping

-- Basic aggregation functions:
SELECT 
  COUNT(*) AS total_orders,           -- Total rows
  COUNT(DISTINCT user_id) AS buyers, -- Unique values
  SUM(total) AS revenue,             -- Sum (numbers only)
  AVG(total) AS avg_order_value,     -- Average
  MIN(total) AS smallest_order,      -- Minimum
  MAX(total) AS largest_order,       -- Maximum
  STRING_AGG(name, ', ') AS all_names -- PostgreSQL (list aggregation)
FROM orders
WHERE status = 'completed';

-- GROUP BY: Split into groups and aggregate each
SELECT 
  DATE(created_at) AS order_date,
  COUNT(*) AS orders,
  SUM(total) AS daily_revenue
FROM orders
WHERE created_at >= '2026-06-01'
GROUP BY DATE(created_at)
ORDER BY daily_revenue DESC;

-- HAVING: Filter AFTER grouping (WHERE filters BEFORE grouping)
SELECT 
  user_id,
  COUNT(*) AS order_count,
  SUM(total) AS total_spent
FROM orders
GROUP BY user_id
HAVING SUM(total) > 1000       -- Only big spenders
   AND COUNT(*) >= 5            -- Frequent buyers too
ORDER BY total_spent DESC;

-- Window functions (powerful! No GROUP BY needed):
SELECT 
  name,
  department,
  salary,
  RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
  AVG(salary) OVER (PARTITION BY department) AS dept_avg_salary,
  LAG(salary) OVER (ORDER BY hire_date) AS prev_salary,
  ROW_NUMBER() OVER (ORDER BY salary DESC) AS global_rank
FROM employees;
Enter fullscreen mode Exit fullscreen mode

Data Modification

-- INSERT: Add new rows
INSERT INTO users (name, email, role, created_at)
VALUES ('Alice', 'alice@example.com', 'user', NOW()),
       ('Bob', 'bob@example.com', 'user', NOW());

-- INSERT from SELECT (copy data between tables):
INSERT INTO archived_users (user_id, name, email, archived_at)
SELECT id, name, email, NOW()
FROM users
WHERE status = 'inactive'
  AND last_login < NOW() - INTERVAL '1 year';

-- UPDATE: Modify existing rows
UPDATE products
SET price = price * 1.10,          -- 10% price increase
    updated_at = NOW()
WHERE category = 'electronics'
  AND active = true;

-- UPDATE with JOIN (update based on another table's data):
UPDATE orders o
SET status = 'shipped',
    shipped_at = NOW()
FROM shipments s
WHERE o.shipment_id = s.id
  AND s.status = 'delivered';

-- DELETE: Remove rows
DELETE FROM sessions
WHERE expires_at < NOW();

-- DELETE with JOIN (delete based on related data):
DELETE FROM order_items
WHERE order_id IN (
  SELECT id FROM orders WHERE status = 'cancelled' AND created_at < NOW() - INTERVAL '30 days'
);

-- ⚠️ UPSERT: Insert or update if exists (PostgreSQL):
INSERT INTO user_preferences (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON CONFLICT (user_id)                    -- If user_id already exists
DO UPDATE SET theme = EXCLUDED.theme,     -- Update these fields
                language = EXCLUDED.language,
                updated_at = NOW();

-- MySQL equivalent:
INSERT INTO user_preferences (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON DUPLICATE KEY UPDATE theme = VALUES(theme),
                         language = VALUES(language),
                         updated_at = NOW();
Enter fullscreen mode Exit fullscreen mode

Indexing for Performance

-- Before optimizing: Check what's slow!
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
-- Look for "Seq Scan" (bad) vs "Index Scan" (good)

-- Create index on single column:
CREATE INDEX idx_users_email ON users (email);

-- Composite index (order matters!):
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
-- Useful for: WHERE user_id = ? AND status = ?
-- Also useful for: WHERE user_id = ? (uses first column)
-- NOT useful for: WHERE status = ? alone (can't use second column without first)

-- Partial index (index only a subset of rows):
CREATE INDEX idx_active_products_price ON products (price)
WHERE active = true;
-- Smaller index = faster writes + faster reads for common queries

-- Unique index (enforces uniqueness):
CREATE UNIQUE INDEX users_email_unique ON users (email);

-- Covering index (includes all columns needed by query):
CREATE INDEX idx_orders_covering ON orders (user_id, status, total, created_at);
-- Query can be satisfied entirely from the index (no table lookup needed!)

-- Text search index (PostgreSQL):
CREATE INDEX idx_posts_search ON posts USING gin(to_tsvector('english', content));

-- When to index:
 Columns in WHERE clauses frequently
 Columns used in JOIN conditions
 Columns in ORDER BY / GROUP BY on large tables
 Tables with very few rows (< 1000)
 Columns with low selectivity (e.g., boolean with 90% same value)
 Columns that change frequently (high write cost)

-- Find unused indexes (PostgreSQL):
SELECT * FROM pg_stat_user_indexes WHERE idx_scan < 50 ORDER BY idx_scan ASC;
Enter fullscreen mode Exit fullscreen mode

Common Patterns & Gotchas

-- Pagination (correct way — stable ordering required!):
SELECT * FROM posts
WHERE published = true
ORDER BY created_at DESC, id DESC  -- Must include unique tiebreaker!
LIMIT 20 OFFSET 40;               -- Page 3 (offset = (page-1) * limit)

-- Better pagination for large datasets (cursor-based):
SELECT * FROM posts
WHERE published = true
  AND (created_at, id) < ('2026-06-01 12:00:00', 99999)  -- Cursor from last row of previous page
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- NULL handling (tricky!):
-- NULL != NULL in SQL!
SELECT * FROM users WHERE email = NULL;      -- Returns NOTHING (even null emails)!
SELECT * FROM users WHERE email IS NULL;      -- Correct way to find NULLs
SELECT * FROM users WHERE email IS NOT NULL;  -- Find non-NULLs

-- COALESCE: Return first non-NULL value:
SELECT COALESCE(phone, email, 'N/A') AS contact_method FROM users;

-- NULLIF: Return NULL if two values are equal:
SELECT NULLIF(status, 'active') AS deactivated_at FROM users;

-- Date/time operations (PostgreSQL):
SELECT 
  NOW(),                              -- Current timestamp
  CURRENT_DATE,                       -- Today's date
  created_at,
  NOW() - created_at AS age,          -- Interval since creation
  EXTRACT(EPOCH FROM (NOW() - created_at)) AS age_seconds,
  DATE_TRUNC('hour', created_at) AS hour_bucket,
  created_at + INTERVAL '7 days' AS expires_at
FROM users
WHERE created_at >= NOW() - INTERVAL '30 days';

-- Transaction (atomic operations):
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- Both succeed or neither does
-- On error: ROLLBACK;

-- CTE (Common Table Expression) — readable complex queries:
WITH monthly_revenue AS (
  SELECT DATE_TRUNC('month', created_at) AS month,
         SUM(total) AS revenue
  FROM orders
  WHERE status = 'completed'
  GROUP BY DATE_TRUNC('month', created_at)
),
monthly_avg AS (
  SELECT AVG(revenue) AS avg_revenue FROM monthly_revenue
SELECT mr.month, mr.revenue, ma.avg_revenue,
       CASE WHEN mr.revenue > ma.avg_revenue THEN 'above' ELSE 'below' END AS performance
FROM monthly_revenue mr
CROSS JOIN monthly_avg ma
ORDER BY mr.month;
Enter fullscreen mode Exit fullscreen mode

What SQL topic confuses you most? Window functions? JOINs? Performance tuning?

Follow @armorbreak for more practical developer guides.

Top comments (0)