The SELECT Statement¶
SQL is how data analysts talk to databases. The SELECT statement is the first sentence you learn — and the one you'll write thousands of times. Every analysis starts here.
Learning Objectives¶
- Understand the anatomy of a SQL query
- Retrieve specific columns from a table
- Use column aliases to rename output
- Write readable, well-formatted SQL
- Understand why
SELECT *is a production anti-pattern
The Anatomy of a SQL Query¶
Every SQL query follows this basic structure:
Two clauses, one semicolon. That's the minimum viable query.
The order SQL executes vs. the order you write it¶
This trips up beginners constantly. You write SELECT first, but SQL evaluates FROM first.
Logical execution order:
1. FROM — which table?
2. WHERE — which rows?
3. GROUP BY — which groups?
4. HAVING — filter groups?
5. SELECT — which columns?
6. ORDER BY — what order?
7. LIMIT — how many rows?
Understanding this explains why you can't use a column alias in a WHERE clause — the alias doesn't exist yet when WHERE runs. It also explains why HAVING exists separately from WHERE.
Why this order matters in practice
If you try SELECT quantity * unit_price AS order_total FROM orders WHERE order_total > 100, you get an error in PostgreSQL (and silent wrong results in some MySQL modes). The alias order_total is defined in step 5 — WHERE runs at step 2 and can't see it yet. Write WHERE quantity * unit_price > 100 instead.
The Sample Schema¶
All examples in this module use these four tables. Familiarise yourself with the structure before writing queries — understanding the data model is step zero.
CREATE TABLE customers (
customer_id VARCHAR(10) PRIMARY KEY,
first_name VARCHAR(50),
last_name VARCHAR(50),
email VARCHAR(100),
city VARCHAR(50),
country VARCHAR(50),
signup_date DATE,
segment VARCHAR(20) -- 'VIP', 'Regular', 'New'
);
CREATE TABLE products (
product_id VARCHAR(10) PRIMARY KEY,
product_name VARCHAR(100),
category VARCHAR(50),
unit_price DECIMAL(10,2),
cost_price DECIMAL(10,2),
stock_qty INT
);
CREATE TABLE orders (
order_id VARCHAR(10) PRIMARY KEY,
customer_id VARCHAR(10), -- references customers.customer_id
product_id VARCHAR(10), -- references products.product_id
order_date DATE,
quantity INT,
unit_price DECIMAL(10,2),
discount_pct DECIMAL(5,2), -- NULL when no discount applied
status VARCHAR(20), -- 'completed', 'pending', 'cancelled', 'refunded'
delivery_date DATE -- NULL when not yet delivered
);
CREATE TABLE order_items (
item_id INT PRIMARY KEY AUTO_INCREMENT,
order_id VARCHAR(10),
product_id VARCHAR(10),
quantity INT,
unit_price DECIMAL(10,2)
);
Relationships:
- Each order belongs to one customer (via customer_id)
- Each order is for one product (via product_id)
- order_items records individual line items for multi-product orders
Selecting Specific Columns¶
Retrieve everything¶
Sample output:
| order_id | customer_id | product_id | order_date | quantity | unit_price | discount_pct | status | delivery_date |
|---|---|---|---|---|---|---|---|---|
| O001 | C001 | P001 | 2024-01-05 | 2 | 149.00 | NULL | completed | 2024-01-08 |
| O002 | C003 | P004 | 2024-01-07 | 1 | 389.00 | 10.00 | completed | 2024-01-12 |
| O003 | C002 | P003 | 2024-01-09 | 3 | 49.99 | NULL | pending | NULL |
Avoid SELECT * in production
SELECT * returns every column. On a table with 50 columns and 10 million rows this wastes memory and query time. If someone adds or removes a column, your query silently changes behaviour. Always name the columns you actually need.
Retrieve specific columns¶
Sample output:
| order_id | customer_id | order_date | unit_price |
|---|---|---|---|
| O001 | C001 | 2024-01-05 | 149.00 |
| O002 | C003 | 2024-01-07 | 389.00 |
| O003 | C002 | 2024-01-09 | 49.99 |
Column Aliases¶
Aliases rename a column in the output — useful for clarity, readability, or when the column is a calculation.
Calculated columns¶
Business question: "What is the gross total for each order?"
SELECT
order_id,
customer_id,
quantity,
unit_price,
quantity * unit_price AS gross_total
FROM orders;
Sample output:
| order_id | customer_id | quantity | unit_price | gross_total |
|---|---|---|---|---|
| O001 | C001 | 2 | 149.00 | 298.00 |
| O002 | C003 | 1 | 389.00 | 389.00 |
| O003 | C002 | 3 | 49.99 | 149.97 |
For mid-level analysts
Calculated columns in SELECT are not stored — they're computed at query time. If you need the same calculation across many queries, consider a view or a generated/computed column (MySQL 5.7+, PostgreSQL 12+). Generated columns store the result physically and can be indexed.
Handling NULL in calculations¶
If discount_pct is NULL, quantity * unit_price * (1 - discount_pct / 100) evaluates to NULL. Use COALESCE to treat NULL as zero:
SELECT
order_id,
customer_id,
quantity * unit_price AS gross_total,
quantity * unit_price * (1 - COALESCE(discount_pct, 0) / 100) AS net_total
FROM orders;
Sample output:
| order_id | customer_id | gross_total | net_total |
|---|---|---|---|
| O001 | C001 | 298.00 | 298.00 |
| O002 | C003 | 389.00 | 350.10 |
| O003 | C002 | 149.97 | 149.97 |
COALESCE(value, default)
Returns the first non-NULL argument. COALESCE(discount_pct, 0) means: "use discount_pct if it has a value; otherwise use 0." This prevents NULL from propagating through arithmetic.
String Concatenation¶
Combining first and last name is a very common operation. Syntax differs between databases.
MySQL / MariaDB:
PostgreSQL / ANSI SQL:
Sample output:
| customer_id | full_name | |
|---|---|---|
| C001 | Priya Sharma | priya@gmail.com |
| C002 | Arjun Mehta | arjun@company.com |
| C003 | Sarah Johnson | sarah@gmail.com |
MySQL vs PostgreSQL — concatenation
The || operator works in PostgreSQL and ANSI SQL but NOT in MySQL by default (MySQL uses it as a logical OR). Always use CONCAT() in MySQL. PostgreSQL supports both.
Formatting Your SQL¶
-- Good: uppercase keywords, one clause per line, aligned columns
SELECT
order_id,
customer_id,
order_date,
quantity * unit_price AS gross_total
FROM orders
WHERE status = 'completed'
ORDER BY gross_total DESC;
-- Bad: everything on one line, no alignment
select order_id,customer_id,order_date,quantity*unit_price as gross_total from orders where status='completed' order by gross_total desc;
Both return identical results. The first one is readable by a colleague at 2am during an incident.
Formatting conventions to follow
- SQL keywords in UPPERCASE
- One clause per line (
SELECT,FROM,WHERE,ORDER BY) - Column list indented, one per line for queries with more than two columns
- Align
ASkeywords using spaces - Semicolon at the end of the statement
Common Mistakes¶
Trailing comma on the last column
Remove the comma after the last column beforeFROM. This is one of the most common beginner errors.
Column names with spaces
Wrap them in backticks (MySQL) or double quotes (PostgreSQL):
Avoid column names with spaces in any schema you design — they cause problems everywhere.Using a SELECT alias in WHERE
Practice Exercises¶
Use the customers table: customer_id, first_name, last_name, email, city, country, signup_date, segment.
Warm-up
1. Retrieve all columns from customers.
2. Retrieve only customer_id, first_name, last_name, and email.
3. Alias first_name as "First Name" and last_name as "Last Name".
Main
4. Return customer names as a single full_name column (first name + space + last name). Use the syntax for your database.
5. Retrieve customer_id, and a calculated days_since_signup — assume today is '2024-03-01'. In MySQL: DATEDIFF('2024-03-01', signup_date). In PostgreSQL: '2024-03-01'::date - signup_date.
6. Add a calculated gross_total column (quantity * unit_price) and net_total (accounting for discount) to a query on the orders table.
Stretch
7. Format customer info for a mailing label: combine first_name, last_name, city, and country into a single mailing_label column. Format: "Priya Sharma, Mumbai, India".
8. From the orders table, calculate the gross margin per order: (unit_price - cost_price) * quantity. You'll need to join products to get cost_price. Write the query with a table alias for each table.
Interview Questions¶
[Beginner] What does SELECT * do, and when should you avoid it?
Show answer
SELECT * retrieves every column in the table. It is fine for ad-hoc exploration (always pair it with LIMIT) but problematic in production code because:
- It transfers unnecessary data, wasting network bandwidth and memory
- If a column is added or removed from the table, the query output changes silently — reports break without an obvious error
- Query optimisers cannot use covering indexes when all columns are selected
In production queries and dashboards, always name the specific columns you need.
[Beginner] What is a column alias and how do you create one?
Show answer
An alias gives a column a different name in the query output. Create one with the AS keyword:
The AS keyword is optional in most databases (quantity * unit_price gross_total also works), but including it makes the query more readable. Aliases are available in ORDER BY but not in WHERE or GROUP BY in most databases.
[Mid-level] Explain the logical execution order of a SQL query. Why does it matter that FROM runs before SELECT?
Show answer
SQL processes clauses in this logical order — not the order you write them:
FROM— identify the table and load rowsWHERE— filter rowsGROUP BY— group rowsHAVING— filter groupsSELECT— compute columns and assign aliasesORDER BY— sort outputLIMIT— return N rows
This order matters because:
- Aliases in WHERE don't work: The alias is defined at step 5, but
WHEREruns at step 2. Use the expression directly:WHERE quantity * unit_price > 100, notWHERE gross_total > 100. - Aliases in ORDER BY do work:
ORDER BYruns at step 6, afterSELECT, so aliases are visible. - HAVING requires GROUP BY first: Aggregate functions in
HAVINGare computed during step 3-4. UsingWHERE COUNT(*) > 5is a syntax error because rows haven't been grouped yet.
[Mid-level] Can you use a column alias in the WHERE clause? Why or why not?
Show answer
No — not in standard SQL or PostgreSQL. The alias defined in SELECT is not available in WHERE because WHERE runs before SELECT in the logical execution order.
-- ERROR in PostgreSQL, silently wrong in some MySQL modes
SELECT quantity * unit_price AS gross_total
FROM orders
WHERE gross_total > 100;
-- CORRECT: repeat the expression
SELECT quantity * unit_price AS gross_total
FROM orders
WHERE quantity * unit_price > 100;
MySQL in some configurations does allow alias references in HAVING but not in WHERE. PostgreSQL is strict about both. Always use the expression directly in WHERE.
[Senior] A junior analyst writes SELECT * FROM large_table in a production dashboard that refreshes every minute on a 200-column, 50-million-row table. What are the problems, and how would you fix them?
Show answer
Problems:
- Data volume: 200 columns × 50M rows × avg 20 bytes/column = ~200GB of data scanned per query. Refreshing every minute means ~288TB of reads per day.
- Network transfer: Only 10–15 columns are probably displayed on the dashboard. Fetching all 200 wastes network bandwidth on every refresh.
- Memory pressure: The dashboard tool must load the full 200-column result into memory before rendering.
- Schema fragility: If the table schema changes (column added, renamed, dropped), the dashboard breaks silently or behaves unexpectedly.
- Index incompatibility:
SELECT *prevents use of covering indexes — the database must access the heap for every row.
How to fix it:
1. Replace SELECT * with explicit column names for only the 10–15 columns the dashboard renders.
2. Add a WHERE clause to filter to the relevant date range (last 30 days, current month, etc.).
3. Consider materialising the query result into a summary table refreshed every 5 minutes via a scheduled job — the dashboard queries the small summary, not the raw 50M-row table.
4. Add indexes on the WHERE and ORDER BY columns if they don't already exist.
Previous: 00-agenda | Next: 02-where-clause