BlogData Analytics BasicsChapter 6
BASICS · CHAPTER 6Beginner → Advanced

SQL for Data Analysts — Complete Guide

From your first SELECT to window functions — every SQL concept a data analyst needs, each with an Indian business example and the reasoning behind it. Built for interview prep and real analyst work.

SELECTWHEREGROUP BY & AggregationsJOINsCASE WHENSubqueriesCTEsWindow Functions
DATA ANALYTICS SERIES:← Ch 5: DatabasesCh 6: SQL Complete ←Ch 7: Excel Complete →
SQL LEARNING ROADMAP — WHAT THIS CHAPTER COVERS
SELECT, WHERE
Beginner
GROUP BY, HAVING
Beginner
JOINs
Intermediate
CASE WHEN, Subqueries
Intermediate
CTEs
Int–Adv
Window Functions
Advanced

SELECT — Reading Data from a Table

Beginner

SELECT is the foundation of every SQL query. It specifies which columns to retrieve from which table.

Select all columns
SELECT *
FROM orders;
-- Returns every column and every row — useful for exploring a table
Select specific columns
SELECT order_id, customer_id, order_date, amount_inr
FROM orders;
Add a calculated column
SELECT
    order_id,
    amount_inr,
    amount_inr * 0.18              AS gst_amount,
    amount_inr + amount_inr * 0.18 AS total_with_gst
FROM orders;
Rename columns with aliases
SELECT
    order_id       AS "Order Number",
    amount_inr     AS "Order Value (₹)",
    status         AS "Order Status"
FROM orders;
ANALYST TIP: In production, never use SELECT * in queries that other systems rely on — if a column is added to the table, it can break downstream dashboards. Explicitly list the columns you need.

WHERE — Filtering Rows

Beginner

WHERE filters which rows to include. Only rows where the condition is TRUE are returned.

Filter by city
SELECT order_id, customer_id, amount_inr
FROM orders
WHERE city = 'Noida';
Multiple conditions (AND / OR)
SELECT order_id, amount_inr, status
FROM orders
WHERE city IN ('Noida', 'Delhi', 'Gurgaon')
  AND status = 'delivered'
  AND amount_inr > 500;
Filter by date range
SELECT order_id, order_date, amount_inr
FROM orders
WHERE order_date BETWEEN '2026-08-01' AND '2026-08-31';
Text pattern matching with LIKE
SELECT customer_id, name, email
FROM customers
WHERE email LIKE '%@gmail.com'   -- ends with @gmail.com
   OR name LIKE 'Priya%';        -- starts with Priya
Find NULL values
SELECT order_id, delivery_date
FROM orders
WHERE delivery_date IS NULL;     -- Orders not yet delivered
-- NEVER write: WHERE delivery_date = NULL (this always returns nothing)
ANALYST TIP: NULL is not a value — it is the absence of a value. The only way to test for NULL is IS NULL or IS NOT NULL. Equality checks (= NULL) always return unknown (not true), so no rows are returned.

GROUP BY & Aggregations — Summarising Data

Beginner–Intermediate

GROUP BY collapses many rows into one summary row per group. It is always paired with aggregate functions: COUNT, SUM, AVG, MAX, MIN.

Count orders by status
SELECT status, COUNT(*) AS order_count
FROM orders
GROUP BY status
ORDER BY order_count DESC;
Revenue and order count by city
SELECT
    city,
    COUNT(order_id)        AS total_orders,
    SUM(amount_inr)        AS total_revenue_inr,
    ROUND(AVG(amount_inr), 0) AS avg_order_value_inr
FROM orders
WHERE status != 'cancelled'
GROUP BY city
ORDER BY total_revenue_inr DESC;
Monthly revenue trend
SELECT
    DATE_FORMAT(order_date, '%Y-%m') AS month,
    COUNT(order_id)                   AS orders,
    ROUND(SUM(amount_inr), 0)         AS revenue_inr
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
HAVING — filter groups after aggregation
-- Cities with at least 100 orders AND avg order value above ₹1,000
SELECT
    city,
    COUNT(order_id)           AS orders,
    ROUND(AVG(amount_inr), 0) AS aov_inr
FROM orders
GROUP BY city
HAVING COUNT(order_id) >= 100
   AND AVG(amount_inr)   > 1000
ORDER BY aov_inr DESC;
ANALYST TIP: Every column in SELECT must either be in GROUP BY or inside an aggregate function (SUM, COUNT, AVG, MAX, MIN). If you get an "Expression not in GROUP BY" error, check this rule.

JOINs — Combining Multiple Tables

Intermediate

JOINs combine rows from two or more tables based on a matching column — typically a foreign key and primary key relationship.

INNER JOIN — only matching rows from both tables
-- Orders with customer names (only orders that have a matching customer)
SELECT
    o.order_id,
    c.name          AS customer_name,
    c.city,
    o.amount_inr,
    o.status
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
ORDER BY o.order_date DESC;
LEFT JOIN — all rows from left table, NULLs if no match
-- All customers, whether or not they have placed an order
SELECT
    c.customer_id,
    c.name,
    c.city,
    COUNT(o.order_id)  AS total_orders,
    COALESCE(SUM(o.amount_inr), 0) AS total_spent_inr
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name, c.city
ORDER BY total_spent_inr DESC;
-- Customers with 0 orders appear with total_orders = 0
Join three tables — orders + customers + products
SELECT
    o.order_id,
    c.name            AS customer_name,
    p.product_name,
    p.category,
    o.quantity,
    o.amount_inr
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
JOIN products  p ON o.product_id  = p.product_id
WHERE o.order_date >= '2026-08-01'
ORDER BY o.amount_inr DESC
LIMIT 20;
Find customers with NO orders (anti-join)
-- LEFT JOIN with NULL check = rows in left table with no match in right
SELECT c.customer_id, c.name, c.city, c.signup_date
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL   -- no matching order found
ORDER BY c.signup_date DESC;
ANALYST TIP: INNER JOIN is the default — it only returns rows where the join condition is met in BOTH tables. LEFT JOIN returns all rows from the left table regardless. If you are not sure which to use, ask: do I want to exclude records that have no match in the other table (INNER) or keep them (LEFT)?

CASE WHEN — Conditional Logic

Intermediate

CASE WHEN works like an IF-ELSE statement inside SQL. It creates new columns based on conditions.

Categorise order value into tiers
SELECT
    order_id,
    amount_inr,
    CASE
        WHEN amount_inr < 500               THEN 'Low Value'
        WHEN amount_inr BETWEEN 500 AND 1999 THEN 'Mid Value'
        WHEN amount_inr >= 2000             THEN 'High Value'
        ELSE 'Unknown'
    END AS value_tier
FROM orders;
Count orders in each tier
SELECT
    CASE
        WHEN amount_inr < 500               THEN 'Low Value'
        WHEN amount_inr BETWEEN 500 AND 1999 THEN 'Mid Value'
        ELSE 'High Value'
    END AS value_tier,
    COUNT(*)            AS order_count,
    SUM(amount_inr)     AS tier_revenue
FROM orders
GROUP BY value_tier
ORDER BY tier_revenue DESC;
Pivot — count returns by category in one row
SELECT
    COUNT(CASE WHEN status = 'delivered' THEN 1 END) AS delivered,
    COUNT(CASE WHEN status = 'returned'  THEN 1 END) AS returned,
    COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled,
    COUNT(*) AS total
FROM orders
WHERE order_date BETWEEN '2026-08-01' AND '2026-08-31';
ANALYST TIP: CASE WHEN is one of the most powerful SQL tools for analysts — it lets you reshape raw data into business categories without changing the source table.

Subqueries — Queries Within Queries

Intermediate

A subquery is a query nested inside another query. It lets you use the result of one query as the input for another.

Subquery in WHERE — orders above average value
SELECT order_id, customer_id, amount_inr
FROM orders
WHERE amount_inr > (
    SELECT AVG(amount_inr)
    FROM orders
    WHERE status = 'delivered'
)
ORDER BY amount_inr DESC;
Subquery in FROM — derived table
-- Monthly revenue summary and its rank
SELECT
    month,
    revenue_inr,
    RANK() OVER (ORDER BY revenue_inr DESC) AS revenue_rank
FROM (
    SELECT
        DATE_FORMAT(order_date, '%Y-%m') AS month,
        SUM(amount_inr)                  AS revenue_inr
    FROM orders
    WHERE status != 'cancelled'
    GROUP BY DATE_FORMAT(order_date, '%Y-%m')
) monthly_revenue
ORDER BY month;
ANALYST TIP: When a subquery is used repeatedly, rewrite it as a CTE (WITH clause) — it makes the query readable and is usually faster.

CTEs — Common Table Expressions

Intermediate–Advanced

A CTE (WITH clause) is a named temporary result that you can reference within a single query. It makes complex queries readable by breaking them into named steps.

CTE to find top customers in each city
WITH customer_revenue AS (
    SELECT
        c.customer_id,
        c.name,
        c.city,
        SUM(o.amount_inr) AS total_spent_inr
    FROM customers c
    JOIN orders o ON o.customer_id = c.customer_id
    WHERE o.status = 'delivered'
    GROUP BY c.customer_id, c.name, c.city
),
ranked_customers AS (
    SELECT
        *,
        RANK() OVER (PARTITION BY city ORDER BY total_spent_inr DESC) AS city_rank
    FROM customer_revenue
)
SELECT customer_id, name, city, total_spent_inr, city_rank
FROM ranked_customers
WHERE city_rank <= 3
ORDER BY city, city_rank;
CTE chain — month-over-month revenue change
WITH monthly_revenue AS (
    SELECT
        DATE_FORMAT(order_date, '%Y-%m') AS month,
        SUM(amount_inr)                  AS revenue_inr
    FROM orders
    WHERE status != 'cancelled'
    GROUP BY DATE_FORMAT(order_date, '%Y-%m')
),
mom_change AS (
    SELECT
        month,
        revenue_inr,
        LAG(revenue_inr) OVER (ORDER BY month) AS prev_revenue_inr
    FROM monthly_revenue
)
SELECT
    month,
    revenue_inr,
    prev_revenue_inr,
    ROUND(100.0 * (revenue_inr - prev_revenue_inr) / prev_revenue_inr, 1) AS mom_growth_pct
FROM mom_change
ORDER BY month;
ANALYST TIP: CTEs are not stored permanently — they exist only for the duration of the query. You can define multiple CTEs in one WITH block, separated by commas, and each CTE can reference the ones defined before it.

Window Functions — Advanced Analytics

Advanced

Window functions perform calculations across a set of rows related to the current row — without collapsing rows like GROUP BY does. They are essential for ranking, running totals, period comparisons, and cohort analysis.

RANK — rank customers by revenue within their city
SELECT
    c.name,
    c.city,
    SUM(o.amount_inr) AS total_spent,
    RANK() OVER (
        PARTITION BY c.city
        ORDER BY SUM(o.amount_inr) DESC
    ) AS rank_in_city
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name, c.city;
ROW_NUMBER — find duplicates
SELECT *
FROM (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY signup_date ASC
        ) AS row_num
    FROM customers
) deduped
WHERE row_num > 1;   -- All rows that are duplicates
LAG — compare each month to the previous month
SELECT
    DATE_FORMAT(order_date, '%Y-%m')                              AS month,
    SUM(amount_inr)                                               AS revenue,
    LAG(SUM(amount_inr)) OVER (ORDER BY DATE_FORMAT(order_date, '%Y-%m'))
                                                                  AS prev_revenue,
    ROUND(100.0 * (SUM(amount_inr) - LAG(SUM(amount_inr)) OVER (ORDER BY DATE_FORMAT(order_date, '%Y-%m')))
          / LAG(SUM(amount_inr)) OVER (ORDER BY DATE_FORMAT(order_date, '%Y-%m')), 1) AS mom_pct
FROM orders
WHERE status != 'cancelled'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
Running total — cumulative revenue by month
SELECT
    month,
    monthly_revenue,
    SUM(monthly_revenue) OVER (
        ORDER BY month
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_revenue
FROM (
    SELECT
        DATE_FORMAT(order_date, '%Y-%m') AS month,
        SUM(amount_inr)                  AS monthly_revenue
    FROM orders
    WHERE status != 'cancelled'
    GROUP BY DATE_FORMAT(order_date, '%Y-%m')
) m
ORDER BY month;
ANALYST TIP: Window functions never collapse rows — the query returns the same number of rows as the input, but adds calculated columns. This is what makes them different from GROUP BY aggregations.

SQL Interview Cheat Sheet — Quick Reference

ConceptWhen to UseCommon Mistake
WHERE vs HAVINGWHERE: filter before GROUP BY. HAVING: filter after aggregation.Using WHERE with SUM/AVG/COUNT — use HAVING instead.
INNER vs LEFT JOININNER: only matched rows. LEFT: all left table rows, NULL if no match.Using INNER JOIN when you need to keep unmatched rows.
GROUP BY ruleEvery non-aggregate SELECT column must be in GROUP BY."Expression not in GROUP BY" error — check each column.
NULL handlingUse IS NULL, not = NULL. COALESCE to replace NULL with a default.SUM(NULL) = NULL not 0. Use COALESCE(SUM(col), 0).
Window PARTITION BYReset ranking/running totals per group (per city, per month).Forgetting PARTITION BY — function runs across all rows.
LAG / LEADLAG = previous row value. LEAD = next row value. Need ORDER BY.No ORDER BY in OVER() — result is non-deterministic.
CTE vs SubqueryCTE is readable, reusable in same query. Subquery is single-use.Deeply nested subqueries — refactor to CTEs.
RANK vs DENSE_RANKRANK skips numbers after a tie (1,1,3). DENSE_RANK does not (1,1,2).Second-highest salary — use DENSE_RANK not RANK to avoid skips.
Continue the Series
← Ch 5: Intro to DatabasesCh 7: Excel Complete Guide →

Frequently Asked Questions

How long does it take to learn SQL for a data analyst job?

With consistent daily practice (1–2 hours), most beginners reach a job-ready level of SQL in 6–10 weeks. Week 1–2: SELECT, WHERE, ORDER BY, basic aggregations. Week 3–4: GROUP BY, HAVING, multiple table JOINs. Week 5–6: Subqueries, CASE WHEN, date functions. Week 7–8: Window functions (ROW_NUMBER, RANK, LAG, LEAD, running totals). Week 9–10: CTEs, query optimisation basics, writing real analyst queries from scratch. The key is practice on real datasets — not just memorising syntax. Many candidates in India learn SQL theory but cannot write a query from a blank screen, which is what interviews test.

Which SQL should I learn — MySQL, PostgreSQL, or SQL Server?

Learn MySQL or PostgreSQL first — both use standard SQL and are widely used in India. The core syntax (SELECT, JOIN, GROUP BY, WHERE, HAVING, window functions, CTEs) is 95% identical across all SQL dialects. The 5% that differs is mainly in date functions, string functions, and some edge cases. In Indian companies: startups and e-commerce typically use MySQL or PostgreSQL; large enterprises use Oracle or SQL Server; analytics on cloud data warehouses uses BigQuery (Google) or Redshift (AWS) which are both SQL-based. Mastering one SQL dialect transfers immediately to others. Do not wait until you know which dialect your target company uses — start with MySQL or PostgreSQL today.

What is the difference between WHERE and HAVING in SQL?

WHERE filters individual rows before aggregation. HAVING filters groups after aggregation. WHERE runs first — it removes rows from the dataset. Then GROUP BY groups what remains. Then HAVING removes groups that do not meet the condition. Example: to find cities where average order value is above ₹1,000, you cannot use WHERE avg(order_amount) > 1000 — WHERE does not know about aggregated values yet. You must use GROUP BY city HAVING AVG(order_amount) > 1000. A practical rule: if you are filtering on an aggregate function (SUM, AVG, COUNT, MAX, MIN), use HAVING. If you are filtering on a raw column value, use WHERE.

What SQL questions are asked in data analyst interviews in India?

Common SQL interview questions in India for data analyst roles: (1) Write a query to find the top 3 customers by revenue in each city — requires window function RANK() OVER (PARTITION BY city ORDER BY revenue DESC). (2) Find month-over-month revenue change — requires LAG() window function or self-join. (3) Find customers who placed an order in January but not in February — requires LEFT JOIN with NULL check or NOT EXISTS. (4) Find duplicate records in a table — requires GROUP BY + HAVING COUNT > 1. (5) What is the difference between INNER JOIN, LEFT JOIN, and FULL OUTER JOIN — conceptual. (6) Write a query to find the second-highest salary — requires LIMIT 1 OFFSET 1 or DENSE_RANK(). (7) Explain NULL handling in SQL — IS NULL vs = NULL. Companies like Flipkart, Swiggy, PhonePe, Paytm, and analytics consulting firms ask questions in this range.

EVIKA ACADEMY · NOIDA SECTOR 51

Practice SQL on Real Indian Datasets

Every SQL concept in this guide is taught with hands-on practice — real e-commerce, banking, and logistics data. Mock interviews with the exact question types companies ask.

Book Free Demo Class →
🎓 Free Demo Class — Online & Offline · Noida Sector 51