Skip to content
Back to Blog
SQL & data October 14, 2026 10 min read

SQL Queries for Data Analysis: 10 Practical Examples (PostgreSQL)

Ten SQL queries that answer real business questions, from revenue by city to month-over-month growth and repeat customers, all on one consistent sales schema.

SQL Queries for Data Analysis: 10 Practical Examples (PostgreSQL)

Which SQL queries does a data analyst really use?

A handful of patterns cover most day-to-day analysis: filter rows (WHERE), aggregate them (GROUP BY, HAVING), combine tables (JOIN), structure the logic in steps (WITH / CTE) and compare rows with each other (window functions). If you master the ten queries below, you can answer most questions a sales, finance or marketing team will ask, and pass most SQL interview tests.

All examples use PostgreSQL syntax and the same small sales schema, so you can run them one after the other. Amounts are in MAD.

The example schema

Four tables: customers, products, orders and the lines of each order. Revenue is always computed from order_items (quantity × unit price at the time of sale), and only delivered orders count.

CREATE TABLE customers (
    customer_id  INTEGER PRIMARY KEY,
    full_name    TEXT NOT NULL,
    city         TEXT,
    signup_date  DATE NOT NULL
);

CREATE TABLE products (
    product_id    INTEGER PRIMARY KEY,
    product_name  TEXT NOT NULL,
    category      TEXT NOT NULL,
    unit_price    NUMERIC(10, 2) NOT NULL
);

CREATE TABLE orders (
    order_id     INTEGER PRIMARY KEY,
    customer_id  INTEGER NOT NULL REFERENCES customers (customer_id),
    order_date   DATE NOT NULL,
    status       TEXT NOT NULL CHECK (status IN ('pending', 'delivered', 'cancelled'))
);

CREATE TABLE order_items (
    order_id    INTEGER NOT NULL REFERENCES orders (order_id),
    product_id  INTEGER NOT NULL REFERENCES products (product_id),
    quantity    INTEGER NOT NULL CHECK (quantity > 0),
    unit_price  NUMERIC(10, 2) NOT NULL,
    PRIMARY KEY (order_id, product_id)
);
TableGrain (one row = …)Key columns
customersone customercustomer_id, city, signup_date
productsone productproduct_id, category, unit_price
ordersone orderorder_id, customer_id, order_date, status
order_itemsone product line in an orderorder_id, product_id, quantity, unit_price

Knowing the grain of each table is what prevents double counting later. The same idea is at the heart of a star schema in Power BI.

How do you filter and sort data in SQL?

1. Delivered orders from Casablanca since January

SELECT
    o.order_id,
    o.order_date,
    c.full_name
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE c.city = 'Casablanca'
  AND o.status = 'delivered'
  AND o.order_date >= DATE '2026-01-01'
ORDER BY o.order_date DESC
LIMIT 20;

Use the DATE '…' literal for dates and always add ORDER BY when you use LIMIT, otherwise the rows returned are not guaranteed.

How do you aggregate data with GROUP BY and HAVING?

2. Revenue and orders by city

SELECT
    c.city,
    COUNT(DISTINCT o.order_id)        AS delivered_orders,
    SUM(oi.quantity * oi.unit_price)  AS revenue_mad
FROM orders AS o
JOIN customers   AS c  ON c.customer_id = o.customer_id
JOIN order_items AS oi ON oi.order_id   = o.order_id
WHERE o.status = 'delivered'
GROUP BY c.city
HAVING COUNT(DISTINCT o.order_id) >= 10
ORDER BY revenue_mad DESC;

WHERE filters rows before grouping; HAVING filters groups after aggregation. Here, only cities with at least 10 delivered orders are kept. COUNT(DISTINCT o.order_id) is needed because the join with order_items multiplies rows: one order with three lines would otherwise be counted three times.

3. Revenue share by category

SELECT
    p.category,
    SUM(oi.quantity)                  AS units_sold,
    SUM(oi.quantity * oi.unit_price)  AS revenue_mad,
    ROUND(
        100.0 * SUM(oi.quantity * oi.unit_price)
        / SUM(SUM(oi.quantity * oi.unit_price)) OVER (),
        1
    )                                 AS share_of_revenue_pct
FROM order_items AS oi
JOIN orders   AS o ON o.order_id   = oi.order_id
JOIN products AS p ON p.product_id = oi.product_id
WHERE o.status = 'delivered'
GROUP BY p.category
ORDER BY revenue_mad DESC;

The expression SUM(SUM(…)) OVER () is a window function applied on top of the aggregate: it returns the grand total on every row, which gives each category's share without a second query.

4. Average and median order value

WITH order_totals AS (
    SELECT
        o.order_id,
        SUM(oi.quantity * oi.unit_price) AS order_total
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.order_id
    WHERE o.status = 'delivered'
    GROUP BY o.order_id
)
SELECT
    COUNT(*)                                                 AS orders,
    ROUND(AVG(order_total), 2)                               AS avg_order_value_mad,
    percentile_cont(0.5) WITHIN GROUP (ORDER BY order_total) AS median_order_value_mad,
    MAX(order_total)                                         AS max_order_value_mad
FROM order_totals;

The CTE first computes one total per order (the right grain), then the outer query aggregates. Always look at the median next to the average: a few very large orders can pull the average far from a "typical" basket.

How do you find missing records with a LEFT JOIN?

5. Customers who never received an order

SELECT
    c.customer_id,
    c.full_name,
    c.city,
    c.signup_date
FROM customers AS c
LEFT JOIN orders AS o
       ON o.customer_id = c.customer_id
      AND o.status = 'delivered'
WHERE o.order_id IS NULL
ORDER BY c.signup_date;

This is the classic anti-join. The status filter is placed in the ON clause, not in WHERE: putting it in WHERE would remove the customers without orders and the query would return nothing useful. The same result can be written with NOT EXISTS, which many teams find more readable.

How do you create segments with CASE WHEN?

6. Orders by basket size

WITH order_totals AS (
    SELECT
        o.order_id,
        SUM(oi.quantity * oi.unit_price) AS order_total
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.order_id
    WHERE o.status = 'delivered'
    GROUP BY o.order_id
)
SELECT
    CASE
        WHEN order_total < 1000 THEN '1. < 1,000 MAD'
        WHEN order_total < 5000 THEN '2. 1,000-4,999 MAD'
        ELSE '3. >= 5,000 MAD'
    END               AS basket_size,
    COUNT(*)          AS orders,
    SUM(order_total)  AS revenue_mad
FROM order_totals
GROUP BY 1
ORDER BY 1;

The numeric prefix in each label keeps the segments in a logical order when sorting by text. Adjust the thresholds to your own business.

How do you calculate month-over-month growth in SQL?

7. Monthly revenue, growth and year-to-date total

WITH monthly AS (
    SELECT
        date_trunc('month', o.order_date)::date AS month,
        SUM(oi.quantity * oi.unit_price)        AS revenue_mad
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.order_id
    WHERE o.status = 'delivered'
    GROUP BY 1
)
SELECT
    month,
    revenue_mad,
    LAG(revenue_mad) OVER (ORDER BY month) AS previous_month_mad,
    ROUND(
        100.0 * (revenue_mad - LAG(revenue_mad) OVER (ORDER BY month))
        / NULLIF(LAG(revenue_mad) OVER (ORDER BY month), 0),
        1
    )                                      AS mom_growth_pct,
    SUM(revenue_mad) OVER (
        PARTITION BY EXTRACT(YEAR FROM month)
        ORDER BY month
    )                                      AS ytd_revenue_mad
FROM monthly
ORDER BY month;
  • date_trunc('month', …) groups all dates of a month together.
  • LAG() reads the previous month's value on the same row.
  • NULLIF(…, 0) avoids a division-by-zero error.
  • SUM() OVER (PARTITION BY year ORDER BY month) gives a running year-to-date total that restarts each January.

This is the SQL equivalent of the "versus last month" comparison every KPI card should show (what makes a good KPI).

How do you rank and deduplicate rows with window functions?

8. Top 3 products per category

WITH product_sales AS (
    SELECT
        p.category,
        p.product_name,
        SUM(oi.quantity * oi.unit_price) AS revenue_mad
    FROM order_items AS oi
    JOIN orders   AS o ON o.order_id   = oi.order_id
    JOIN products AS p ON p.product_id = oi.product_id
    WHERE o.status = 'delivered'
    GROUP BY p.category, p.product_name
),
ranked AS (
    SELECT
        category,
        product_name,
        revenue_mad,
        DENSE_RANK() OVER (PARTITION BY category ORDER BY revenue_mad DESC) AS rnk
    FROM product_sales
)
SELECT category, product_name, revenue_mad, rnk
FROM ranked
WHERE rnk <= 3
ORDER BY category, rnk;

DENSE_RANK() gives the same rank to ties and does not skip numbers; RANK() would skip, ROW_NUMBER() would break ties arbitrarily. Choose deliberately.

9. Last order per customer (and days since)

WITH ranked_orders AS (
    SELECT
        o.customer_id,
        o.order_id,
        o.order_date,
        ROW_NUMBER() OVER (
            PARTITION BY o.customer_id
            ORDER BY o.order_date DESC, o.order_id DESC
        ) AS rn
    FROM orders AS o
    WHERE o.status = 'delivered'
)
SELECT
    customer_id,
    order_id                  AS last_order_id,
    order_date                AS last_order_date,
    CURRENT_DATE - order_date AS days_since_last_order
FROM ranked_orders
WHERE rn = 1
ORDER BY days_since_last_order DESC;

ROW_NUMBER() with PARTITION BY is the standard way to keep one row per group, which is also how you deduplicate data in pipelines. PostgreSQL offers a shorter equivalent:

SELECT DISTINCT ON (customer_id)
    customer_id,
    order_id,
    order_date
FROM orders
WHERE status = 'delivered'
ORDER BY customer_id, order_date DESC, order_id DESC;

Subtracting two DATE values in PostgreSQL returns a number of days, which makes recency calculations simple.

How do you measure customer retention in SQL?

10. Repeat purchase rate within 90 days, by first-order cohort

WITH first_orders AS (
    SELECT
        customer_id,
        MIN(order_date) AS first_order_date
    FROM orders
    WHERE status = 'delivered'
    GROUP BY customer_id
),
repeaters AS (
    SELECT DISTINCT f.customer_id
    FROM first_orders AS f
    JOIN orders AS o
      ON o.customer_id = f.customer_id
     AND o.status = 'delivered'
     AND o.order_date >  f.first_order_date
     AND o.order_date <= f.first_order_date + 90
)
SELECT
    date_trunc('month', f.first_order_date)::date      AS cohort_month,
    COUNT(*)                                           AS new_customers,
    COUNT(r.customer_id)                               AS repeat_within_90_days,
    ROUND(100.0 * COUNT(r.customer_id) / COUNT(*), 1)  AS repeat_rate_pct
FROM first_orders AS f
LEFT JOIN repeaters AS r ON r.customer_id = f.customer_id
GROUP BY 1
ORDER BY 1;

Each customer is assigned to the month of their first delivered order (their cohort), then the query checks whether they ordered again within 90 days. Adding an integer to a DATE in PostgreSQL adds days. This is a simple, explainable retention KPI.

How do you check data quality before analysing?

Before trusting any of the numbers above, run a few checks on each table:

SELECT
    COUNT(*)                               AS total_rows,
    COUNT(*) - COUNT(city)                 AS missing_city,
    COUNT(*) - COUNT(DISTINCT customer_id) AS duplicate_ids,
    MIN(signup_date)                       AS first_signup,
    MAX(signup_date)                       AS last_signup
FROM customers;

SELECT
    lower(trim(full_name)) AS normalised_name,
    city,
    COUNT(*)               AS occurrences
FROM customers
GROUP BY lower(trim(full_name)), city
HAVING COUNT(*) > 1;

COUNT(column) ignores NULLs, so COUNT(*) − COUNT(city) gives the number of missing cities. The second query catches near-duplicate customers whose names only differ by case or spaces. Systematic checks like these are covered in more depth in data quality and anomaly detection.

SQL cheat sheet: question → technique

Business questionSQL technique
Which rows match a condition?WHERE, ORDER BY, LIMIT
Total or average per group?GROUP BY + SUM / AVG / COUNT
Keep only significant groups?HAVING
Combine customers, orders, products?INNER JOIN
Who has never done X?LEFT JOIN … IS NULL or NOT EXISTS
Multi-step logic?WITH (CTE)
Compare with previous period?LAG() OVER (ORDER BY …)
Running or year-to-date total?SUM() OVER (PARTITION BY … ORDER BY …)
Top N per group, deduplication?ROW_NUMBER / DENSE_RANK
Typical value robust to outliers?percentile_cont(0.5) WITHIN GROUP

Common mistakes to avoid

  • Double counting after a join: aggregate at the right grain first (CTE), then join.
  • Filtering a LEFT JOIN in WHERE: it silently becomes an inner join.
  • Integer division: in PostgreSQL, 1 / 3 returns 0; multiply by 100.0 to force decimals.
  • Forgetting NULLs: NULL = NULL is not true; use IS NULL and COALESCE.
  • Comparing timestamps to dates loosely: be explicit about boundaries (>= start, < next start).

The official PostgreSQL documentation on window functions is an excellent next read.

Conclusion

Ten patterns, one schema: filtering, grouping, joining, CTEs, CASE segments, period comparisons, ranking, deduplication, cohorts and data checks. Together they cover most of what a data analyst writes every week and what data engineers use inside pipelines. The same SQL foundations were used to prepare and query the warehouse in the CNSS decision-support project.

If you are building your skills, see the data analyst roadmap for Morocco. If you need reliable SQL pipelines for your own data, have a look at the data engineering service.

SQLPostgreSQLData analysisWindow functionsCTEKPI
Anass Essadikine

About the author

Anass Essadikine

Data & AI Engineer and Software Engineer based in Casablanca, Morocco. I build data platforms, Power BI dashboards, RAG assistants and Next.js / Spring Boot applications.

Related projects

Frequently asked questions

The core of SQL is the same across PostgreSQL, MySQL, SQL Server, BigQuery and Snowflake. PostgreSQL is a good choice to learn because it is free, follows the standard closely and supports window functions, CTEs and advanced aggregates.

Keep reading

All articles

Looking for someone with these skills?

I'm open to freelance projects, internships and full-time roles. Let's talk.