Connect any database. Ask in plain English.

Try free
← Blog

SQL Query Examples: 17 Queries Run on a Real Database, With Their Results (2026)

Seventeen SQL query examples for everyday analysis, from GROUP BY and joins to window functions, running totals, medians and "latest row per customer", each run on a sample sales database with the result it returned. Plus what changes in MySQL, SQL Server and BigQuery.

Fastero Dev TeamFastero Dev Team2026-11-08

Most lists of SQL examples show a query and stop. You can't tell whether it runs, or what it gives back. Every query below was run on the same small sales database, and each one is followed by the rows it actually returned, so you can check your own version against it.

The short answer: the queries that cover most day-to-day analysis are a GROUP BY with a sum, a join across two or three tables, a date_trunc by month, HAVING to filter groups, a LEFT JOIN to find what's missing, and four window functions: LAG for month-over-month change, RANK for "top N per group", SUM() OVER for running totals, and ROW_NUMBER for the latest row per customer. All 17 are below with their results. They're written for PostgreSQL; a table near the end lists what to change for MySQL, SQL Server and BigQuery.

Fastero

Connect your database. Ask questions. Get dashboards.

Postgres, BigQuery, Snowflake, and 10+ sources — live-connected, AI-powered, no dashboard builder learning curve.

Try free →

The sample database

Four tables of invented sales data: 2,967 orders from 60 customers between September 2023 and September 2026, in a PostgreSQL schema called demo.

Table Columns used below Rows
demo.orders order_id, customer_id, ordered_at, channel, status 2,967
demo.order_items order_id, product_id, quantity, unit_price 8,653
demo.products product_id, name, category 24
demo.customers customer_id, company, segment, country 60

An order's revenue is the sum of quantity * unit_price over its items. Orders have a status of completed, pending or cancelled, and 149 were cancelled, so nearly every query below leaves them out with WHERE o.status <> 'cancelled'. Forgetting that filter is the most common reason two reports disagree.

Basic queries

1. Filter, sort and limit

The five most recent cancelled online orders:

SELECT order_id, customer_id, ordered_at, channel
FROM demo.orders
WHERE status = 'cancelled'
  AND channel = 'Online'
ORDER BY ordered_at DESC
LIMIT 5
order_id customer_id ordered_at channel
2958 39 2026-09-25 16:00 Online
2935 53 2026-09-21 13:00 Online
2841 14 2026-08-27 09:00 Online
2817 21 2026-08-19 15:00 Online
2800 52 2026-08-14 08:00 Online

WHERE filters rows before anything else happens; ORDER BY ... DESC puts the newest first; LIMIT keeps the first five.

2. Totals per group, across three tables

Revenue and orders by product category. The category lives on the product, the price on the order item and the status on the order, so this needs all three:

SELECT p.category,
       COUNT(DISTINCT o.order_id) AS orders,
       ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM demo.orders o
JOIN demo.order_items oi ON oi.order_id = o.order_id
JOIN demo.products p ON p.product_id = oi.product_id
WHERE o.status <> 'cancelled'
GROUP BY p.category
ORDER BY revenue DESC
category orders revenue
Hardware 1356 2756966.40
Software 2199 2632235.00
Services 1359 2048796.00
Accessories 1566 791980.00

COUNT(DISTINCT o.order_id) matters here: after the join there is one row per order item, so a plain COUNT(*) would count items, not orders. An order with products from two categories counts once in each, which is why the four order counts add up to more than the 2,818 orders that weren't cancelled.

The revenue-by-category query in Fastero's SQL editor, run on the sample sales database, with the four categories, their orders and revenue in the result grid below

3. Share of the total

What share of orders each sales channel brought in:

SELECT channel,
       COUNT(*) AS orders,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct_of_orders
FROM demo.orders
WHERE status <> 'cancelled'
GROUP BY channel
ORDER BY orders DESC
channel orders pct_of_orders
Online 1003 35.6
Sales team 978 34.7
Partner 837 29.7

SUM(COUNT(*)) OVER () is the total across all groups, computed after the GROUP BY, so you get the percentage without a subquery. Write 100.0, not 100: in PostgreSQL and SQL Server, an integer divided by an integer drops the decimals.

4. Filter groups with HAVING

Customers with 80 or more orders:

SELECT c.company, c.segment, COUNT(o.order_id) AS orders
FROM demo.customers c
JOIN demo.orders o ON o.customer_id = c.customer_id
WHERE o.status <> 'cancelled'
GROUP BY c.company, c.segment
HAVING COUNT(o.order_id) >= 80
ORDER BY orders DESC
company segment orders
Company B1001 Enterprise 290
Company C1002 Enterprise 137
Company D1003 Enterprise 109
Company E1004 Enterprise 92
Company F1005 Enterprise 82

WHERE filters rows before grouping; HAVING filters the groups after. A condition on an aggregate such as COUNT can only go in HAVING.

Joins

5. Find what's missing with a LEFT JOIN

Customers whose last order was more than 90 days before the latest order in the data (28 September 2026), or who never ordered:

SELECT c.company, c.country, MAX(o.ordered_at)::date AS last_order
FROM demo.customers c
LEFT JOIN demo.orders o
  ON o.customer_id = c.customer_id AND o.status <> 'cancelled'
GROUP BY c.company, c.country
HAVING MAX(o.ordered_at) < DATE '2026-09-28' - INTERVAL '90 days'
    OR MAX(o.ordered_at) IS NULL
ORDER BY last_order NULLS FIRST
LIMIT 5
company country last_order
Company O1040 United States 2026-06-15
Company V1047 United Kingdom 2026-06-15
Company Z1025 United States 2026-06-22
Company U1020 United States 2026-06-29

A LEFT JOIN keeps every customer, with NULLs where there's no matching order. The status filter sits in the ON clause on purpose: put it in WHERE and customers with no orders drop out, because their NULL status fails the test. Here every customer has ordered, so only four lapsed ones come back.

Dates

6. Totals per month

SELECT date_trunc('month', o.ordered_at)::date AS month,
       ROUND(SUM(oi.quantity * oi.unit_price), 0) AS revenue
FROM demo.orders o
JOIN demo.order_items oi ON oi.order_id = o.order_id
WHERE o.status <> 'cancelled'
  AND o.ordered_at >= DATE '2026-01-01'
GROUP BY 1
ORDER BY 1
month revenue
2026-01-01 202959
2026-02-01 228162
2026-03-01 255592
2026-04-01 277362
2026-05-01 229914
2026-06-01 292342
2026-07-01 270162
2026-08-01 262633
2026-09-01 324743

date_trunc('month', ...) rounds each timestamp down to the first of its month, so all of a month's orders share one value to group on. GROUP BY 1 means "group by the first column in the SELECT".

7. Orders by day of the week

SELECT TO_CHAR(ordered_at, 'Dy') AS weekday,
       EXTRACT(ISODOW FROM ordered_at) AS day_number,
       COUNT(*) AS orders
FROM demo.orders
WHERE status <> 'cancelled'
GROUP BY 1, 2
ORDER BY 2
weekday day_number orders
Mon 1 497
Tue 2 497
Wed 3 492
Thu 4 493
Fri 5 501
Sat 6 172
Sun 7 166

Grouping on the day number as well as the name lets you sort Monday to Sunday instead of alphabetically. ISODOW numbers Monday 1 and Sunday 7; plain DOW starts at Sunday = 0.

Window functions

A window function computes a value for each row from a set of related rows, without collapsing them the way GROUP BY does. These four cover most of what analysts use them for.

8. Month-over-month change and year to date

WITH monthly AS (
  SELECT date_trunc('month', o.ordered_at)::date AS month,
         SUM(oi.quantity * oi.unit_price) AS revenue
  FROM demo.orders o
  JOIN demo.order_items oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY 1
)
SELECT month,
       ROUND(revenue, 0) AS revenue,
       ROUND(LAG(revenue) OVER (ORDER BY month), 0) AS previous_month,
       ROUND(100 * (revenue / LAG(revenue) OVER (ORDER BY month) - 1), 1) AS growth_pct,
       ROUND(SUM(revenue) OVER (PARTITION BY date_trunc('year', month) ORDER BY month), 0) AS year_to_date
FROM monthly
WHERE month >= DATE '2026-05-01'
ORDER BY month
month revenue previous_month growth_pct year_to_date
2026-05-01 229914 229914
2026-06-01 292342 229914 27.2 522256
2026-07-01 270162 292342 -7.6 792418
2026-08-01 262633 270162 -2.8 1055051
2026-09-01 324743 262633 23.6 1379794

Look at May. LAG reads the previous row, and the WHERE month >= ... filter runs before the window functions, so May has no April to compare with, and its "year to date" restarts at May. To show the last five months with the right comparisons, compute everything first and filter in an outer query. Our editor screenshot below shows the version that starts in January, where the running total is right.

A month-over-month revenue query in Fastero's SQL editor using LAG and a running SUM, with nine months of results showing revenue, previous month, growth and year to date

For more on this pattern, see how to compare month-over-month metrics in SQL.

9. Top N per group with RANK

The two best-selling products in each category:

WITH product_revenue AS (
  SELECT p.category, p.name,
         SUM(oi.quantity * oi.unit_price) AS revenue
  FROM demo.order_items oi
  JOIN demo.products p ON p.product_id = oi.product_id
  JOIN demo.orders o ON o.order_id = oi.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY p.category, p.name
)
SELECT category, name, ROUND(revenue, 0) AS revenue, rank
FROM (
  SELECT *, RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rank
  FROM product_revenue
) ranked
WHERE rank <= 2
ORDER BY category, rank
category name revenue rank
Accessories Studio Bundle 313203 1
Accessories Advanced Bundle 201587 2
Hardware Studio Widget 1123762 1
Hardware Advanced Widget 740917 2
Services Studio Toolkit 816629 1
Services Advanced Toolkit 554016 2
Software Studio Gadget 908661 1
Software Ultra Gadget 683487 2

PARTITION BY category restarts the ranking in each category. You can't filter on a window function in the same query's WHERE, which is why the ranking sits in a subquery. RANK gives tied rows the same number; use ROW_NUMBER if you need exactly two rows per category even with ties.

A top-two-products-per-category query in Fastero's SQL editor using RANK() OVER (PARTITION BY category), returning eight rows with category, product name, revenue and rank

10. The latest row per customer with ROW_NUMBER

Each customer's most recent order, then the five most recent of those:

SELECT customer_id, order_id, ordered_at::date AS latest_order, channel
FROM (
  SELECT o.*,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at DESC) AS rn
  FROM demo.orders o
  WHERE status <> 'cancelled'
) latest
WHERE rn = 1
ORDER BY latest_order DESC
LIMIT 5
customer_id order_id latest_order channel
58 2963 2026-09-28 Online
1 2966 2026-09-28 Online
2 2967 2026-09-28 Sales team
50 2961 2026-09-27 Sales team
54 2962 2026-09-27 Online

This is the standard way to get one whole row per group (the latest status, the last invoice, the current price) when MAX() alone would give you the date but not the rest of the row.

11. Running share of revenue (the Pareto check)

How much of all revenue the biggest customers account for:

WITH customer_revenue AS (
  SELECT o.customer_id, SUM(oi.quantity * oi.unit_price) AS revenue
  FROM demo.orders o
  JOIN demo.order_items oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY o.customer_id
),
ranked AS (
  SELECT customer_id, revenue,
         ROW_NUMBER() OVER (ORDER BY revenue DESC) AS position,
         SUM(revenue) OVER (ORDER BY revenue DESC) / SUM(revenue) OVER () AS cumulative_share
  FROM customer_revenue
)
SELECT position, customer_id, ROUND(revenue, 0) AS revenue, ROUND(100 * cumulative_share, 1) AS cumulative_pct
FROM ranked
WHERE position <= 5
ORDER BY position
position customer_id revenue cumulative_pct
1 1 1356883 16.5
2 2 650275 24.4
3 3 509934 30.6
4 4 436795 35.9
5 5 397208 40.7

Five of 60 customers bring in 40.7% of revenue. SUM(revenue) OVER (ORDER BY revenue DESC) is a running total down the sorted list; divided by the grand total, it's the cumulative share.

Connect any database. Ask in plain English.

Start free →

CASE, CTEs and statistics

12. Buckets with CASE

Orders by size band, with the average order in each:

WITH order_totals AS (
  SELECT o.order_id, SUM(oi.quantity * oi.unit_price) AS total
  FROM demo.orders o
  JOIN demo.order_items oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY o.order_id
)
SELECT CASE
         WHEN total < 1000 THEN 'Under 1,000'
         WHEN total < 5000 THEN '1,000 to 4,999'
         ELSE '5,000 and over'
       END AS order_size,
       COUNT(*) AS orders,
       ROUND(AVG(total), 0) AS avg_total
FROM order_totals
GROUP BY 1
ORDER BY MIN(total)
order_size orders avg_total
Under 1,000 575 718
1,000 to 4,999 1754 2561
5,000 and over 489 6801

The WITH clause (a CTE) first totals each order, then the outer query buckets the totals. CASE checks its conditions in order, so total < 5000 only sees orders that weren't under 1,000. ORDER BY MIN(total) sorts the bands by size rather than alphabetically.

13. Average and median side by side

WITH order_totals AS (
  SELECT o.order_id, c.segment, SUM(oi.quantity * oi.unit_price) AS total
  FROM demo.orders o
  JOIN demo.customers c ON c.customer_id = o.customer_id
  JOIN demo.order_items oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY o.order_id, c.segment
)
SELECT segment,
       COUNT(*) AS orders,
       ROUND(AVG(total), 0) AS avg_order,
       ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)::numeric, 0) AS median_order
FROM order_totals
GROUP BY segment
ORDER BY avg_order DESC
segment orders avg_order median_order
Enterprise 1155 4723 4318
Mid-market 835 2346 2179
Small business 828 985 834

When the average sits well above the median, a few large orders are pulling it up. PERCENTILE_CONT(0.5) is the median; change it to 0.9 for the 90th percentile.

Customer metrics

14. Active customers per month

SELECT date_trunc('month', ordered_at)::date AS month,
       COUNT(DISTINCT customer_id) AS active_customers,
       COUNT(*) AS orders
FROM demo.orders
WHERE status <> 'cancelled'
  AND ordered_at >= DATE '2026-06-01'
GROUP BY 1
ORDER BY 1
month active_customers orders
2026-06-01 32 92
2026-07-01 34 95
2026-08-01 32 90
2026-09-01 31 109

COUNT(DISTINCT customer_id) counts each customer once per month, however many orders they placed.

15. New customers per quarter

A customer is new in the quarter of their first order:

WITH first_orders AS (
  SELECT customer_id, MIN(ordered_at) AS first_order_at
  FROM demo.orders
  WHERE status <> 'cancelled'
  GROUP BY customer_id
)
SELECT date_trunc('quarter', first_order_at)::date AS quarter,
       COUNT(*) AS new_customers
FROM first_orders
GROUP BY 1
ORDER BY 1
LIMIT 6
quarter new_customers
2023-07-01 28
2023-10-01 22
2024-01-01 10

Only three rows came back, although we asked for six: all 60 customers had placed their first order by March 2024. The first-order date is also the starting point for cohort retention; see how to calculate cohort retention in SQL.

16. Profile a table before you trust it

Before writing any of the queries above on a new table, it pays to see its range and values:

SELECT MIN(ordered_at) AS first_order, MAX(ordered_at) AS last_order,
       COUNT(*) AS orders, COUNT(DISTINCT customer_id) AS customers,
       COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
       string_agg(DISTINCT status, ', ') AS statuses,
       string_agg(DISTINCT channel, ', ') AS channels
FROM demo.orders
first_order last_order orders customers cancelled statuses channels
2023-09-01 09:00 2026-09-28 15:00 2967 60 149 cancelled, completed, pending Online, Partner, Sales team

This is where you find out that the data ends in September, that a "pending" status exists, or that a channel is spelled two ways. COUNT(*) FILTER (WHERE ...) counts only the rows that match.

17. Check a join won't lose rows

SELECT (SELECT COUNT(*) FROM demo.customers) AS customers,
       (SELECT COUNT(*) FROM demo.products) AS products,
       (SELECT COUNT(*) FROM demo.order_items) AS order_items,
       (SELECT COUNT(*) FROM demo.customers c
         WHERE NOT EXISTS (SELECT 1 FROM demo.orders o WHERE o.customer_id = c.customer_id)) AS customers_without_orders
customers products order_items customers_without_orders
60 24 8653 0

NOT EXISTS finds customers with no orders at all. Zero means an inner join from customers to orders drops no one; anything else means a report built on that join silently leaves those customers out.

What changes in MySQL, SQL Server and BigQuery

The queries above are PostgreSQL. Joins, GROUP BY, HAVING, CASE, CTEs and the window functions LAG, RANK, ROW_NUMBER and SUM() OVER are standard and work in MySQL 8.0 and later, SQL Server, BigQuery, Snowflake and DuckDB. These are the parts that differ:

PostgreSQL MySQL 8 SQL Server BigQuery
LIMIT 5 LIMIT 5 SELECT TOP 5 ... or OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY LIMIT 5
date_trunc('month', d) DATE_FORMAT(d, '%Y-%m-01') DATETRUNC(month, d) (SQL Server 2022 and later) DATE_TRUNC(d, MONTH)
x::date CAST(x AS DATE) CAST(x AS date) CAST(x AS DATE) or DATE(x)
COUNT(*) FILTER (WHERE ...) SUM(CASE WHEN ... THEN 1 ELSE 0 END) SUM(CASE WHEN ... THEN 1 ELSE 0 END) COUNTIF(...)
string_agg(x, ', ') GROUP_CONCAT(x SEPARATOR ', ') STRING_AGG(x, ', ') STRING_AGG(x, ', ')
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) no built-in median PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) OVER (PARTITION BY ...) PERCENTILE_CONT(x, 0.5) OVER (PARTITION BY ...)
INTERVAL '90 days' INTERVAL 90 DAY DATEADD(day, -90, d) INTERVAL 90 DAY

CASE WHEN ... THEN 1 END inside COUNT or SUM works in every one of them, so it's the safe choice when a query has to run on more than one database.

Getting a query like these written for you

You can also describe the result you want and let an AI model write the SQL. We asked Claude, connected to the same sample database through Fastero, which product category brought in the most revenue and how many orders each had, leaving out cancelled orders. It wrote the same three-table join as query 2, drew the chart, and printed the SQL it ran so the numbers can be checked:

Claude answering in claude.ai: a bar chart of net revenue by product category from the sample sales database, with Hardware at $2,756,966 ahead of Software, Services and Accessories, and a Save to a dashboard button

Its totals and order counts match query 2. That's the habit worth keeping with generated SQL: check that it filtered what you meant (here, cancelled orders) and counted the right thing (orders, not order items) before you trust the number.

FAQ

What are the most common SQL queries for data analysis?

A filtered SELECT, a GROUP BY with SUM or COUNT, a join across the tables that hold the pieces, a date_trunc to group by month, HAVING to filter groups, and window functions for month-over-month change (LAG), top N per group (RANK), running totals (SUM() OVER) and the latest row per group (ROW_NUMBER).

What's the difference between WHERE and HAVING?

WHERE filters rows before they're grouped; HAVING filters groups after GROUP BY. A condition on an aggregate, such as COUNT(*) >= 80, has to go in HAVING.

How do I get the top N rows per group in SQL?

Rank the rows inside each group with RANK() or ROW_NUMBER() OVER (PARTITION BY group ORDER BY value DESC) in a subquery or CTE, then keep the rows where the rank is N or less, as in query 9.

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

Total by month first, then use LAG(revenue) OVER (ORDER BY month) to read the previous month and compute revenue / previous - 1. Filter to the months you want to show in an outer query, so the first month you show still has its comparison.

Do these queries work in MySQL?

Most do in MySQL 8.0 and later, which has window functions and CTEs. Change date_trunc to DATE_FORMAT, ::date to CAST, FILTER (WHERE ...) to SUM(CASE ...), string_agg to GROUP_CONCAT, and replace the median, which MySQL has no built-in function for.

Where can I practise SQL on a real database?

Any PostgreSQL you can install locally, or a hosted SQL editor with sample data. Every new Fastero workspace comes with a sample table of invented orders, with region, channel, category, quantity and price, to practise grouping, dates and window functions on.


Related reading:

Sources: every query and result above was run on the sample sales database in Fastero's demo workspace (PostgreSQL, invented data) on 8 November 2026. Dialect details: PostgreSQL window functions, PostgreSQL aggregate functions, MySQL window functions, SQL Server DATETRUNC, BigQuery date functions.


Try Fastero free — a SQL editor on your own database, with sample data to practise on, saved queries your team can reuse, and dashboards from any query. No credit card required.

Ready to try it yourself?

Connect your database, ask questions in plain English, and get live dashboards — in under 2 minutes. No credit card required.