5 signs your business reporting should be automated
If your team spends hours every week rebuilding the same spreadsheets, you are paying a hidden tax. Here's how to spot it, and what to do about it.
SQL can look intimidating when you start. There are hundreds of functions, several dialects and endless edge cases. The good news is that day-to-day analyst work relies on a small set of patterns, used again and again.
All the examples below use two simple tables: customers and orders.
customers (customer_id, name, city, signup_date)
orders (order_id, customer_id, order_date, amount, status)
The most basic query, and the habit that matters most: ask for specific columns, not SELECT *.
SELECT order_id, order_date, amount
FROM orders;
Naming columns makes your query faster, easier to read and less likely to break when the table changes.
WHERE narrows your results to the rows you care about. Combine conditions with AND, OR and IN.
SELECT order_id, customer_id, amount
FROM orders
WHERE status = 'Completed'
AND amount >= 100
AND order_date BETWEEN '2026-01-01' AND '2026-03-31';
"What were our five biggest orders?" is a sort plus a limit.
SELECT order_id, customer_id, amount
FROM orders
ORDER BY amount DESC
LIMIT 5;
In SQL Server, write SELECT TOP 5 ... instead of LIMIT 5.
Aggregations turn thousands of rows into an answer: totals, averages and counts per group.
SELECT status,
COUNT(*) AS order_count,
SUM(amount) AS total_value,
AVG(amount) AS average_value
FROM orders
GROUP BY status;
Every column in the SELECT must either be in the GROUP BY or wrapped in an aggregate function.
WHERE filters rows before grouping. HAVING filters groups after aggregation.
SELECT customer_id, SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 1000;
This finds your customers who have spent more than £1,000 in total.
Real questions need data from more than one table. An INNER JOIN returns only the rows that match in both.
SELECT c.name, c.city, o.order_id, o.amount
FROM orders AS o
INNER JOIN customers AS c
ON c.customer_id = o.customer_id;
Short table aliases like o and c keep joins readable.
A LEFT JOIN keeps every row from the left table, even without a match. That makes it perfect for finding gaps, such as customers who have never ordered.
SELECT c.customer_id, c.name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
customers
| customer_id | name |
|---|---|
| 1 | Ada |
| 2 | Ben |
| 3 | Cy |
orders
| order_id | customer_id | amount |
|---|---|---|
| 10 | 1 | 120 |
| 11 | 1 | 900 |
| 13 | 2 | 600 |
INNER JOIN → matches only
| name | order_id |
|---|---|
| Ada | 10 |
| Ada | 11 |
| Ben | 13 |
LEFT JOIN → every customer
| name | order_id |
|---|---|
| Ada | 10 |
| Ada | 11 |
| Ben | 13 |
| Cy | NULL |
CASE is SQL's if-then-else. Use it to create bands, flags and labels.
SELECT order_id,
amount,
CASE
WHEN amount >= 500 THEN 'Large'
WHEN amount >= 100 THEN 'Medium'
ELSE 'Small'
END AS order_size
FROM orders;
A Common Table Expression (WITH ...) lets you build a query in readable stages, like named steps in a recipe.
WITH customer_totals AS (
SELECT customer_id, SUM(amount) AS total_spent
FROM orders
WHERE status = 'Completed'
GROUP BY customer_id
)
SELECT c.name, ct.total_spent
FROM customer_totals AS ct
INNER JOIN customers AS c
ON c.customer_id = ct.customer_id
ORDER BY ct.total_spent DESC;
Window functions calculate across related rows without collapsing them. A classic use is finding each customer's most recent order.
WITH ranked_orders AS (
SELECT customer_id,
order_id,
order_date,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC
) AS order_rank
FROM orders
)
SELECT customer_id, order_id, order_date
FROM ranked_orders
WHERE order_rank = 1;
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC)
| customer_id | order_id | order_date | order_rank |
|---|---|---|---|
| 1 | 11 | 2026-02-10 | 1 |
| 1 | 10 | 2026-01-05 | 2 |
| 2 | 13 | 2026-03-20 | 1 |
| 2 | 12 | 2026-03-01 | 2 |
order_rank = 1 and you have each customer's latest order.Swap ROW_NUMBER() for SUM(amount) OVER (...) and you have running totals. This is one of the most powerful patterns in SQL.
Reading SQL is easy; writing it under pressure is a skill. The fastest way to build it is to practise on real questions: take a dataset and ask what sold best, who stopped buying, or how this month compares with last.
That's exactly what we do, live and hands-on, in our free Power BI and SQL class every Friday.
Free every Friday
Join our free live class every Friday at 6pm UK time: hands-on lessons, live Q&A and career tips from a working Senior Data Analyst.
Keep reading
If your team spends hours every week rebuilding the same spreadsheets, you are paying a hidden tax. Here's how to spot it, and what to do about it.
I arrived in the UK with an engineering career behind me and no local experience. Here's the honest story of how I moved into data, and the lessons I'd pass on to anyone making the switch.
What Fabric is, how it relates to Power BI, what it costs to run, and how to tell whether your business actually needs it yet.