MySQL Queries & Subqueries Guide
50 in-depth MySQL interview questions focused on queries and subqueries, with easy-to-understand explanations, comparison tables, visual diagrams and real SQL, covering data types, filtering, joins, subqueries, CTEs, aggregates, views, indexing and optimization. Tick each question off as you master it.
MySQL Basics, Data Types & Constraints
Logical Order MySQL Actually Evaluates a Query In
SQL is written SELECT-first, but MySQL evaluates it in roughly this order internally. This is exactly why you can't reference a SELECT alias in WHERE, but you often can in ORDER BY or HAVING — those clauses run after SELECT has already computed its output columns.
001What is MySQL, and how does a query actually get executed inside it?Beginner+
Easy explanation
MySQL is an open-source relational database management system that stores data in tables and lets you query it using SQL (Structured Query Language). When you send a query, MySQL doesn't execute it in the order you typed it — it parses it, the optimizer picks an execution plan, and only then does the storage engine actually fetch and process the rows.
Understanding the LOGICAL order of evaluation (FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT) rather than the WRITTEN order (SELECT ... FROM ... WHERE ...) explains a lot of behavior that otherwise seems confusing — like why a column alias defined in SELECT can't be used in WHERE, but can often be used in ORDER BY.
SELECT name, salary * 12 AS annual_salary
FROM employees
WHERE department = 'Sales' -- can't use 'annual_salary' here, it doesn't exist yet
ORDER BY annual_salary DESC; -- CAN use it here, SELECT has already run
002What are the main MySQL data types, and how do you choose between them?Beginner+
Easy explanation
Numeric types include INT (whole numbers), DECIMAL/NUMERIC (exact fixed-point numbers — essential for money, since floating point can introduce rounding errors), and FLOAT/DOUBLE (approximate, faster, but not appropriate for currency). String types include VARCHAR (variable-length text with a max length), CHAR (fixed-length, padded), and TEXT (for large, unbounded text).
Date/time types include DATE, DATETIME, and TIMESTAMP (TIMESTAMP is timezone-aware and stored in UTC internally, converting on read/write, while DATETIME stores exactly what you give it with no timezone conversion). Choosing the smallest type that safely fits your data matters for both storage size and index performance — using BIGINT for a column that will never exceed a few thousand rows wastes space unnecessarily.
| Type | Use for |
|---|---|
| INT / BIGINT | Whole numbers, IDs, counts |
| DECIMAL(10,2) | Money — exact, no rounding errors |
| VARCHAR(n) | Variable-length text with a known max |
| TEXT | Large, unbounded text (like a blog post) |
| DATETIME | A specific date and time, no timezone conversion |
| TIMESTAMP | A moment in time, timezone-aware, auto-converts |
003What is the difference between CHAR and VARCHAR?Beginner+
Easy explanation
CHAR is a FIXED-length string type — if you define `CHAR(10)` and store a 3-character value, MySQL pads it with spaces to fill all 10 characters, and it always uses the same amount of storage regardless of the actual content's length.
VARCHAR is a VARIABLE-length string type — it only uses as much storage as the actual string needs (plus a small length-tracking overhead), making it more space-efficient for values that vary a lot in length. The rule of thumb: use CHAR for fixed-length codes that are always the same length (like a 2-letter country code), and VARCHAR for everything else, like names or descriptions.
CREATE TABLE codes (
country_code CHAR(2), -- always exactly 2 chars, e.g. 'US'
description VARCHAR(255) -- varies in length
);
004What is a PRIMARY KEY constraint, and how does it differ from a UNIQUE constraint?Beginner+
Easy explanation
A PRIMARY KEY uniquely identifies each row in a table — it can never be NULL, and a table can only have ONE primary key (though that key can span multiple columns as a composite key). Most tables use an auto-incrementing integer as their primary key.
A UNIQUE constraint also ensures no two rows share the same value in that column, but unlike a primary key, a table can have MULTIPLE unique constraints, and (in MySQL) a UNIQUE column can allow NULL values (and can allow more than one NULL, since NULL is never considered equal to another NULL for uniqueness checks).
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) UNIQUE, -- unique, but not the primary key
phone VARCHAR(20) UNIQUE
);
005What is a FOREIGN KEY constraint, and what do ON DELETE CASCADE / SET NULL / RESTRICT actually do?Intermediate+
Easy explanation
A FOREIGN KEY links a column in one table to the PRIMARY KEY of another table, enforcing referential integrity — you can't insert a row referencing a parent that doesn't exist. It also controls what happens to CHILD rows when the referenced PARENT row is deleted or updated.
ON DELETE CASCADE automatically deletes the child rows too when the parent is deleted (useful for genuinely dependent data, like deleting a post's comments when the post itself is deleted). ON DELETE SET NULL sets the foreign key column to NULL instead of deleting the child (useful when the child can meaningfully exist without that parent). ON DELETE RESTRICT (the default in many setups) simply BLOCKS the delete entirely if any child rows still reference that parent, forcing you to handle them explicitly first.
CREATE TABLE comments (
id INT PRIMARY KEY,
post_id INT,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
-- deleting a post automatically deletes all its comments
006What is the difference between NULL and an empty string, or NULL and 0?Intermediate+
Easy explanation
NULL represents the ABSENCE of a value entirely — 'unknown' or 'not applicable' — it's fundamentally different from an empty string `''` (a string that exists but has zero characters) or the number `0` (a real, known numeric value). A `phone_number` column being NULL means 'we don't have this information'; being `''` means 'we explicitly recorded that there's no phone number as an empty value'.
This distinction matters enormously in comparisons: `NULL = NULL` evaluates to NULL (not TRUE!), because MySQL can't say two unknown things are equal — you must use `IS NULL` or `IS NOT NULL` to correctly check for NULL, since `column = NULL` will never match any row, even one that IS NULL.
SELECT * FROM users WHERE phone = NULL; -- WRONG: matches nothing, ever
SELECT * FROM users WHERE phone IS NULL; -- correct way to check for NULL
SELECT * FROM users WHERE phone IS NOT NULL; -- correct way to check for NOT NULL
007What are MySQL storage engines, and what is the difference between InnoDB and MyISAM?Intermediate+
Easy explanation
A storage engine is the underlying component that actually handles how MySQL stores, retrieves, and manages data on disk — different engines have different trade-offs, and MySQL lets you choose per table (though virtually all modern applications use InnoDB by default).
InnoDB supports transactions (COMMIT/ROLLBACK), foreign key constraints, and row-level locking (multiple transactions can write to different rows in the same table concurrently) — it's the default engine since MySQL 5.5 and the right choice for almost every real application. MyISAM is an older engine that doesn't support transactions or foreign keys and only offers table-level locking (the whole table locks during a write), but was historically slightly faster for read-heavy, non-transactional workloads — it's rarely the right choice for new projects today.
| InnoDB | MyISAM | |
|---|---|---|
| Transactions | Yes | No |
| Foreign keys | Yes | No |
| Locking granularity | Row-level | Table-level |
| Modern default? | Yes | No — legacy |
008What is AUTO_INCREMENT, and what happens to it if a transaction is rolled back?Intermediate+
Easy explanation
AUTO_INCREMENT automatically generates a new, unique, incrementing numeric value for a column (usually the primary key) every time a row is inserted, without you having to specify it manually — it's the standard way to generate simple surrogate keys in MySQL.
An important, often-surprising behavior: if a transaction that inserted a row is rolled back, the AUTO_INCREMENT value that was consumed by that failed insert is generally NOT reused — the counter still advances, leaving a 'gap' in the sequence. This is intentional (avoiding it would require locking the counter in ways that hurt concurrency), so application code should never assume auto-increment IDs are perfectly sequential with no gaps.
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
total DECIMAL(10,2)
);
INSERT INTO orders (total) VALUES (49.99); -- id is generated automatically
009What is a CHECK constraint, and how do you use it to enforce business rules at the database level?Intermediate+
Easy explanation
A CHECK constraint lets you define a condition that every row in a table must satisfy — if an INSERT or UPDATE would violate it, MySQL rejects the statement entirely. This lets you enforce basic business rules directly in the database schema, as a safety net even if application-level validation has a bug or is bypassed.
CHECK constraints are fully supported starting MySQL 8.0.16 (earlier versions silently accepted but ignored the syntax) — common uses include ensuring a price is never negative, an age is within a sane range, or a status column only contains one of a specific set of allowed values.
CREATE TABLE products (
id INT PRIMARY KEY,
price DECIMAL(10,2) CHECK (price >= 0),
status VARCHAR(20) CHECK (status IN ('active','discontinued'))
);
010What is the difference between DELETE, TRUNCATE, and DROP?Advanced+
Easy explanation
DELETE removes rows from a table based on a WHERE condition (or all rows if no WHERE is given), one row at a time, logging each deletion — it can be rolled back within a transaction, and any AUTO_INCREMENT counter is unaffected. TRUNCATE removes ALL rows from a table at once, much faster than DELETE since it deallocates the table's data pages directly rather than logging individual row deletions — but it also resets the AUTO_INCREMENT counter back to its starting value, and in most configurations it can't be rolled back.
DROP removes the ENTIRE table (or database) — not just the data, but the structure itself (columns, constraints, indexes) — it's the most destructive of the three and is not reversible without a backup. The rule of thumb: DELETE for selective row removal within a transaction, TRUNCATE for fast, total row wipeout when you're keeping the table structure, DROP only when you genuinely want the table gone entirely.
| Command | Removes | Resets auto-increment? | Can filter with WHERE? |
|---|---|---|---|
| DELETE | Some or all rows | No | Yes |
| TRUNCATE | All rows | Yes | No |
| DROP | The entire table structure | N/A — table is gone | No |
SELECT, Filtering & Sorting
Filtering a Result Set Step by Step
WHERE filters individual rows BEFORE grouping happens, so it can't reference an aggregate like COUNT(*). HAVING filters AFTER grouping, which is the only place you can filter on an aggregate value.
011What is the basic anatomy of a SELECT statement, and in what order should its clauses be written?Beginner+
Easy explanation
A SELECT statement is written in this order: SELECT (which columns), FROM (which table), WHERE (row filter), GROUP BY (grouping), HAVING (group filter), ORDER BY (sort order), LIMIT (row count cap). Not every clause is required — the simplest valid query is just `SELECT column FROM table`.
Remember that the WRITTEN order and the actual EVALUATION order are different — MySQL evaluates FROM/WHERE/GROUP BY/HAVING before it even computes the SELECT list, and only sorts (ORDER BY) and limits (LIMIT) after that — this explains several syntax quirks that otherwise seem arbitrary.
SELECT department, COUNT(*) AS emp_count
FROM employees
WHERE status = 'active'
GROUP BY department
HAVING COUNT(*) > 5
ORDER BY emp_count DESC
LIMIT 10;
012What is the difference between WHERE and HAVING?Beginner+
Easy explanation
WHERE filters individual ROWS before any grouping or aggregation happens — it operates on the raw table data, and cannot reference an aggregate function's result, because that result doesn't exist yet at this stage of execution.
HAVING filters GROUPS, after GROUP BY has already run — it's the only clause where you can filter based on an aggregate value like `COUNT(*) > 5` or `AVG(salary) > 50000`. A very common mistake is trying to write `WHERE COUNT(*) > 5`, which fails because COUNT(*) simply doesn't exist as a value at the point WHERE is evaluated.
SELECT department, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active' -- filters rows first
GROUP BY department
HAVING AVG(salary) > 60000; -- filters the resulting groups
013How do you remove duplicate rows from a result using DISTINCT?Beginner+
Easy explanation
DISTINCT removes duplicate ROWS from the result set — if you select multiple columns, DISTINCT considers the COMBINATION of all selected columns together, not each column independently, so two rows are only considered duplicates if every selected column matches.
A common use is finding the unique set of values in one column, like every distinct city customers are from — but be careful when selecting multiple columns with DISTINCT, since it's easy to accidentally get more 'unique' rows than expected if you didn't realize a second column was also being considered.
SELECT DISTINCT city FROM customers;
-- vs, considers BOTH columns together:
SELECT DISTINCT city, country FROM customers;
014What are the different types of operators you can use in a WHERE clause (comparison, logical, range, pattern, set)?Intermediate+
Easy explanation
Comparison operators (`=`, `!=`/`<>`, `<`, `>`, `<=`, `>=`) compare a column to a value. Logical operators (`AND`, `OR`, `NOT`) combine multiple conditions. Range operators use `BETWEEN x AND y` (inclusive on both ends) instead of writing two separate comparisons.
Pattern matching uses `LIKE` with wildcards — `%` matches any number of characters, `_` matches exactly one character. Set membership uses `IN (...)` to check if a value matches any item in a list, which is cleaner than chaining many `OR` conditions together.
SELECT * FROM products
WHERE price BETWEEN 10 AND 50
AND category IN ('Electronics','Books')
AND name LIKE 'Wireless%' -- starts with 'Wireless'
AND status != 'discontinued';
015How does ORDER BY work with multiple columns, and how do you sort NULLs?Intermediate+
Easy explanation
ORDER BY sorts the result set, and you can list MULTIPLE columns — MySQL sorts by the first column, and only uses the second column to break ties WITHIN groups of rows that have the same value in the first column, and so on for additional columns.
By default, MySQL sorts NULLs as if they were the SMALLEST possible value — they appear FIRST in ascending order and LAST in descending order. To control this explicitly regardless of sort direction, a common trick is adding `column IS NULL` as an extra sort expression, which evaluates to 0 (not null) or 1 (null), letting you push NULLs to a specific position.
SELECT * FROM employees
ORDER BY department ASC, salary DESC;
-- push NULLs to the end regardless of direction:
SELECT * FROM employees ORDER BY commission IS NULL, commission DESC;
016What is the difference between LIMIT and LIMIT with OFFSET, and how do you paginate results?Intermediate+
Easy explanation
`LIMIT n` restricts the result set to at most `n` rows. Adding an OFFSET (`LIMIT n OFFSET m`, or the shorthand `LIMIT m, n`) skips the first `m` rows before returning the next `n` — this is the classic way to implement 'pages' of results, like page 3 of a product listing.
The major downside of OFFSET-based pagination: for a large offset, MySQL still has to scan and discard all the skipped rows internally before it can return your actual page, making deep pagination (like page 5,000) progressively slower. For large-scale pagination, cursor-based pagination (`WHERE id > last_seen_id ORDER BY id LIMIT n`) is significantly faster since it can jump straight to the right position using an index.
-- page 3, 20 items per page (offset-based, simple but slow for deep pages)
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 40;
-- cursor-based (fast even for 'deep' pages)
SELECT * FROM products WHERE id > 1043 ORDER BY id LIMIT 20;
017What are the common MySQL string functions you should know (CONCAT, SUBSTRING, TRIM, REPLACE, UPPER/LOWER)?Intermediate+
Easy explanation
CONCAT joins multiple strings together (`CONCAT(first_name, ' ', last_name)`). SUBSTRING extracts a portion of a string starting at a given position for a given length. TRIM removes leading/trailing whitespace (or a specified character) from a string.
REPLACE swaps every occurrence of a substring with another substring within a string. UPPER and LOWER convert a string's case, commonly used for case-insensitive comparisons in databases where the column's default collation is case-sensitive.
SELECT CONCAT(first_name, ' ', last_name) AS full_name,
SUBSTRING(email, 1, LOCATE('@', email)-1) AS username,
TRIM(bio) AS clean_bio,
REPLACE(phone, '-', '') AS digits_only,
LOWER(email) AS normalized_email
FROM users;
018What are the common MySQL date/time functions (NOW, DATEDIFF, DATE_ADD, DATE_FORMAT)?Intermediate+
Easy explanation
NOW() returns the current date and time. CURDATE() returns just today's date. DATEDIFF(date1, date2) returns the number of days between two dates. DATE_ADD (and DATE_SUB) let you add or subtract a specific interval (days, months, years) from a date.
DATE_FORMAT lets you convert a date/time value into a specific human-readable string format, using format specifiers like `%Y` for a 4-digit year or `%d` for a 2-digit day — extremely common when generating reports or displaying dates to users.
SELECT
NOW() AS right_now,
DATEDIFF(CURDATE(), order_date) AS days_since_order,
DATE_ADD(order_date, INTERVAL 30 DAY) AS warranty_expires,
DATE_FORMAT(order_date, '%M %d, %Y') AS pretty_date
FROM orders;
019What is the difference between LIKE with wildcards and REGEXP for pattern matching?Advanced+
Easy explanation
LIKE offers simple pattern matching using just two wildcards: `%` (any number of any characters) and `_` (exactly one character) — it's fast, simple, and sufficient for basic 'starts with', 'ends with', or 'contains' style searches.
REGEXP (or RLIKE) supports full regular expression pattern matching, letting you express far more complex patterns — matching one of several alternatives, character classes, repetition counts, and anchors — at the cost of being slower to evaluate and generally not able to use a standard B-tree index efficiently, since the pattern isn't necessarily a simple prefix match.
SELECT * FROM products WHERE name LIKE 'iPhone%'; -- starts with 'iPhone'
SELECT * FROM products WHERE name REGEXP '^(iPhone|iPad)[0-9]+$'; -- more complex pattern
020What is the CASE expression, and how is it used to add conditional logic directly inside a query?Advanced+
Easy explanation
CASE lets you embed IF/ELSE-style conditional logic directly inside a SELECT, WHERE, or ORDER BY clause, evaluating a series of WHEN conditions in order and returning the value associated with the first one that matches (or an ELSE value if none match).
This is extremely useful for creating computed categories on the fly (like bucketing ages into groups), for conditional aggregation (counting how many rows meet one condition vs another, all in a single query), or for custom sort orders that don't match a plain alphabetical or numeric order.
SELECT name,
CASE
WHEN age < 18 THEN 'Minor'
WHEN age BETWEEN 18 AND 64 THEN 'Adult'
ELSE 'Senior'
END AS age_group
FROM people;
-- conditional aggregation: count active vs inactive in one query
SELECT
SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active_count,
SUM(CASE WHEN status='inactive' THEN 1 ELSE 0 END) AS inactive_count
FROM users;
Joins & Set Operations
Visualizing the Main JOIN Types
INNER JOIN keeps only rows where both tables have a match. LEFT JOIN keeps every row from the left table regardless of a match, filling unmatched right-side columns with NULL.
021What is a JOIN, and why is it fundamental to relational databases?Beginner+
Easy explanation
A JOIN combines rows from two or more tables based on a related column between them, typically a foreign key referencing a primary key — this is what lets you keep data normalized across separate tables (avoiding duplication) while still being able to reconstruct the full, combined picture when you actually need it.
Without JOINs, you'd either have to denormalize everything into one giant table (reintroducing all the redundancy normalization was meant to remove) or make multiple separate round trips and manually stitch the results together in application code — JOIN lets the database do this combination work efficiently, in a single query.
SELECT orders.id, customers.name
FROM orders
JOIN customers ON orders.customer_id = customers.id;
022What is the difference between INNER JOIN and LEFT JOIN?Beginner+
Easy explanation
INNER JOIN returns only the rows where a match exists in BOTH tables — if a customer has never placed an order, that customer won't appear at all in an INNER JOIN between customers and orders.
LEFT JOIN (a.k.a. LEFT OUTER JOIN) keeps EVERY row from the left (first-named) table, whether or not a match exists on the right — any columns coming from the unmatched right table are filled with NULL. This is exactly the join you need for a question like 'show me every customer, including ones who've never ordered anything'.
-- INNER JOIN: only customers WITH orders appear
SELECT c.name, o.total FROM customers c
JOIN orders o ON o.customer_id = c.id;
-- LEFT JOIN: every customer appears, total is NULL if no orders
SELECT c.name, o.total FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;
023What is a RIGHT JOIN, and why do most developers just use LEFT JOIN instead?Intermediate+
Easy explanation
RIGHT JOIN (RIGHT OUTER JOIN) is the mirror image of LEFT JOIN — it keeps every row from the RIGHT table, filling unmatched left-side columns with NULL. Logically it's identical to a LEFT JOIN with the table order simply swapped.
Because you can always rewrite any RIGHT JOIN as an equivalent LEFT JOIN by reversing which table is listed first, most style guides and developers stick to ONLY using LEFT JOIN for consistency and readability — having both directions used interchangeably in a codebase makes queries harder to scan and compare at a glance.
-- these two are logically equivalent:
SELECT * FROM a RIGHT JOIN b ON a.id = b.a_id;
SELECT * FROM b LEFT JOIN a ON a.id = b.a_id;
024What is a FULL OUTER JOIN, and how do you simulate it in MySQL (which doesn't support it natively)?Intermediate+
Easy explanation
A FULL OUTER JOIN keeps ALL rows from BOTH tables — matched rows are combined normally, and unmatched rows from either side appear with NULLs filling in for the columns from the other table. It answers 'give me everything from both tables, matched where possible'.
MySQL doesn't have native FULL OUTER JOIN support — the standard workaround is to combine a LEFT JOIN and a RIGHT JOIN (or two LEFT JOINs with table order swapped) using UNION, which also conveniently removes the duplicate rows that would otherwise appear where both joins matched the same rows.
-- simulating FULL OUTER JOIN in MySQL
SELECT a.*, b.* FROM a LEFT JOIN b ON a.id = b.a_id
UNION
SELECT a.*, b.* FROM a RIGHT JOIN b ON a.id = b.a_id;
025What is a self-join, and when is it actually useful?Intermediate+
Easy explanation
A self-join joins a table to ITSELF, treated as if it were two separate tables using two different aliases — this is useful whenever rows in a table have a relationship to OTHER rows in that same table, rather than to rows in a different table.
The classic example is an `employees` table with a `manager_id` column that references another row's `id` in the same table — a self-join lets you list every employee alongside their manager's name in one query, by joining the table to itself on that relationship.
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
026What is a CROSS JOIN, and what real use case does it actually have?Intermediate+
Easy explanation
A CROSS JOIN produces the CARTESIAN PRODUCT of two tables — every row from the first table combined with EVERY row from the second table, with no join condition at all. If Table A has 3 rows and Table B has 4 rows, a CROSS JOIN produces 12 rows total.
While an accidental CROSS JOIN (forgetting a join condition) is a very common and costly bug, a deliberate CROSS JOIN has real uses — like generating every possible combination of size and color for a product catalog, or generating a complete calendar of dates crossed with every store location for a reporting template.
-- deliberately generate every size/color combination
SELECT sizes.name, colors.name
FROM sizes
CROSS JOIN colors;
027What is the difference between UNION and UNION ALL?Advanced+
Easy explanation
UNION combines the result sets of two or more SELECT queries into one, and automatically removes any duplicate rows from the combined result — this deduplication requires MySQL to do extra work (effectively sorting and comparing every row) before returning the final result.
UNION ALL combines the result sets WITHOUT removing duplicates, keeping every row from every query exactly as returned — since it skips the deduplication step entirely, UNION ALL is meaningfully faster. Use UNION ALL whenever you know the result sets won't overlap, or when duplicates are acceptable or even expected — only pay the extra cost of UNION when you genuinely need duplicates removed.
-- combined, duplicates removed (slower)
SELECT city FROM customers UNION SELECT city FROM suppliers;
-- combined, duplicates kept (faster)
SELECT city FROM customers UNION ALL SELECT city FROM suppliers;
028Both queries have the same result columns — what rules must UNION-ed queries follow?Advanced+
Easy explanation
Every SELECT statement combined with UNION or UNION ALL must return the SAME NUMBER of columns, in a compatible order — MySQL matches columns POSITIONALLY (the 1st column of query A with the 1st column of query B), not by column name, so mismatched column meanings in the same position is a common and subtle bug.
The columns should also have compatible data types (MySQL will attempt implicit conversion, but mixing very different types, like text and numbers, in the same position can cause unexpected coercion). The FINAL result set's column names come from the FIRST query in the UNION, regardless of what the later queries' columns are named.
-- valid: both queries return 2 columns, compatible types
SELECT name, email FROM customers
UNION
SELECT company_name, contact_email FROM suppliers;
-- resulting columns are named 'name' and 'email' (from the FIRST query)
029How do you find rows in one table that do NOT have a matching row in another table (an anti-join)?Advanced+
Easy explanation
This is a very common real-world pattern — 'find customers who have never placed an order', or 'find products that have never been sold' — and MySQL doesn't have a dedicated ANTI-JOIN keyword, so it's expressed using a LEFT JOIN combined with a check for NULL on the right side.
The logic: do a LEFT JOIN so every row from the left table appears regardless of a match, then filter in the WHERE clause for rows where the right table's key came back NULL — meaning no match was found at all. An alternative, often equally efficient, approach uses `WHERE id NOT IN (subquery)` or `WHERE NOT EXISTS (subquery)`.
-- customers who have never placed an order
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
030How do you join more than two tables together in a single query?Advanced+
Easy explanation
You simply chain multiple JOIN clauses one after another — each JOIN can reference any table already introduced earlier in the query, not just the very first one, letting you build up a complex combined result across many related tables in one statement.
The ORDER you write joins in doesn't necessarily determine the ORDER the optimizer actually executes them in — the query planner is free to reorder joins internally if it estimates a different order will be more efficient, as long as the logical result stays the same. Readability-wise, it's still good practice to write joins in a sensible, easy-to-follow order (usually following the natural parent-to-child relationship chain).
SELECT o.id, c.name AS customer, p.name AS product, oi.quantity
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id;
Subqueries
Where a Subquery Can Live Inside a Query
A subquery is just a query nested inside another query — but WHERE it's placed changes what it's allowed to return and how MySQL evaluates it, from a single value to a whole reusable temporary result set.
031What is a subquery, and what are the main types based on where they appear?Beginner+
Easy explanation
A subquery (or inner query) is a SELECT statement nested inside another SQL statement — it's evaluated first (or once per outer row, for correlated subqueries), and its result is used by the outer (containing) query. Subqueries let you break a complex question into smaller logical steps within a single SQL statement.
The main types, based on WHERE they're placed: a scalar subquery appears where a single value is expected (like in SELECT or WHERE with `=`); a row/column subquery returns multiple rows and is used with `IN`, `ANY`, or `ALL`; a table subquery (a.k.a. derived table) appears in the FROM clause and is treated like a temporary table; and a correlated subquery references a column from the OUTER query, re-evaluating once per outer row.
-- scalar subquery: one value
SELECT name, (SELECT AVG(salary) FROM employees) AS company_avg FROM employees LIMIT 1;
-- table subquery: used like a temporary table
SELECT * FROM (SELECT department, AVG(salary) AS avg_sal FROM employees GROUP BY department) dept_avgs;
032What is a scalar subquery, and where can it be used?Beginner+
Easy explanation
A scalar subquery is a subquery that's guaranteed to return EXACTLY ONE value — one row, one column. Because it resolves to a single value, it can be used anywhere a literal value would be valid: in the SELECT list, in a WHERE comparison with `=`, `<`, `>`, or as part of an expression.
If a scalar subquery unexpectedly returns MORE than one row at runtime, MySQL throws an error ('subquery returns more than 1 row') — so scalar subqueries should only be used when you're certain the underlying logic can only ever produce a single result, like an aggregate function with no GROUP BY, or a query filtered down to one specific unique row.
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
-- the subquery returns exactly one value: the company-wide average
033What is the difference between IN and EXISTS when used with a subquery?Intermediate+
Easy explanation
`IN` compares a value against the LIST of values returned by a subquery — MySQL typically has to materialize the full list of subquery results before it can check membership against it. `EXISTS` checks only whether the subquery returns ANY rows at all — it doesn't care about the actual VALUES returned, just whether at least one row exists, and can often short-circuit as soon as one matching row is found.
For most modern versions of MySQL, the query optimizer can rewrite one into the other and pick whichever is more efficient for the specific query, so performance differences are often smaller than commonly believed — but `EXISTS` is generally considered safer and more intuitive for CORRELATED subqueries (checking a per-row condition), while `IN` reads more naturally for checking against a fixed, independent list.
-- IN: compares against a list of values
SELECT * FROM customers WHERE id IN (SELECT customer_id FROM orders WHERE total > 1000);
-- EXISTS: just checks if any matching row exists (correlated)
SELECT * FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.total > 1000);
034What is a correlated subquery, and how is it different from a regular (non-correlated) subquery?Intermediate+
Easy explanation
A regular (non-correlated) subquery is completely self-contained and can be run entirely on its own — it's evaluated ONCE, and its result is reused for every row the outer query checks. A correlated subquery references a column from the OUTER query inside its own WHERE clause, meaning it can't be run in isolation — its result genuinely depends on which outer row is currently being evaluated.
Because of this dependency, a correlated subquery conceptually runs ONCE PER ROW of the outer query — for a large outer table, this can be significantly slower than an equivalent non-correlated approach (like a JOIN or a subquery in FROM), which is why correlated subqueries should be used carefully and checked with EXPLAIN if performance matters.
-- correlated: 'd.id' references the OUTER query's current row
SELECT d.name,
(SELECT COUNT(*) FROM employees e WHERE e.department_id = d.id) AS emp_count
FROM departments d;
035What is a derived table (a subquery in the FROM clause), and why must it always have an alias?Intermediate+
Easy explanation
A derived table is a subquery placed in the FROM clause, treated by the outer query exactly like a regular, temporary table for the duration of that query — this is extremely useful for pre-aggregating or pre-filtering data before joining or filtering it further in the outer query.
MySQL REQUIRES every derived table to have an alias (`AS some_name`) — without one, the query fails with a syntax error, since the outer query needs a name to refer to this temporary result set by, exactly the same way every real table needs to be referenceable.
SELECT dept_avgs.department, dept_avgs.avg_salary
FROM (
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
) AS dept_avgs -- alias is required
WHERE dept_avgs.avg_salary > 60000;
036What is a Common Table Expression (CTE), and how is it different from a derived table subquery?Intermediate+
Easy explanation
A CTE, defined with `WITH name AS (SELECT ...)` before the main query, defines a temporary named result set that can be referenced one or more times later in the query — functionally similar to a derived table subquery, but defined ONCE at the top, making the overall query dramatically more readable, especially when the same intermediate result is needed multiple times.
Unlike a plain derived table (which must be written inline exactly where it's used, and duplicated if needed twice), a CTE is defined once and can be referenced by name multiple times in the same query — and MySQL 8.0+ also supports RECURSIVE CTEs, which can reference themselves, essential for querying hierarchical data like an organization chart or a category tree.
WITH dept_avgs AS (
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
)
SELECT e.name, e.salary, d.avg_salary
FROM employees e
JOIN dept_avgs d ON d.department = e.department
WHERE e.salary > d.avg_salary;
037How do you use a recursive CTE to query hierarchical data, like an employee/manager org chart?Advanced+
Easy explanation
A recursive CTE has two parts, combined with `UNION ALL`: an ANCHOR member (the starting point — like the top-level manager with no manager themselves) and a RECURSIVE member (which references the CTE's own name, joining it back to the base table to find the next level down), repeating until no more matching rows are found.
This is the standard way to query tree-like or graph-like structures in a relational database — traversing an org chart, expanding a nested category tree, or finding all ancestors/descendants of a node — something that would otherwise require awkward, hard-coded multiple self-joins for each fixed depth level, or a full application-level loop.
WITH RECURSIVE org_chart AS (
SELECT id, name, manager_id, 1 AS level
FROM employees WHERE manager_id IS NULL -- anchor: the top
UNION ALL
SELECT e.id, e.name, e.manager_id, oc.level + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id -- recursive: next level down
)
SELECT * FROM org_chart ORDER BY level;
038What is the difference between ANY, ALL, and SOME when comparing a value against a subquery's results?Advanced+
Easy explanation
`= ANY (subquery)` is functionally identical to `IN (subquery)` — TRUE if the value matches AT LEAST ONE row returned by the subquery. `> ANY (subquery)` is TRUE if the value is greater than AT LEAST ONE of the returned values (in other words, greater than the MINIMUM of them).
`> ALL (subquery)` is TRUE only if the value is greater than EVERY single value returned by the subquery (in other words, greater than the MAXIMUM of them) — a much stricter condition than ANY. `SOME` is simply an alias for `ANY` — the two keywords behave identically in MySQL, and `ANY` is more commonly used in practice for clarity.
-- greater than at least one department's average (i.e., greater than the smallest average)
SELECT * FROM employees WHERE salary > ANY (SELECT AVG(salary) FROM employees GROUP BY department);
-- greater than EVERY department's average (i.e., greater than the largest average)
SELECT * FROM employees WHERE salary > ALL (SELECT AVG(salary) FROM employees GROUP BY department);
039How do you use a subquery to find the "top N per group" (e.g., the highest-paid employee in each department)?Advanced+
Easy explanation
One classic subquery approach: for each employee, use a correlated subquery to count how many OTHER employees in the same department earn MORE than them — if that count is 0, they're the top earner in their department. This works but can be slow on large tables since the correlated subquery re-runs per row.
A more modern, generally faster approach (MySQL 8.0+) uses a WINDOW FUNCTION instead — `RANK()` or `ROW_NUMBER()` partitioned by department and ordered by salary descending, then filtering for rank 1 in an outer query, since window functions can compute this in a single efficient pass rather than a per-row correlated check.
-- correlated subquery approach
SELECT * FROM employees e1
WHERE 0 = (SELECT COUNT(*) FROM employees e2 WHERE e2.department = e1.department AND e2.salary > e1.salary);
-- modern window function approach (MySQL 8.0+)
SELECT * FROM (
SELECT *, RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk
FROM employees
) ranked WHERE rnk = 1;
040What is the performance risk of a correlated subquery in the SELECT list, and how would you rewrite it as a JOIN?Advanced+
Easy explanation
A correlated subquery placed in the SELECT list (like computing an order count per customer inline) is conceptually re-executed ONCE PER ROW returned by the outer query — for a large result set, this means running that inner query potentially thousands or millions of times, which can be dramatically slower than an equivalent set-based operation.
In most cases, this pattern can be rewritten as a LEFT JOIN combined with GROUP BY, which lets MySQL compute the aggregation in one efficient pass over the joined data instead of one query execution per outer row — this rewrite is one of the most common, high-impact query optimizations in real-world MySQL performance tuning.
-- slow: correlated subquery runs once per customer row
SELECT c.name, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS order_count
FROM customers c;
-- faster: single pass with JOIN + GROUP BY
SELECT c.name, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;
Aggregate Functions, Views, Indexes & Optimization
GROUP BY: Collapsing Rows Into Summary Groups
GROUP BY collapses many rows sharing the same value into one summary row per group, and an aggregate function (SUM, COUNT, AVG) computes one value across all rows in each group.
041What are the main aggregate functions in MySQL (COUNT, SUM, AVG, MIN, MAX)?Beginner+
Easy explanation
Aggregate functions compute a SINGLE summary value from MULTIPLE rows. COUNT(*) counts the total number of rows (including ones with NULLs), while COUNT(column) counts only rows where that specific column is NOT NULL — this distinction matters and is a common gotcha. SUM adds up a numeric column's values, and AVG computes their average.
MIN and MAX return the smallest and largest values in a column respectively, and they work on non-numeric types too (like finding the earliest or latest date, or the alphabetically first/last name). Without a GROUP BY clause, an aggregate function computes ONE value across the ENTIRE table.
SELECT
COUNT(*) AS total_rows,
COUNT(discount) AS rows_with_discount,
SUM(total) AS revenue,
AVG(total) AS avg_order,
MIN(order_date) AS first_order,
MAX(order_date) AS latest_order
FROM orders;
042How does GROUP BY work, and what rule governs which columns can appear in the SELECT list?Beginner+
Easy explanation
GROUP BY collapses multiple rows that share the same value in the grouped column(s) into a single summary row per group — commonly paired with an aggregate function to compute something (a total, a count, an average) FOR EACH group separately, rather than across the whole table at once.
The rule: every column in the SELECT list must either be part of the GROUP BY clause itself, or be wrapped in an aggregate function — MySQL (in strict SQL mode) will reject a query that selects a non-grouped, non-aggregated column, since it wouldn't have one single, unambiguous value to display for that group.
SELECT department, COUNT(*) AS emp_count, AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
-- 'department' is grouped; count/avg are aggregated — both are valid here
043How do you count how many rows fall into each of several categories in a single query?Intermediate+
Easy explanation
GROUP BY combined with COUNT(*) is the standard pattern — grouping by the category column and counting rows within each group gives you a breakdown across every distinct value that column has, all in one pass over the data instead of running a separate COUNT query per category.
This same pattern extends naturally to any other aggregate — SUM per category, AVG per category, MIN/MAX per category — and can be combined with ORDER BY on the aggregate result to rank categories by size, which is one of the most common patterns in real-world reporting queries.
SELECT status, COUNT(*) AS how_many
FROM orders
GROUP BY status
ORDER BY how_many DESC;
044What is a MySQL VIEW, and what are its practical uses?Intermediate+
Easy explanation
A VIEW is a saved, named SELECT query that behaves like a virtual table — you can query it just like a regular table, but MySQL runs the underlying SELECT statement fresh every time the view is queried, so it's always up to date with the real underlying tables.
Practical uses: simplifying a complex, frequently-repeated query so users just query a simple view name instead of retyping a long JOIN every time; restricting access by exposing only specific columns/rows of a sensitive table through a view (giving read access to the view without giving direct access to the underlying table); and providing a stable interface that hides underlying schema changes from applications that only query through the view.
CREATE VIEW active_customers AS
SELECT id, name, email FROM customers WHERE status = 'active';
-- now query it like a regular table:
SELECT * FROM active_customers WHERE name LIKE 'A%';
045Can you INSERT, UPDATE, or DELETE through a VIEW? What restrictions apply?Intermediate+
Easy explanation
Yes, MySQL allows you to modify data through a view in many cases, and the change gets applied to the actual underlying base table — but this only works for 'simple' (updatable) views: ones based on a single table, without GROUP BY, aggregate functions, DISTINCT, UNION, or subqueries in the SELECT list.
A view built from a JOIN, an aggregate query, or anything more complex is generally NOT updatable (or only partially updatable in narrow cases) — trying to INSERT/UPDATE/DELETE through such a view either fails outright or behaves in ways that can be surprising, so most teams treat views as read-only reporting tools rather than a general-purpose way to write data.
CREATE VIEW simple_view AS SELECT id, name, email FROM customers;
UPDATE simple_view SET email = 'new@email.com' WHERE id = 1; -- works: updates the real customers table
-- but a view with a JOIN or GROUP BY generally can't be updated this way
046What is the EXPLAIN statement, and what key columns should you look at to diagnose a slow query?Intermediate+
Easy explanation
EXPLAIN shows you the execution plan MySQL's optimizer chose for a query WITHOUT actually running it (EXPLAIN ANALYZE, in MySQL 8.0.18+, actually executes it and shows real timing too) — this is the single most important diagnostic tool for understanding why a query is slow.
Key columns to check: `type` (the join/access type — `ALL` means a full table scan, which is usually bad on a large table; `ref`/`range`/`const` mean an index is being used effectively), `key` (which index, if any, was actually used), `rows` (the estimated number of rows MySQL expects to examine — a huge number here on a query that should be selective is a red flag), and `Extra` (look out for 'Using filesort' or 'Using temporary', which indicate extra, potentially expensive work).
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC;
-- check: type (want ref/range, not ALL), key (is an index being used?), rows (how many examined?)
047What is a covering index, and why can it make a query fast without even touching the actual table data?Advanced+
Easy explanation
A covering index is an index that contains EVERY column a specific query needs — both the columns being filtered/sorted on AND the columns being selected — meaning MySQL can answer the entire query by reading ONLY the index itself, never needing to look up the actual row in the table at all (this extra lookup step is sometimes called a 'bookmark lookup').
This can be a significant performance win because reading from a smaller, more compact index structure is generally faster than reading full table rows, especially when a query only needs a few narrow columns from a table with many wide columns — you can check whether this is happening by looking for 'Using index' in EXPLAIN's Extra column.
-- if this index exists:
CREATE INDEX idx_covering ON orders(customer_id, order_date, total);
-- this query can be fully answered FROM THE INDEX ALONE (a covering index):
SELECT order_date, total FROM orders WHERE customer_id = 42;
048Why does a leading wildcard in LIKE (e.g., '%searchterm') prevent MySQL from using a B-tree index?Advanced+
Easy explanation
A standard B-tree index stores values in sorted order, which lets MySQL efficiently jump to and scan a range of values that share a known PREFIX — `LIKE 'John%'` can use an index because MySQL can jump straight to entries starting with 'John' in the sorted structure.
`LIKE '%John'` (a leading wildcard) can't use a standard B-tree index at all, because the matching characters could start ANYWHERE in the string — there's no useful sorted prefix to jump to, forcing MySQL to check every single row's value individually (a full table scan or full index scan). For genuine full-text 'contains anywhere' search needs, a dedicated FULLTEXT index is the appropriate tool instead of LIKE with a leading wildcard.
-- CAN use an index (prefix match):
WHERE name LIKE 'John%'
-- CANNOT use a standard B-tree index (no fixed prefix):
WHERE name LIKE '%John%'
-- for genuine contains-search at scale, use FULLTEXT instead:
SELECT * FROM articles WHERE MATCH(body) AGAINST('database performance');
049What is the difference between a stored procedure and a stored function in MySQL?Advanced+
Easy explanation
A stored procedure is a saved, reusable block of SQL logic that you INVOKE with `CALL` — it can accept input parameters, output parameters, perform multiple statements including inserts/updates/deletes, and doesn't necessarily have to return any value at all.
A stored function is similar but MUST return exactly one value, and (unlike a procedure) can be used DIRECTLY inside a regular SQL expression, like in a SELECT list or a WHERE clause, the same way you'd use a built-in function like `UPPER()`. Procedures are generally used for encapsulating multi-step business logic or batch operations; functions are used for reusable calculations you want to embed inline within other queries.
DELIMITER //
CREATE FUNCTION get_tax(amount DECIMAL(10,2)) RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
RETURN amount * 0.08;
END //
DELIMITER ;
-- used inline, like a built-in function:
SELECT total, get_tax(total) AS tax FROM orders;
050What is a MySQL trigger, and what is a real-world use case for one?Advanced+
Easy explanation
A trigger is a piece of SQL logic that MySQL automatically executes in response to a specific event (BEFORE or AFTER an INSERT, UPDATE, or DELETE) on a specific table — you never call a trigger directly; it fires automatically whenever the matching event happens.
A common real-world use: maintaining an audit log automatically — an AFTER UPDATE trigger on a `salaries` table could automatically insert a row into a `salary_history` table every time a salary changes, guaranteeing the history is captured consistently regardless of which application code path performed the update, rather than relying on every single code path remembering to log it manually.
CREATE TRIGGER log_salary_change
AFTER UPDATE ON employees
FOR EACH ROW
BEGIN
IF OLD.salary != NEW.salary THEN
INSERT INTO salary_history (employee_id, old_salary, new_salary, changed_at)
VALUES (OLD.id, OLD.salary, NEW.salary, NOW());
END IF;
END;