SQL Is the One Skill Every Developer Quietly Regrets Skipping

TL;DR

SQL is how you actually talk to databases. Most developers think they’re weak at it but actually just never learned it properly. Master SELECT, WHERE, JOIN, and GROUP BY — those four cover 90% of what you’ll do. Then you’ll write faster, cleaner code that doesn’t need constant database optimization.

I once watched a junior dev solve a complex reporting problem by writing a loop in Python that fetched 100,000 rows from the database one at a time, did calculations in Python, then wrote results back. The entire task could’ve been one SQL query that took milliseconds instead of 20 minutes of processing time.

That’s when I realized most developers aren’t bad at SQL — they just never invested in learning it properly. They learned enough to get by, then avoided it. That avoidance costs money and performance. Let me show you why SQL is actually the skill you should’ve been practicing all along.

Why SQL Matters More Than You Think

Here’s the uncomfortable truth: your code is only as fast as your database queries. You can optimize your JavaScript until it’s blazing fast, but if the database is returning 100,000 rows when you need 10, your whole system is slow.

Most performance problems aren’t in application code. They’re in database queries. And most slow queries come from developers who don’t know SQL well enough to write good ones.

SQL is also one of the few skills that transfers across your entire career. You’ll use it in every job. It’s worth learning well.

SELECT: The Foundation

SELECT retrieves data. It’s the foundation of SQL. Master it and everything else makes sense.

SELECT name, email, created_at
FROM users
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 10;

This says: get name, email, and created_at from the users table, but only for active users, sorted newest first, return only 10 rows.

The order matters: FROM (which table), WHERE (which rows), ORDER BY (sort), LIMIT (how many). SQL executes them in that order internally.

A common mistake is forgetting WHERE and then processing everything in your application:

// WRONG: Get all users, filter in code
const users = await db.query('SELECT * FROM users');
const activeUsers = users.filter(u => u.status === 'active');
const recentUsers = activeUsers.slice(0, 10);

That fetches a million rows just to return 10. The database already knows how to filter efficiently. Let it do the work:

// RIGHT: Let the database filter
const users = await db.query(`
  SELECT name, email
  FROM users
  WHERE status = 'active'
  ORDER BY created_at DESC
  LIMIT 10
`);

One query instead of three. Fewer rows transferred. Database does what it’s good at.

Let me show you the queries you’ll actually use. These five patterns cover 80% of what most developers do.

WHERE: Filter Efficiently

WHERE filters rows based on conditions. It’s where you optimize. The earlier you filter, the less data gets processed.

-- Simple comparison
SELECT * FROM orders
WHERE order_date > '2026-01-01';

-- Multiple conditions
SELECT * FROM orders
WHERE status = 'completed' AND amount > 100;

-- OR condition
SELECT * FROM orders
WHERE status = 'cancelled' OR status = 'refunded';

-- IN clause (cleaner than multiple ORs)
SELECT * FROM orders
WHERE status IN ('cancelled', 'refunded', 'pending');

-- Pattern matching
SELECT * FROM users
WHERE email LIKE '%@example.com';

-- Range
SELECT * FROM products
WHERE price BETWEEN 10 AND 100;

-- NULL checks
SELECT * FROM users
WHERE phone IS NOT NULL;

The power of WHERE is that the database uses indexes to find matching rows quickly. If you don’t filter in WHERE, you’re asking the database to check every row. That’s why WHERE conditions should be as specific as possible.

Common mistake: doing calculations in WHERE:

-- SLOW: Database calculates for every row
SELECT * FROM orders
WHERE amount * quantity > 1000;

-- FAST: Compare to constants
SELECT * FROM orders
WHERE amount > 100 AND quantity > 10;

The database can use indexes for the second version. The first requires calculating for every row.

JOIN: Combining Data From Multiple Tables

JOIN combines rows from multiple tables based on a relationship. Instead of fetching from one table, filtering in code, then fetching from another, JOIN gets everything in one query.

-- WRONG: Multiple queries
const users = await db.query('SELECT * FROM users WHERE id = 5');
const orders = await db.query(`SELECT * FROM orders WHERE user_id = ${users[0].id}`);

-- RIGHT: One JOIN query
SELECT u.name, u.email, o.order_id, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.id = 5;

One query is faster, clearer, and uses less network traffic.

There are different types of JOINs:

-- INNER JOIN: Only rows that exist in both tables
SELECT u.name, o.order_id
FROM users u
INNER JOIN orders o ON u.id = o.user_id;

-- LEFT JOIN: All users, even if they have no orders
SELECT u.name, o.order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;

-- Multiple JOINs: Combine three tables
SELECT u.name, o.order_id, p.product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items p ON o.order_id = p.order_id;

INNER JOIN is what you usually want. LEFT JOIN when you need all rows from the left table even if there’s no match on the right.

A JOIN query that returns millions of rows is a bad JOIN query. If you’re not filtering with WHERE, you’re returning too much data. Always add WHERE conditions to narrow results.

GROUP BY: Aggregation and Analysis

GROUP BY combines rows and calculates aggregates. Instead of fetching all rows and grouping in code, GROUP BY does it in the database.

-- Count orders per user
SELECT user_id, COUNT(*) as order_count
FROM orders
GROUP BY user_id;

-- Total revenue per product
SELECT product_id, SUM(amount) as total_revenue
FROM order_items
GROUP BY product_id;

-- Average order amount per customer, but only for completed orders
SELECT user_id, AVG(amount) as avg_order
FROM orders
WHERE status = 'completed'
GROUP BY user_id;

-- Filter grouped results with HAVING
SELECT user_id, COUNT(*) as order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 5; -- Only users with more than 5 orders

GROUP BY is where SQL shows its real power. You can answer complex questions in one query that would take hundreds of lines of code in your application.

Common aggregates: COUNT (how many), SUM (add up), AVG (average), MIN (smallest), MAX (largest).

When you use GROUP BY, you can only select columns that are either in the GROUP BY clause or wrapped in an aggregate function. This makes sense — if you group by user_id, you can only get user_id. But you can get COUNT(*) or SUM(amount).

-- OK: user_id is in GROUP BY, order_count is an aggregate
SELECT user_id, COUNT(*) as order_count
FROM orders
GROUP BY user_id;

-- WRONG: name is not in GROUP BY and not an aggregate
SELECT user_id, name, COUNT(*) as order_count
FROM orders
GROUP BY user_id;

Indexes: Why Your Queries Are Slow

If a query is slow, usually the problem is missing indexes. An index is a data structure that lets the database find rows quickly without checking every single row.

Without an index, finding a user by email means checking every user. With an index on email, the database jumps straight to matching rows.

-- If this query is slow, add an index
SELECT * FROM users WHERE email = 'alice@example.com';

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

-- Now the query is fast
SELECT * FROM users WHERE email = 'alice@example.com';

When to index: columns you filter on frequently (WHERE conditions), columns you join on (JOIN conditions), columns you order by (ORDER BY).

When NOT to index: columns you rarely filter on (you’ll check every row anyway), small tables (the overhead of the index isn’t worth it), or columns with low cardinality (status column with three values — index doesn’t help much).

More indexes means faster queries but slower writes. Every INSERT, UPDATE, or DELETE has to update all the indexes. For read-heavy systems, more indexes help. For write-heavy systems, fewer indexes might be faster overall.

The N+1 Query Problem: The Most Common Mistake

The N+1 problem happens when you fetch one row, then for each row, fetch related data. It’s everywhere and it’s slow.

// N+1: One query to get users, then one per user
const users = await db.query('SELECT id, name FROM users');
for (const user of users) {
  const orders = await db.query(`SELECT * FROM orders WHERE user_id = ${user.id}`);
  user.orders = orders;
}

// If you have 100 users, that's 101 queries total

Solution: use JOIN or fetch related data separately.

// Option 1: JOIN (best when relationships are one-to-one or many-to-one)
const users = await db.query(`
  SELECT u.id, u.name, o.order_id, o.amount
  FROM users u
  LEFT JOIN orders o ON u.id = o.user_id
`);

// Option 2: Separate efficient queries (best when relationships are complex)
const users = await db.query('SELECT id, name FROM users');
const allOrders = await db.query('SELECT * FROM orders WHERE user_id IN (?, ?, ...)');
// Then associate orders with users in code

// Option 3: Use a library that does this for you
const users = await User.find().include('orders');

The N+1 problem is why knowing SQL is important. If you understand databases, you see the inefficiency immediately. If you don’t, you ship code that works but is 100x slower than necessary.

Common Mistakes Beyond N+1

SELECT *. Always specify the columns you need. SELECT * fetches every column, including expensive ones you don’t use.

-- WRONG
SELECT * FROM users;

-- RIGHT
SELECT id, name, email FROM users;

LIKE at the start of a pattern. LIKE ‘%something’ can’t use indexes. LIKE ‘something%’ can. Order matters.

Unnecessary JOINs. Every JOIN adds cost. Make sure you actually need the joined data.

Large result sets without LIMIT. If a query returns a million rows, that’s a lot of data to transfer and process. Usually you want pagination (LIMIT and OFFSET).

Not normalizing data. If you store a comma-separated list of IDs in one column instead of using a proper junction table, you’ll struggle with queries. Proper schema design makes queries cleaner.

When NOT to Use SQL

SQL works great for structured data in relational databases. It’s not ideal for unstructured data, graphs, or highly denormalized schemas.

For document databases like MongoDB, you use a different query language. For search, you use Elasticsearch. For graphs, you use Neo4j.

But for most business applications — user data, orders, products, transactions — relational databases and SQL are the right tool. Learning SQL is learning to use the right tool well.

The Real Payoff

Most developers avoid SQL because they think it’s hard. It’s not. It’s actually simpler than writing equivalent code in Python or JavaScript. The syntax is minimal. The concepts are straightforward.

What happens when you get good at SQL: you write faster queries, debug database issues yourself, understand performance problems, and impress your team. You go from “how do I get this data” to “how do I get this data efficiently.”

Spend time learning SQL well. It’s an investment that pays dividends for decades.

FAQ

Should I use an ORM or write SQL directly?

ORMs (Object-Relational Mapping) can make simple queries easy. But they often hide SQL and generate inefficient queries. Best approach: use an ORM for simple CRUD operations, write SQL directly for complex queries. Know both.

What’s the difference between INNER JOIN and LEFT JOIN?

INNER JOIN returns only rows that match in both tables. LEFT JOIN returns all rows from the left table, with NULL for unmatched right table columns. Use INNER when you need both sides to exist, LEFT when you want everything from the left side regardless of matches.

When should I use UNION vs JOIN?

UNION combines results from multiple queries (stacks rows). JOIN combines columns from multiple tables (adds columns). They solve different problems. Use UNION when you need to combine separate datasets, JOIN when you need related data from multiple tables.

Is SQL the same across all databases?

Most SQL is standard, but each database (PostgreSQL, MySQL, SQL Server, Oracle) has extensions and quirks. The core SELECT, WHERE, JOIN, GROUP BY work everywhere. Learn those first, then database-specific features as needed.

How do I optimize a slow query?

Profile it with EXPLAIN (most databases have this). See which operations are slow. Add indexes to frequently searched columns. Make sure you’re filtering in WHERE and not in code. Avoid N+1 queries. Use GROUP BY and aggregates instead of post-processing results.

What’s a transaction and why should I care?

A transaction is a group of queries that either all succeed or all fail. They prevent inconsistency. If you transfer money between accounts, you need a transaction — either both the debit and credit succeed, or both fail. Don’t leave a system half-updated.

Should I store JSON in a column or use separate tables?

If the data has structure and you’ll query it, use separate tables. If it’s metadata or variable structure, JSON columns are fine. Most databases support JSON querying now, but it’s slower than relational queries.

How much SQL is enough to be good?

If you can write SELECT with WHERE, JOIN, GROUP BY, and aggregate functions, you’re in the top 50% of developers. If you understand indexes and the N+1 problem, you’re in the top 10%. Most jobs only need the first level. Focus there and level up as needed.

Facebook
Twitter
LinkedIn
Pinterest

Leave a Reply

Your email address will not be published. Required fields are marked *

DevelopersCodex

Real-world dev tutorials. No fluff, no filler.

© 2026 DevelopersCodex. All rights reserved.