SELECT in MySQL
The SELECT statement retrieves rows from one or more tables. It is the most frequently used
SQL statement and supports a rich set of clauses for filtering, sorting, grouping, and transforming data.
Basic SELECT Syntax
SELECT column1, column2, ... FROM table_name [WHERE condition] [GROUP BY column] [HAVING condition] [ORDER BY column [ASC|DESC]] [LIMIT n];
Selecting All Columns vs Specific Columns
-- Select every column (convenient but fragile) SELECT * FROM employees; -- Select specific columns (recommended for production) SELECT employee_id, first_name, last_name, email FROM employees;
SELECT * in production queries. It transfers unnecessary data, breaks if columns are reordered or added, and prevents covering-index optimisations.Query Execution Order
SQL is a declarative language — you describe what you want, not how to compute it. MySQL processes the clauses of a SELECT in a specific logical order that differs from the order they appear in writing. Understanding this order is essential for writing correct queries and knowing why certain references are legal or illegal.
Step | Clause | Description |
|---|---|---|
1 | FROM / JOIN | Identify source tables and join them |
2 | WHERE | Filter individual rows before grouping |
3 | GROUP BY | Group rows into buckets |
4 | HAVING | Filter groups after aggregation |
5 | SELECT | Compute output expressions and aliases |
6 | DISTINCT | Remove duplicate rows from the result |
7 | ORDER BY | Sort the result set |
8 | LIMIT / OFFSET | Trim to the requested page |
WHERE executes before SELECT, you cannot reference column aliases defined in the SELECT list inside a WHERE clause. Use a subquery or repeat the expression.-- FAILS: alias 'annual_salary' is not yet defined when WHERE runs SELECT salary * 12 AS annual_salary FROM employees WHERE annual_salary > 60000; -- ERROR: Unknown column 'annual_salary' -- Fix 1: repeat the expression in WHERE SELECT salary * 12 AS annual_salary FROM employees WHERE salary * 12 > 60000; -- Fix 2: wrap in a subquery (alias is now visible at the outer WHERE) SELECT * FROM ( SELECT employee_id, salary * 12 AS annual_salary FROM employees ) AS sub WHERE sub.annual_salary > 60000; -- ORDER BY CAN reference SELECT aliases (it runs after SELECT) SELECT salary * 12 AS annual_salary FROM employees ORDER BY annual_salary DESC; -- valid
Column Aliases with AS
Use AS to rename columns in the result set. Aliases improve readability and are required when
using expressions or functions. The alias scope matters: aliases are visible in ORDER BY and
HAVING (MySQL extension) but NOT in WHERE or other SELECT expressions.
SELECT first_name AS "First Name", last_name AS "Last Name", salary AS monthly_salary FROM employees; -- AS keyword is optional but strongly recommended for clarity SELECT first_name fn, last_name ln FROM employees; -- Aliases with spaces or special chars must be quoted SELECT CONCAT(first_name, ' ', last_name) AS "Full Name" FROM employees; -- Alias reuse in HAVING (MySQL-specific extension) SELECT department_id, AVG(salary) AS avg_sal FROM employees GROUP BY department_id HAVING avg_sal > 50000; -- MySQL allows alias in HAVING
Computed Columns and Expressions
SELECT
product_name,
price,
price * 1.10 AS price_with_tax,
price * quantity AS line_total,
ROUND(price * 0.9, 2) AS discounted_price,
ROUND((price - cost) / price * 100, 1) AS gross_margin_pct
FROM order_items;
-- String expressions
SELECT
CONCAT(first_name, ' ', last_name) AS full_name,
CONCAT_WS(', ', last_name, first_name) AS formal_name,
UPPER(email) AS email_upper,
LENGTH(phone) AS phone_len,
TRIM(description) AS clean_desc,
SUBSTRING(sku, 1, 3) AS sku_prefix
FROM products;
-- Date expressions
SELECT
NOW() AS current_datetime,
CURDATE() AS today,
YEAR(hire_date) AS hire_year,
DATEDIFF(NOW(), hire_date) AS days_employed,
DATE_FORMAT(hire_date, '%b %Y') AS hire_month
FROM employees;SELECT DISTINCT
DISTINCT removes duplicate rows from the result. It applies to the combination of ALL
selected columns, not just one. MySQL performs an implicit sort or hash to find duplicates,
so it has a performance cost on large results.
-- Unique departments SELECT DISTINCT department_id FROM employees; -- Unique city + country combinations (DISTINCT on both columns together) SELECT DISTINCT city, country FROM addresses; -- DISTINCT with aggregate: count unique values SELECT COUNT(DISTINCT department_id) AS num_departments FROM employees; -- DISTINCT vs GROUP BY — equivalent results, different intent SELECT DISTINCT country FROM customers; SELECT country FROM customers GROUP BY country; -- same result
CASE WHEN in SELECT
CASE is a conditional expression in SQL. It works anywhere an expression is valid —
in SELECT, WHERE, ORDER BY, GROUP BY, and aggregate functions. Use the simple form for
equality checks, the searched form for range conditions.
-- Searched CASE (arbitrary conditions)
SELECT
product_name,
stock,
CASE
WHEN stock = 0 THEN 'Out of stock'
WHEN stock < 10 THEN 'Low stock'
WHEN stock BETWEEN 10 AND 50 THEN 'Medium stock'
ELSE 'In stock'
END AS stock_status
FROM products;
-- Simple CASE (equality only)
SELECT
order_id,
CASE status
WHEN 'pending' THEN 'Awaiting payment'
WHEN 'paid' THEN 'Processing'
WHEN 'shipped' THEN 'On the way'
WHEN 'delivered' THEN 'Complete'
ELSE 'Unknown'
END AS status_label
FROM orders;
-- CASE in ORDER BY: custom sort order
SELECT product_name, status
FROM products
ORDER BY
CASE status
WHEN 'featured' THEN 1
WHEN 'active' THEN 2
WHEN 'draft' THEN 3
ELSE 4
END;
-- CASE inside aggregate: conditional count
SELECT
COUNT(*) AS total_orders,
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) AS delivered,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
ROUND(
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1
) AS delivery_rate_pct
FROM orders;Subqueries in SELECT (Correlated Scalar Subquery)
A scalar subquery in the SELECT list returns exactly one value per row. A correlated scalar subquery references a column from the outer query, causing it to execute once for each row of the outer query — which can be slow on large tables.
-- Non-correlated scalar subquery: executes once for the whole query SELECT e.first_name, e.salary, (SELECT AVG(salary) FROM employees) AS company_avg, e.salary - (SELECT AVG(salary) FROM employees) AS diff_from_avg FROM employees e; -- Correlated scalar subquery: executes once per row (expensive on big tables) SELECT e.first_name, e.salary, (SELECT AVG(salary) FROM employees e2 WHERE e2.department_id = e.department_id) AS dept_avg FROM employees e; -- Better: replace the correlated subquery with a JOIN to a derived table SELECT e.first_name, e.salary, d.dept_avg FROM employees e JOIN ( SELECT department_id, AVG(salary) AS dept_avg FROM employees GROUP BY department_id ) d ON d.department_id = e.department_id;
Window Function Preview
Window functions (MySQL 8.0+) perform calculations across a set of rows related to the current row without collapsing them into groups. They appear in the SELECT list with an OVER() clause.
SELECT first_name, department_id, salary, AVG(salary) OVER (PARTITION BY department_id) AS dept_avg, salary - AVG(salary) OVER (PARTITION BY department_id) AS diff_from_dept_avg, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS dept_rank, ROW_NUMBER() OVER (ORDER BY salary DESC) AS overall_rank FROM employees;
Using SELECT to Validate Data
SELECT is an indispensable data-quality tool. Use it to find NULLs, duplicates, and out-of-range values before they cause problems.
-- Find rows with NULL in a required column SELECT id, first_name, email FROM customers WHERE email IS NULL OR TRIM(email) = ''; -- Find duplicate email addresses SELECT email, COUNT(*) AS occurrences FROM customers GROUP BY email HAVING occurrences > 1 ORDER BY occurrences DESC; -- Find out-of-range prices SELECT id, product_name, price FROM products WHERE price <= 0 OR price > 100000; -- Find orphaned orders (no matching customer) SELECT o.order_id, o.customer_id FROM orders o LEFT JOIN customers c ON c.customer_id = o.customer_id WHERE c.customer_id IS NULL; -- Summary of NULL counts per column SELECT SUM(email IS NULL) AS null_emails, SUM(phone IS NULL) AS null_phones, SUM(address IS NULL) AS null_addresses FROM customers;
SELECT INTO OUTFILE
SELECT ... INTO OUTFILE exports query results directly to a file on the MySQL server's
filesystem. The MySQL server process must have write access to the target directory.
-- Export to CSV SELECT id, first_name, last_name, email FROM customers WHERE active = 1 INTO OUTFILE '/tmp/customers_export.csv' FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY ' '; -- Check FILE privilege is required SHOW GRANTS FOR CURRENT_USER(); -- Must show: GRANT FILE ON *.* -- Import back with LOAD DATA INFILE LOAD DATA INFILE '/tmp/customers_export.csv' INTO TABLE customers_import FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY ' ' (id, first_name, last_name, email);
secure_file_priv to control which directory is allowed.Practical Monthly Sales Report
-- Monthly sales summary: revenue, order count, AOV, new customers
SELECT
DATE_FORMAT(o.created_at, '%Y-%m') AS month,
COUNT(DISTINCT o.order_id) AS total_orders,
COUNT(DISTINCT o.customer_id) AS active_customers,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS gross_revenue,
ROUND(AVG(o.total), 2) AS avg_order_value,
SUM(CASE WHEN o.status = 'cancelled' THEN 1 ELSE 0 END) AS cancellations,
ROUND(
SUM(CASE WHEN o.status = 'cancelled' THEN 1 ELSE 0 END)
* 100.0 / COUNT(*), 1
) AS cancel_rate_pct,
-- New customers: first order placed in this month
COUNT(DISTINCT CASE
WHEN o.created_at = (
SELECT MIN(o2.created_at)
FROM orders o2
WHERE o2.customer_id = o.customer_id
) THEN o.customer_id END) AS new_customers
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.created_at >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
GROUP BY DATE_FORMAT(o.created_at, '%Y-%m')
ORDER BY month;SELECT Without FROM
MySQL allows SELECT without a FROM clause. Useful for evaluating expressions,
calling functions, or testing values. MySQL also supports the standard FROM DUAL form.
SELECT 1 + 1 AS result;
SELECT NOW() AS server_time;
SELECT VERSION() AS mysql_version;
SELECT DATABASE() AS current_db;
SELECT USER() AS current_user;
-- Useful for testing functions
SELECT
AES_ENCRYPT('secret', 'key') AS encrypted,
SHA2('password', 256) AS hashed;
-- DUAL is a special dummy table (compatible with Oracle)
SELECT 'Hello, MySQL!' AS greeting FROM DUAL;Performance Notes
SELECT specific columns, not *, to enable covering index optimisations and reduce network transfer.
Ensure WHERE columns are indexed — use EXPLAIN to verify the query plan.
Avoid wrapping indexed columns in functions in WHERE (e.g. WHERE YEAR(created_at) = 2024 prevents index use; use WHERE created_at BETWEEN instead).
Use LIMIT to avoid fetching thousands of rows when only a few are needed.
Scalar subqueries in the SELECT list execute once per row — consider a JOIN or window function for better performance.
DISTINCT forces a deduplication pass — only use it when you genuinely need it, not as a workaround for unwanted duplicates caused by missing JOIN conditions.