A query can return the right answer and still be wrong for production. These SQL anti-patterns stop the database using indexes, multiply rows by accident or flood the network, and "what's wrong with this query?" is a favourite senior interview format. Each one below shows the bad version, why it hurts and the fix.
Spotting SQL Anti-Patterns
Interviewers often show you a broken or slow query and ask: "What is wrong with this?"
This tests deep SQL understanding beyond just syntax.
This section covers the most common anti-patterns with before/after fixes.
Non-Sargable Predicates (Index-Killer Queries)
SARGABLE = Search ARGument ABLE — a condition that can USE an index.
Non-sargable predicates wrap the indexed column in a function or calculation,
making the index unusable. The database falls back to a full table scan.
Anti-Pattern 1: Function on indexed column in WHERE
-- ❌ WRONG: YEAR() wraps hire_date — cannot use index on hire_date
SELECT * FROM employees WHERE YEAR(hire_date) = 2023;
-- ✅ RIGHT: Range condition uses index
SELECT * FROM employees
WHERE hire_date >= '2023-01-01' AND hire_date < '2024-01-01';
-- ❌ WRONG: arithmetic on indexed column
SELECT * FROM products WHERE price * 1.18 > 1000;
-- ✅ RIGHT: move calculation to the other side
SELECT * FROM products WHERE price > 1000 / 1.18;
-- ❌ WRONG: UPPER() on indexed email column
SELECT * FROM users WHERE UPPER(email) = 'NAVEED@EXAMPLE.COM';
-- ✅ RIGHT: use case-insensitive collation or store normalised
SELECT * FROM users WHERE email = 'naveed@example.com';
-- Or: create functional index: CREATE INDEX idx_upper_email ON users ((UPPER(email)));
Anti-Pattern 2: Leading wildcard in LIKE
-- ❌ WRONG: Leading % forces full table scan
SELECT * FROM employees WHERE name LIKE '%Kumar';
-- ✅ BETTER: trailing wildcard uses index
SELECT * FROM employees WHERE name LIKE 'Kumar%';
-- ✅ BEST: If you need substring search, use FULL-TEXT index
ALTER TABLE employees ADD FULLTEXT INDEX ft_name (name);
SELECT * FROM employees WHERE MATCH(name) AGAINST ('Kumar' IN NATURAL LANGUAGE MODE);
Anti-Pattern 3: Implicit Type Conversion
-- ❌ WRONG: emp_id is INT but we compare to a STRING
SELECT * FROM employees WHERE emp_id = '42';
-- MySQL silently converts '42' to 42 — but if column is VARCHAR:
-- The string column gets cast to INT for comparison → full scan!
-- ✅ RIGHT: match types explicitly
SELECT * FROM employees WHERE emp_id = 42;
-- ❌ WRONG: date string without quotes
SELECT * FROM orders WHERE order_date = 2025-01-15;
-- MySQL reads this as 2025 - 1 - 15 = 2009! (arithmetic, not a date)
-- ✅ RIGHT: quote date strings
SELECT * FROM orders WHERE order_date = '2025-01-15';
The N+1 Query Problem
N+1 happens when code runs 1 query to get N rows, then runs N MORE queries (one per row).
Example: Get 100 customers (1 query), then for each customer get their orders (100 queries).
Total: 101 queries instead of 1 JOIN. This is a catastrophic performance bug.
Common in ORMs (Hibernate, ActiveRecord) when lazy loading is misconfigured.
-- ❌ N+1 PROBLEM (pseudocode showing what bad application code does):
-- Query 1: SELECT customer_id, name FROM customers LIMIT 100;
-- Then for each of the 100 customers, run separately:
-- Query 2: SELECT * FROM orders WHERE customer_id = 1;
-- Query 3: SELECT * FROM orders WHERE customer_id = 2;
-- ... 100 more queries! Total: 101 database round trips
-- ✅ FIX: One JOIN instead of 101 queries
SELECT c.customer_id, c.name,
COUNT(o.order_id) AS order_count,
SUM(o.amount) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name;
-- ✅ Alternative fix: IN clause (if ORM forces it)
SELECT * FROM orders
WHERE customer_id IN (1, 2, 3, ... 100); -- one query with 100 IDs
Cartesian Product Explosion
-- ❌ WRONG: Missing JOIN condition creates cartesian product
SELECT e.name, d.dept_name
FROM employees e, departments d;
-- If 100 employees × 10 departments = 1000 rows! (not 100)
-- ✅ RIGHT: Always specify JOIN condition
SELECT e.name, d.dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.dept_id;
-- ❌ WRONG: CROSS JOIN on large tables accidentally
SELECT * FROM fact_sales CROSS JOIN dim_date;
-- 1M sales × 3650 dates = 3.65 BILLION rows!
SELECT * in Production
-- ❌ WRONG: SELECT * problems:
-- 1. Fetches unused columns → more network traffic
-- 2. Cannot use covering index
-- 3. Breaks if schema changes (new column appears in unexpected position)
-- 4. In JOINs, ambiguous column names can cause errors
SELECT * FROM employees e JOIN departments d ON e.dept_id = d.dept_id;
-- Now which table does "name" come from? Ambiguous!
-- ✅ RIGHT: Specify exactly what you need
SELECT e.emp_id, e.name, e.salary, d.dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.dept_id;
Reading Query Execution Plans (EXPLAIN)
-- MySQL: EXPLAIN shows how a query will be executed
EXPLAIN SELECT * FROM employees WHERE city = 'Bangalore';
-- Look for "type" column:
-- ALL = full table scan (bad!)
-- index = full index scan (ok)
-- range = index range scan (good)
-- ref = index lookup (good)
-- const = single row lookup (best!)
-- EXPLAIN ANALYZE (PostgreSQL) - actually runs the query and shows real costs
EXPLAIN ANALYZE SELECT * FROM employees WHERE city = 'Bangalore';
-- MySQL: EXPLAIN FORMAT=JSON for detailed output
EXPLAIN FORMAT=JSON SELECT e.name, d.dept_name
FROM employees e JOIN departments d ON e.dept_id = d.dept_id;
FAQs
What does sargable mean?
Search-ARGument-ABLE: a predicate the database can answer using an index, such as order_date >= '2025-01-01'. Wrapping the column in a function, like YEAR(order_date) = 2025, usually prevents index use.
What is the N+1 query problem?
Running one query to fetch a list and then one more query per item. Replace it with a single JOIN or an IN query that fetches all related rows at once.
Why avoid SELECT * in production code?
It reads and sends columns you don't need, prevents covering-index plans, and breaks code silently when columns are added or reordered.