SQL for Data Analysts: Essential Skills & Query Guide
The SQL Skills That Actually Land You a Data Analyst Job

The SQL Skills That Actually Land You a Data Analyst Job

The SQL Skills That Actually Land You a Data Analyst Job
If you've spent any time researching how to become a data analyst, you probably already know the answer to what to learn first. It's SQL. A 2026 analysis by 365 Data Science looked at over 1,000 data analyst job postings and found SQL showing up in roughly 53% of them, more than Excel, more than Python, more than anything else on the list.
But "learn SQL" isn't really one skill, it's a whole category of them. Plenty of analysts can write a basic SELECT statement and still freeze up the moment they're asked to join four tables, clean up a column full of NULLs, or explain why a query is taking forever to run. This guide walks through what actually matters, roughly in the order you'll need it, with examples you can run yourself, starting with why SQL is worth the effort in the first place.
Every data analyst job, no matter the industry, comes down to the same loop. Get the data, shape the data, explain the data. SQL is how most companies store and serve that data, which is why it shows up more consistently in job postings than any visualization or programming tool.
A few reasons SQL stays essential even as AI tools get better at writing queries for you.
It's the interface to the data itself. Dashboards and BI tools sit on top of SQL, and someone still has to define the underlying queries.
It's tested in interviews. Live SQL rounds, whether whiteboard or shared screen, are standard at most companies hiring analysts.
It scales with you. The same core syntax works whether you're pulling 100 rows in MySQL or aggregating a billion rows in BigQuery.
It sharpens how you think. Writing a good SQL query forces you to be precise about the question you're actually asking, before you even get to the answer.
The rest of this guide covers the specific skills worth mastering, roughly in the order they tend to come up on the job.
Joins combine rows from two or more tables based on a related column. This is probably the single most tested SQL skill in analyst interviews, because almost no real business question can be answered from one table alone.
Join Type | What It Returns | Common Use Case |
|---|---|---|
INNER JOIN | Only rows with matches in both tables | Orders that have a matching customer record |
LEFT JOIN | All rows from the left table, matched rows from the right (NULL if no match) | All customers, even ones with zero orders |
RIGHT JOIN | All rows from the right table, matched rows from the left | Rarely used, usually rewritten as a LEFT JOIN |
FULL OUTER JOIN | All rows from both tables, matched where possible | Reconciling two lists that should mostly overlap |
SELF JOIN | A table joined to itself | Comparing employees to their managers in the same table |
CROSS JOIN | Every row from table A paired with every row from table B | Generating all date x product combinations |
Here's an example using customers and their orders.
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.order_amount
FROM customers c
LEFT JOIN orders o
ON c.customer_id = o.customer_id
ORDER BY c.customer_id;
Using LEFT JOIN here instead of INNER JOIN is deliberate. It keeps customers who haven't placed any orders, which matters if you're calculating something like the percentage of customers who never ordered.
A habit worth building. When you're asked to join tables in an interview, ask out loud what should happen to unmatched rows. That one question shows you understand joins conceptually, not just the syntax.
Aggregations collapse many rows into a summary, usually grouped by some category.
Function | Purpose |
|---|---|
COUNT() | Number of rows (or non NULL values in a column) |
SUM() | Total of a numeric column |
AVG() | Average of a numeric column |
MIN() / MAX() | Smallest or largest value |
GROUP BY | Defines the categories to aggregate within |
HAVING | Filters groups after aggregation, unlike WHERE, which filters rows before |
Here's an example calculating monthly revenue by region.
SELECT
region,
DATE_TRUNC('month', order_date) AS order_month,
SUM(order_amount) AS total_revenue,
COUNT(order_id) AS total_orders
FROM orders
GROUP BY region, DATE_TRUNC('month', order_date)
HAVING SUM(order_amount) > 100000
ORDER BY order_month, total_revenue DESC;
A common mistake is putting an aggregate condition in WHERE instead of HAVING. WHERE runs before grouping, so it can't reference an aggregated value like SUM(order_amount).
A subquery is a query nested inside another query. They come in handy when you need an intermediate result before your main logic can run.
A scalar subquery returns a single value.
SELECT
order_id,
order_amount
FROM orders
WHERE order_amount > (SELECT AVG(order_amount) FROM orders);
A correlated subquery references the outer query and runs once per row.
SELECT
c.customer_id,
c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_amount > 5000
);
Subqueries and CTEs (the WITH clause) often do the same job, but CTEs are almost always more readable once the logic gets past a single line. Most analysts default to CTEs today and save inline subqueries for short, simple filters.
Window functions perform calculations across a set of rows related to the current row, without collapsing the result into a single row per group the way GROUP BY does. This is the skill that tends to separate "knows SQL" from "knows SQL well."
Function | What It Does |
|---|---|
ROW_NUMBER() | Assigns a unique sequential number to each row |
RANK() | Assigns a rank, with ties sharing a rank and a gap after |
DENSE_RANK() | Same as RANK(), but no gap after ties |
LAG() / LEAD() | Looks at the previous or next row's value |
SUM() OVER() | Running or grouped total without collapsing rows |
Here's an example finding the top order per customer.
SELECT *
FROM (
SELECT
customer_id,
order_id,
order_amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_amount DESC
) AS rn
FROM orders
) ranked
WHERE rn = 1;
And here's one for a running monthly total.
SELECT
order_month,
monthly_revenue,
SUM(monthly_revenue) OVER (ORDER BY order_month) AS running_total
FROM monthly_summary;
Window functions are usually the topic analysts feel least confident about, and also the one that comes up most in mid to senior interviews. It's worth deliberately practicing ten or fifteen problems instead of just reading about them.
NULL means unknown or absent, not zero and not an empty string. Mishandling NULLs is one of the most common ways a dashboard ends up quietly wrong.
A few things to keep in mind.
Use IS NULL or IS NOT NULL. Never = NULL, since that always evaluates to unknown rather than true or false.
COUNT(column) skips NULLs, but COUNT(*) counts every row regardless.
Any arithmetic or string operation involving NULL returns NULL. 5 + NULL is NULL, not 5.
A NULL in a join key means that row won't match in an INNER JOIN, even if conceptually it should.
COALESCE is probably the single most useful NULL handling function.
SELECT
customer_id,
COALESCE(phone_number, 'Not Provided') AS phone_number
FROM customers;
NULLIF turns a specific value into NULL, which is handy for avoiding a divide by zero error.
SELECT
order_id,
total_amount / NULLIF(quantity, 0) AS price_per_unit
FROM orders;
Without NULLIF, a quantity of 0 would throw a division error and break the entire query.
CASE WHEN lets you build conditional, business readable logic directly into a query, turning raw values into categories a stakeholder can actually use.
SELECT
order_id,
order_amount,
CASE
WHEN order_amount >= 10000 THEN 'High Value'
WHEN order_amount >= 2000 THEN 'Mid Value'
ELSE 'Low Value'
END AS order_segment
FROM orders;
order_id | order_amount | order_segment |
|---|---|---|
101 | 12,500 | High Value |
102 | 3,200 | Mid Value |
103 | 850 | Low Value |
CASE WHEN also pairs well with aggregation to "pivot" data when there's no native PIVOT function available.
SELECT
region,
SUM(CASE WHEN order_status = 'Completed' THEN order_amount ELSE 0 END) AS completed_revenue,
SUM(CASE WHEN order_status = 'Refunded' THEN order_amount ELSE 0 END) AS refunded_revenue
FROM orders
GROUP BY region;
You don't need to be a database administrator, but understanding indexes helps you explain, and sometimes fix, why a query is slow, which becomes a genuinely useful skill once you're working with large tables.
What an index actually does. It's a separate data structure, usually a B tree, that lets the database find rows without scanning the entire table, similar to an index at the back of a book.
Concept | What It Means |
|---|---|
Full table scan | Database reads every row, which is slow on large tables |
Index scan | Database uses the index to jump straight to relevant rows |
Primary key | Automatically indexed, uniquely identifies each row |
Composite index | An index on multiple columns together, useful when queries filter on the same combination repeatedly |
EXPLAIN / EXPLAIN ANALYZE | Shows the query execution plan, the first thing to check when a query is slow |
Checking a query's execution plan in PostgreSQL or MySQL.
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE customer_id = 4521;
If customer_id isn't indexed, this query scans the entire orders table. As an analyst, you typically won't be creating indexes on production systems, but knowing how to read an execution plan helps you figure out why a query is timing out and have a more informed conversation with a data engineer about it.
Core SQL is portable, but each platform has its own dialect quirks. Knowing the differences saves real debugging time when you switch between tools on the job.
Feature | MySQL | PostgreSQL | BigQuery |
|---|---|---|---|
Row limiting | LIMIT 10 | LIMIT 10 | LIMIT 10 |
String concatenation | CONCAT(a, b) | a || b or CONCAT(a,b) | CONCAT(a, b) |
Current date | CURDATE() | CURRENT_DATE | CURRENT_DATE() |
Auto increment ID | AUTO_INCREMENT | SERIAL / IDENTITY | Not applicable, no native auto increment |
Window functions | Supported (8.0+) | Fully supported | Fully supported |
Regex matching | REGEXP | ~ | REGEXP_CONTAINS() |
Pricing model | Free / self hosted | Free / self hosted | Pay per query, based on data scanned |
Best for practising | General SQL fundamentals | Advanced SQL (CTEs, window functions, JSON) | Large scale analytics, cloud native workflows |
A few ways to actually practice.
Start with MySQL or PostgreSQL, either locally or through a free sandbox. The fundamentals transfer everywhere.
Move to BigQuery once you're comfortable, since many companies run analytics on cloud warehouses like BigQuery, Snowflake or Redshift rather than traditional databases.
Practice on data that's actually messy, with duplicate rows, missing values and inconsistent formatting, not just clean textbook tables. It's a lot closer to what the job actually looks like.
Rebuild queries from scratch instead of copying solutions. The goal is recognizing patterns, not memorizing answers.
Here's a query that pulls together joins, CASE WHEN, NULL handling and a window function. It's the kind of query you might actually end up writing on the job.
Say a stakeholder wants to know each customer's total spend, when they last ordered, and how they rank against other customers in their region.
SELECT
c.customer_id,
c.customer_name,
c.region,
COALESCE(SUM(o.order_amount), 0) AS total_spend,
MAX(o.order_date) AS last_order_date,
CASE
WHEN COALESCE(SUM(o.order_amount), 0) >= 50000 THEN 'VIP'
WHEN COALESCE(SUM(o.order_amount), 0) >= 10000 THEN 'Regular'
ELSE 'New / Low Activity'
END AS customer_tier,
RANK() OVER (
PARTITION BY c.region
ORDER BY COALESCE(SUM(o.order_amount), 0) DESC
) AS regional_rank
FROM customers c
LEFT JOIN orders o
ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region
ORDER BY c.region, regional_rank;
This single query answers a segmentation question, a recency question and a ranking question at once. That's exactly the kind of multi part logic that shows up in both interviews and real dashboards.
Using INNER JOIN by default. It silently drops rows and skews totals when a LEFT JOIN is what the question actually needs.
Filtering aggregates in WHERE instead of HAVING.
Forgetting that NULLs break equals comparisons. Always use IS NULL instead.
Not partitioning window functions correctly. A missing PARTITION BY quietly changes the entire calculation.
Ignoring query performance until it becomes a production problem. Get comfortable reading EXPLAIN output early.
Memorizing syntax instead of understanding the logic behind it. Dialects change over time, but the reasoning behind joins, aggregation and window functions mostly doesn't.
You can learn the fundamentals from documentation and practice sites on your own. Getting from "I can write a query" to "I can confidently handle the SQL round in an interview, and the SQL work in the job" usually takes structured practice on real, messy datasets with actual feedback on what you got wrong. If that's the stage you're at, it's worth looking into a structured data analytics program that builds these exact skills through real project work instead of isolated exercises.
FAQ