SQL SELECT · Complete Guide + 22 Interview Questions

Retrieving Data Using the SQL SELECT Statement: Complete Guide

After more than a decade of training developers and building data pipelines, I have noticed that SQL SELECT is where most candidates either shine or stumble. It looks simple — pick the columns, pick the table, done. But the subtle parts, like how NULL behaves, when to use GROUP BY versus DISTINCT, and why a filter in ON is not the same as a filter in WHERE, are what decide whether you get the offer. This guide walks through the full statement, every clause, and the twenty-two questions that come up most often in interviews, with real examples and the answers that actually satisfy an interviewer.

🎯 Key Takeaways
22 Interview Questions Beginner to Advanced Real Examples Updated Oct 6, 2026
Kailas Warade ✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines. He writes the SQL interview guides at Sankalan Data Tech, focused on practical, real-world answers.
🏆 Oracle Certified 💼 Data Engineer 📝 SQL & PL/SQL Guides 📍 Pune, India
📤 Share:

On This Page

What SELECT Really Does

When you write a SELECT, you are not telling the database how to find your data. You are describing what you want back. The database works out the rest for itself — which indexes to use, in what order to join the tables, how to filter the rows. Your job is to describe the result, and the query planner's job is to get there quickly.

The SELECT statement is the most used command in relational databases, and for good reason. Every dashboard, every report, every scheduled job that pulls numbers out of a database eventually funnels down to a SELECT. When the syntax is right, the query plan is efficient and the results are what you expect. When it is slightly wrong, one of two things happens. Either you get rows you did not want, which is easy to spot, or you silently miss rows that should have been there. The second case is the one that trips people up in interviews, because nothing in the output tells you something is wrong.

A SELECT query returns what is called a result set. It behaves like a table, but it is not stored anywhere by default. It exists only for the duration of the query. That is worth remembering when you are debugging, because it means two identical queries run a few minutes apart can return different results if the underlying rows have changed in between. Sometimes the query is fine — the data underneath has simply moved.

The Full Syntax in One Place

Here is the complete SELECT statement with every clause in the order they must be written. Only SELECT and FROM are mandatory. Everything else is optional, and you add it only when the query needs it.

SELECT   [DISTINCT | ALL] column1, column2, ...
FROM     table_name [alias]
         [JOIN other_table ON condition]
WHERE    condition
GROUP BY column1, column2, ...
HAVING   aggregate_condition
ORDER BY column1 [ASC | DESC], ...
LIMIT    number [OFFSET offset];

The rules worth memorising

  • The order of clauses is fixed. If you put ORDER BY before WHERE, the parser stops and returns a syntax error rather than partial results.
  • Column aliases use AS: SELECT price * quantity AS line_total. The keyword is optional in most databases, but writing it makes the intent obvious to the next person who reads your code.
  • Table aliases do not need AS: FROM orders o and FROM orders AS o both work. Pick one style and stay consistent.
  • String literals use single quotes. WHERE customer_name = 'Alice' is correct. Double quotes mean something different in most databases — they refer to an identifier rather than a string. Using them by mistake either fails or quietly selects a column with that exact name.
  • Keywords are case-insensitive, but the convention is to write keywords in uppercase and identifiers in lowercase. Following this convention makes long queries easier for teammates to scan.
An easy mistake to avoid: writing a query like SELECT * FROM orders WHERE order_amount > 5000 ORDER BY customer_id GROUP BY region; and wondering why it fails. The parser reads from top to bottom, and it expects GROUP BY before ORDER BY. Once the clause order becomes second nature, you will spot this kind of mistake in your own code before the database does.

The Order SQL Actually Runs Your Query

You write a query in one order, but the database runs it in another. This single fact explains almost every "why does this query not work" moment you will run into. It is also one of the most reliable ways to show an interviewer that you understand SQL, rather than just its surface syntax.

1 FROM → 2 WHERE → 3 GROUP BY → 4 HAVING → 5 SELECT → 6 ORDER BY → 7 LIMIT

Here is what each step does:

  • FROM — the engine identifies the tables and applies any joins, building the working set of rows.
  • WHERE — rows that fail the condition are thrown away before any grouping or aggregation happens.
  • GROUP BY — the remaining rows collapse into groups keyed by the columns you listed.
  • HAVING — groups that fail the condition are thrown away. This is why HAVING COUNT(*) > 5 works but WHERE COUNT(*) > 5 does not. The count simply does not exist yet when the WHERE step runs.
  • SELECT — the engine evaluates the column list, computes any expressions, and applies the aliases.
  • ORDER BY — the final result is sorted. Aliases from the SELECT clause are available here because SELECT has already run.
  • LIMIT — the result is trimmed to the number of rows you asked for.
From my training sessions: A colleague once spent twenty minutes debugging a query that kept returning zero rows. She had written the filter on the alias — the WHERE clause referenced line_total — but the alias is only created at step five, long after WHERE has done its work. The moment she swapped the alias for the actual expression price * quantity, the query behaved as expected. That is the execution order in action.

The Sample Data We Use in Every Example

Every query in this guide runs against the same two small tables. Keeping the data consistent makes it easier to compare results and to see exactly how each clause changes the outcome. If you are following along on your own database, set up the same rows with the same numbers, and the outputs will match.

-- customers table
-- customer_id | customer_name | region | credit_limit | created_on
-- C001        | Alice Corp    | North  | 50000        | 2021-03-15
-- C002        | Bob Traders   | South  | 25000        | 2022-07-01
-- C003        | Charlie Ltd   | North  | 75000        | 2019-11-20
-- C004        | Diana Retail  | West   | 40000        | 2023-01-10
-- C005        | Ethan Supplies| South  | 55000        | 2020-06-05
-- C006        | Fiona Mart    | West   | NULL         | 2024-02-14
-- C007        | George Wholesale | North | 80000       | 2018-05-30

And here is the orders table used in the join examples:

-- orders table
-- order_id | customer_id | order_amount | order_status
-- 1001     | C001        | 12000        | Delivered
-- 1002     | C002        | 4500         | Shipped
-- 1003     | C001        | 7500         | Delivered
-- 1004     | C003        | 22000        | Pending
-- 1005     | C002        | 3200         | Delivered
-- 1006     | C004        | 18000        | Shipped
-- 1007     | C001        | 9000         | Delivered

There are four things I deliberately built into this data set, because they show up in real interviews almost every time:

  • Fiona Mart has a NULL credit_limit. Every interview that covers NULL handling will have a case like this.
  • Alice Corp appears three times in the orders table, so GROUP BY and DISTINCT both have something to work with.
  • Regions repeat across customers, which makes regional aggregation a natural query.
  • Customer C005 has no orders. That gives us a reason to reach for a LEFT JOIN instead of an INNER JOIN when we want to list all customers with or without orders.

Every Clause Explained

A SELECT statement is built from a small number of clauses. Understanding what each one does, and knowing when to reach for it, is most of what writing good SQL is about.

1. SELECT

Lists the columns or expressions you want to see. Use * to bring everything back, or DISTINCT to remove duplicate rows.

SELECT customer_name, credit_limit * 1.1 AS adjusted FROM customers;

2. FROM

Names the tables you are reading from. Aliases keep multi-table queries readable.

FROM orders o

3. WHERE

Filters rows before any grouping. Supports =, >, <, LIKE, IN, BETWEEN, and IS NULL.

WHERE order_amount > 5000 AND order_status = 'Delivered'

4. GROUP BY

Collapses rows that share the same values so aggregates can run per group.

GROUP BY region

5. HAVING

Filters groups after aggregation. Only makes sense when there is a GROUP BY.

HAVING COUNT(*) > 2

6. ORDER BY

Sorts the final result. Uses ASC or DESC. Can reference aliases.

ORDER BY order_amount DESC

7. LIMIT / OFFSET

Trims the result to a fixed number of rows. Oracle uses ROWNUM, SQL Server uses TOP.

LIMIT 10 OFFSET 20

WHERE vs HAVING — side by side

This question comes up in almost every interview I have been part of. Here is the clean comparison:

Aspect WHERE HAVING
When it runs Before GROUP BY After GROUP BY
What it filters Individual rows Grouped rows
Can it use aggregates? No — COUNT(*) fails here Yes — COUNT(*) > 5 is fine
Typical use WHERE order_amount > 5000 HAVING AVG(order_amount) > 10000
Can it see SELECT aliases? No Sometimes, depending on the database

Ordering rows the way you actually want

SELECT customer_name, credit_limit
FROM   customers
ORDER BY credit_limit DESC, customer_name ASC;

Two sort keys. Credit limit first, highest at the top. If two customers share the same credit limit, the name breaks the tie alphabetically. This pattern — a primary sort key plus a tie-breaker — shows up in almost every interview that involves sorting.

A detail worth remembering: in most databases, NULL sorts last when you use ASC and first when you use DESC. Oracle and PostgreSQL let you override this with NULLS FIRST or NULLS LAST. If your output ever looks wrong at the top or bottom of a sorted list, this is often the reason.

The Six Shapes of a SELECT Query

Almost every SELECT query you will write fits into one of these six patterns. Once you recognise them, writing new queries becomes a matter of knowing which shape fits the question.

1. Simple SELECT (no filter)

SELECT customer_name, region FROM customers;

Returns every row. Rarely useful in production on large tables, but fine for small reference tables or quick inspections.

2. SELECT with WHERE

SELECT customer_name, credit_limit FROM customers WHERE credit_limit > 50000;

The most common form. Rows that fail the condition never reach the rest of the pipeline, which makes a good WHERE clause the single biggest performance lever you have.

3. SELECT with JOIN

SELECT o.order_id, c.customer_name
FROM   orders o
INNER JOIN customers c ON o.customer_id = c.customer_id;

Pulls columns from more than one table in a single query. The details matter more than the syntax, and the next section goes through them carefully.

4. SELECT with a subquery

SELECT customer_name, credit_limit
FROM   customers
WHERE  credit_limit > (SELECT AVG(order_amount) FROM orders);

Returns customers whose credit limit exceeds the average order amount. A subquery can sit in the SELECT list, the FROM clause, or the WHERE clause. Each position has different rules about how many rows and columns the subquery must return.

5. SELECT with GROUP BY

SELECT region, COUNT(*) AS customer_count, AVG(credit_limit) AS avg_credit
FROM   customers
GROUP BY region;

Rows collapse into groups and aggregates run per group. Every column in the SELECT list either appears in GROUP BY or is wrapped in an aggregate function. The database enforces this.

6. SELECT with DISTINCT

SELECT DISTINCT region FROM customers;

Removes rows where every selected column has the same value. It looks similar to GROUP BY at first glance, but it does a different job. DISTINCT is about de-duplication. GROUP BY is about aggregation.

Joins Without the Jargon

Joins are how you combine data that lives in more than one table. Most SELECT interview questions either involve a join directly or hide one inside a scenario. The five standard join types are easier to remember if you picture what happens to the rows that do not match.

INNER JOIN — keep only matched rows

SELECT o.order_id, c.customer_name
FROM   orders o
INNER JOIN customers c ON o.customer_id = c.customer_id;

Returns seven rows, one per order, each paired with the customer who placed it. If an order's customer_id did not exist in the customers table, that order would be silently excluded. This is the most common join type in production.

LEFT JOIN — keep everything from the left, match what you can

SELECT c.customer_name, o.order_id
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;

Returns every customer, including Ethan Supplies (C005), who has no orders. The order_id column will be NULL for that customer. This is the join you reach for when you want to spot things that have no matches — like finding customers who have never placed an order.

RIGHT JOIN — the mirror of LEFT

SELECT o.order_id, c.customer_name
FROM   orders o
RIGHT JOIN customers c ON o.customer_id = c.customer_id;

Same result as swapping the tables and using LEFT JOIN. Most teams pick one orientation and stick to it. LEFT JOIN is more common in the wild because it reads top to bottom.

FULL OUTER JOIN — keep everything from both sides

SELECT o.order_id, c.customer_name
FROM   orders o
FULL OUTER JOIN customers c ON o.customer_id = c.customer_id;

All rows from both tables, with NULLs wherever there is no match. Useful for reconciliation queries — "show me every order and every customer, and mark which ones do not line up."

CROSS JOIN — every combination

SELECT c.customer_name, o.order_status
FROM   customers c CROSS JOIN orders o;

Seven customers times seven orders equals forty-nine rows. You will almost never use this in a business query, but you will be asked to compute the row count in interviews.

SELF JOIN — the same table twice

SELECT c1.customer_name AS customer, c2.customer_name AS referred_by
FROM   customers c1
LEFT JOIN customers c2 ON c1.referred_by_id = c2.customer_id;

Two aliases for the same table. This is the standard way to model hierarchies, referral chains, or any other case where a row points at another row in the same table.

The tricky one: "What is the difference between putting a filter in the ON clause and putting it in the WHERE clause of a LEFT JOIN?"
If the filter goes in ON, the LEFT JOIN keeps all left-side rows and applies the filter only when deciding which right-side rows to match. If the filter goes in WHERE, the LEFT JOIN effectively becomes an INNER JOIN, because rows where the right side is NULL fail the WHERE test and get discarded. Interviewers love this question because it separates people who memorised the syntax from people who understand what the join actually does.

Set Operators: UNION, INTERSECT, MINUS

Set operators stack or compare entire result sets. They are less common than joins in day-to-day work, but they come up often enough in interviews that knowing them cold is worth the effort.

UNION — combine and remove duplicates

SELECT customer_name FROM customers
UNION
SELECT supplier_name FROM suppliers;

Every unique name that appears in either table. Duplicates are removed, so if Acme is both a customer and a supplier, it appears once.

UNION ALL — combine without removing duplicates

SELECT customer_name FROM customers
UNION ALL
SELECT supplier_name FROM suppliers;

Every row from both tables. Faster than UNION because the database does not have to sort and de-duplicate. If you know your data has no duplicates, or you specifically want to keep them, use this one.

INTERSECT — rows in both

SELECT customer_name FROM customers
INTERSECT
SELECT supplier_name FROM suppliers;

Only names that appear in both tables. Useful for overlap analysis — "who is both a customer and a supplier?"

MINUS / EXCEPT — rows in the first only

SELECT customer_name FROM customers
MINUS
SELECT supplier_name FROM suppliers;

Names that appear as customers but not as suppliers. Oracle writes MINUS; SQL Server and PostgreSQL write EXCEPT. Same concept, different keyword.

The rule that trips people up: both SELECT statements must return the same number of columns, and the matching columns must have compatible data types. Column names come from the first SELECT. If you violate either rule, the query fails immediately.

Aggregate Functions in Practice

Aggregate functions take many rows and give you back a single value. They show up constantly in interviews, usually paired with GROUP BY but sometimes on their own. Here is the quick tour, with actual results from the sample data.

Function What it returns Result on sample data
COUNT(*) Counts every row, NULLs included 7 orders
COUNT(credit_limit) Counts non-NULL values only 6 — Fiona Mart's NULL is skipped
SUM(order_amount) Adds all non-NULL values 76200
AVG(order_amount) Mean of non-NULL values 10885.71
MIN(order_amount) Smallest non-NULL value 3200 — order 1005
MAX(order_amount) Largest non-NULL value 22000 — order 1004

Aggregates with GROUP BY

SELECT region,
       COUNT(*)             AS customer_count,
       AVG(credit_limit)    AS avg_credit,
       MAX(credit_limit)    AS top_credit
FROM   customers
GROUP BY region
ORDER BY avg_credit DESC;

One row per region, with customer count, average credit limit, and top credit limit for each. The ORDER BY puts the highest-average region at the top. It is a small touch, but it makes the output easier to scan.

Aggregates with HAVING

SELECT customer_id, SUM(order_amount) AS total_spend
FROM   orders
GROUP BY customer_id
HAVING SUM(order_amount) > 15000;

Only customers whose total order value exceeds 15,000 come through. Notice you cannot swap HAVING for WHERE here. The sum does not exist yet at the WHERE stage. That single fact is why HAVING exists.

From my training sessions: I ask every batch of developers the same question — what is COUNT(*) minus COUNT(credit_limit) on the customers table? Most of them answer "one" and then explain it away as "the NULL row." They are right about the number, but they often miss the point: COUNT(*) counts rows, COUNT(column) counts values. Once someone internalises that difference, they tend not to make the same mistake again.

22 Interview Questions and Answers

These are the questions I have seen come up most often across interviews and training sessions. Each answer is written to be useful on its own, so you can read any of them without needing the section above. If you are preparing for an interview, read through once for context, then use the search box and the quiz further down to test yourself.

🔍

No matching questions found

Try a keyword like JOIN, NULL, or DISTINCT.

The SELECT statement pulls data out of one or more tables. It is the command you will run most often as a developer, analyst, or engineer, and it sits underneath almost every report, dashboard, and data pipeline in production.

When you write SELECT customer_name, credit_limit FROM customers, you are describing the result you want — two columns from a table called customers. You are not telling the database how to find it. The query planner reads your request, picks an execution strategy, and returns the rows. That is why the same query can run fast on one database and slowly on another: the data, the indexes, and the statistics are different, even though the SQL is identical.

You can query a single table, join several, filter the rows you want, group them for aggregation, sort the output, and limit how much you get back. All of that happens inside a single statement, which is why SELECT is worth understanding in depth rather than memorising a handful of examples.

You use the asterisk wildcard. The full statement is SELECT * FROM customers; and the asterisk tells the database to return every column the table currently has.

On our sample table of seven customers, that means seven rows and five columns coming back. It is the quickest way to see what is in a table when you are exploring an unfamiliar database, and it saves typing when you genuinely want everything.

That said, SELECT * has real costs, and interviewers expect you to mention them. It transfers more data across the network than you need, it forces the database to read the entire row even when a narrower index could answer the query, and it breaks silently if someone adds or renames a column. In production code, list the columns explicitly. It is a small habit that pays off every time the schema changes.

The short version is that SELECT * returns every column, while SELECT column_name returns only the ones you name. But the interesting part of this question is not the definition — it is the consequences.

When you select specific columns, the database can sometimes answer the query from an index alone, without ever reading the table. This is called a covering index, and it can turn a query that took two seconds into one that takes two milliseconds. The difference is biggest on wide tables with many columns and millions of rows.

There is also the question of stability. A query that lists columns explicitly keeps working when someone adds a new column to the table. A query that used SELECT * starts returning a different shape of data, which can break downstream code that assumed a fixed set of columns. I have seen this exact issue take down a nightly pipeline more than once.

You write DISTINCT immediately after SELECT, before the column list: SELECT DISTINCT region FROM customers;.

On our sample table, the region column contains seven values that repeat: North, South, North, West, South, West, North. After DISTINCT, you get three rows — North, South, and West. The database throws away every row that duplicates one it has already seen.

One thing worth knowing: DISTINCT applies to the entire row you select, not to a single column. If you write SELECT DISTINCT region, credit_limit, you only remove rows where both values match. Two customers in the same region with different credit limits will both come back. If that surprises you, you are not alone — it is one of the most common points of confusion in this topic.

If what you really want is the count of distinct values, wrap it: SELECT COUNT(DISTINCT region) FROM customers; returns 3.

You simply list the columns you want, separated by commas: SELECT customer_name, credit_limit FROM customers;.

There is not much syntax to learn here, but there is a habit worth building. Always ask yourself whether you actually need every column you named. If you are writing a query for a report, take the time to check which columns the report uses. If you are debugging, a narrower query is faster to read and easier to reason about.

You can also select expressions, not just columns: SELECT customer_name, credit_limit * 1.1 AS adjusted_limit FROM customers; returns a computed column. The AS clause names the new column; without it, the database generates a name like credit_limit * 1.1 which is awkward to reference later in the query.

Every database has its own way of doing this, and interviewers expect you to know at least two of them.

In Oracle, you use ROWNUM. The important detail is that ROWNUM is applied before the query is sorted, so if you want the top three orders by amount you need to sort in a subquery and apply ROWNUM outside it:

SELECT * FROM (
  SELECT order_id, order_amount FROM orders ORDER BY order_amount DESC
) WHERE ROWNUM <= 3;

In MySQL and PostgreSQL, you use LIMIT with an optional OFFSET:

SELECT order_id, order_amount FROM orders ORDER BY order_amount DESC LIMIT 3;

In SQL Server, the keyword is TOP:

SELECT TOP 3 order_id, order_amount FROM orders ORDER BY order_amount DESC;

All three queries return the same result on our sample table: order 1004 (22,000), order 1006 (18,000), and order 1001 (12,000).

One thing to always pair with row limiting is ORDER BY. Without a sort order, the database is free to return whichever rows it reaches first, which can change from one execution to the next. That behaviour is not a bug, but it will surprise you if you are not expecting it.

Use the ORDER BY clause, followed by the column name and the direction:

SELECT order_id, order_amount
FROM orders
ORDER BY order_amount DESC;

ASC is the default, so you only need to write DESC when you want descending order. You can also sort by more than one column, and you can sort by an expression or an alias from the SELECT list.

One subtlety that catches people out: NULLs are sorted somewhere in the result, but where depends on your database. In Oracle and PostgreSQL, NULLs sort last when you use ASC and first when you use DESC. If that ordering is not what you want, both databases support NULLS FIRST and NULLS LAST to override it.

Performance note: if the column you are sorting on has an index, the database can read the rows in sorted order and skip a separate sort step. On large tables, that can be the difference between a query that takes a second and one that takes a minute.

Yes, and this is one of the most useful things about SQL. You use a JOIN to combine rows from two or more tables based on a column that they share.

The most common type is INNER JOIN, which returns only the rows where the join condition matches on both sides. If you want to keep rows from the left table even when there is no match on the right, use LEFT JOIN. The mirror is RIGHT JOIN, and the union of both is FULL OUTER JOIN.

A simple example using our two sample tables:

SELECT o.order_id, c.customer_name
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id;

This returns seven rows — every order, paired with the name of the customer who placed it. If any order had a customer_id that did not exist in the customers table, that order would drop out of the result because INNER JOIN only keeps matched rows.

If you want to include unmatched orders, swap to LEFT JOIN or FULL OUTER JOIN depending on what you are trying to spot.

You write the join condition in the ON clause. The clause tells the database how the two tables relate to each other, and the query returns a combined result set with columns from both.

SELECT o.order_id, c.customer_name
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id;

The columns from the SELECT list can come from either table, and the ON clause specifies which column links them. In this case, the link is orders.customer_id = customers.customer_id.

You can join more than two tables, chain multiple ON conditions with AND, and mix different join types in the same query. The only rule is that the order of the joins matters — the database applies them left to right, and each join sees the result of the ones before it.

If you want to see the same query with a different join type — for instance to list every customer including those who have never placed an order — swap the table order and use LEFT JOIN:

SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;

This returns all seven customers, with NULL in the order_id column where there is no order, which is exactly what you want for a "customers and their orders, including those with none" report.

A CROSS JOIN produces every possible combination of rows from the two tables. It does not need an ON clause because it does not try to match anything — it just pairs everything with everything.

SELECT c.customer_name, o.order_status
FROM customers c CROSS JOIN orders o;

On our sample data, seven customers times seven orders equals forty-nine rows. Every customer is paired with every order status, including combinations that do not make any sense.

You will rarely use this in business queries. But it has two legitimate uses: generating test data, where you want a large number of combinations to play with, and building reports where you genuinely need every pairing — for instance, a shipping schedule where every customer has to be checked against every delivery window.

One thing to be careful about: an accidental CROSS JOIN is easy to create. If you forget the ON clause in a regular join, some databases will silently treat it as a cross join and return an enormous result set. If a query suddenly returns far more rows than you expected, this is usually why.

You use the WHERE clause. It runs after FROM and before GROUP BY, so it filters individual rows before anything gets aggregated or grouped. Rows that fail the condition never make it into the rest of the query.

SELECT order_id, order_amount
FROM orders
WHERE order_amount > 5000;

On our sample table this returns five rows: orders 1001, 1003, 1004, 1006, and 1007. Orders 1002 and 1005 fall below the threshold and are filtered out.

You can combine conditions with AND, OR, and NOT, and you can use a wide range of comparison operators. The ones worth knowing by heart:

  • =, <> (or !=) for equality and inequality
  • >, <, >=, <= for numeric comparisons
  • LIKE for pattern matching
  • IN for matching a list of values
  • BETWEEN for a range
  • IS NULL and IS NOT NULL for NULL checks

The WHERE clause is also where the biggest performance wins live. A tight filter reduces the number of rows the rest of the query has to process, so it is worth thinking about what the database can actually push down to the storage layer rather than what looks tidy in the SQL.

The main tool is DISTINCT. Placed immediately after SELECT, it removes rows where every selected column has the same value.

SELECT DISTINCT region FROM customers;

On our sample data, this collapses the seven rows into three: North, South, and West. If you select more than one column, DISTINCT considers the entire combination — so SELECT DISTINCT region, credit_limit would only remove rows where both values match, which on this data removes nothing.

There is a second way to handle duplicates, and it is worth knowing when to reach for it instead. If you want not just the unique values but a count of how many times each one appears, use GROUP BY:

SELECT region, COUNT(*) AS customer_count
FROM customers
GROUP BY region;

This returns three rows, each with a count. DISTINCT and GROUP BY can look similar in simple cases, but their purpose is different. DISTINCT is about de-duplication. GROUP BY is about preparing rows for aggregation. Reach for the one that matches what you are actually trying to do.

GROUP BY collects rows that share the same values in the columns you name, and then lets you run aggregate functions on each group. It is how you answer questions like "how many orders has each customer placed?" or "what is the average order value per region?"

SELECT customer_id,
       COUNT(*)          AS order_count,
       SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id;

On our sample data, this returns four rows — one per customer who has placed at least one order. Customer C001 has three orders, totalling 28,500. Customer C002 has two, totalling 7,700. C003 and C004 each have one order.

One rule you cannot break: every column in the SELECT list must either appear in the GROUP BY clause or be wrapped inside an aggregate function. If you select a column that is not grouped and not aggregated, the database does not know which value to return for each group, and it will refuse to run the query.

Common aggregates you will see alongside GROUP BY: COUNT(), SUM(), AVG(), MIN(), and MAX(). You can also use COUNT(DISTINCT column) to count unique values within each group.

HAVING filters groups after GROUP BY has produced them. That is the whole point, and it explains why WHERE cannot do the same job — WHERE runs before grouping, when the aggregates do not exist yet.

SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
HAVING SUM(order_amount) > 10000;

On our sample data, this returns only customers C001 (28,500) and C004 (18,000) — the ones whose total order value exceeds 10,000. Customer C002 (7,700) and C003 (22,000) fall on the wrong side of that filter.

If you tried to write WHERE SUM(order_amount) > 10000 instead, the query would fail with an error like "aggregate functions are not allowed in WHERE." That error message is worth remembering, because it is the quickest way to remind yourself that HAVING is the right tool for filters on aggregated values.

A query can absolutely use both. A common pattern is to filter rows with WHERE first (to reduce the amount of data being grouped), then filter the groups with HAVING:

SELECT customer_id, AVG(order_amount) AS avg_order
FROM orders
WHERE order_status = 'Delivered'
GROUP BY customer_id
HAVING AVG(order_amount) > 5000;

Here WHERE keeps only delivered orders, and HAVING keeps only customers whose delivered average exceeds 5,000.

Yes, and this is where table aliases start to pay off. You name the columns you want, prefixing each with its table alias, and join the tables in the FROM clause.

SELECT c.customer_name, c.region, o.order_amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id;

Two tables, three columns, one query. The aliases c and o keep the query readable and — more importantly — tell the database exactly which column you mean when names collide. If both tables had a column called region, you would need to write c.region or o.region to disambiguate. Without the alias, the database throws an "ambiguous column" error.

You are not limited to two tables. You can chain joins for as many tables as you need, mixing join types and adding extra conditions in each ON clause. The database applies them one at a time, in the order you wrote them.

When the query gets long enough to need several lines, keep the aliases short and consistent. I use single letters — c for customers, o for orders — because it makes the ON clause line up nicely and the columns easy to scan. Whatever convention you pick, stick with it across the codebase so readers of your queries can move between them without re-learning.

You use the LIKE operator with wildcards. There are two wildcards worth knowing: the percent sign % matches any sequence of characters, including none, and the underscore _ matches exactly one character.

SELECT * FROM customers
WHERE customer_name LIKE 'A%';

On our sample data, this returns Alice Corp — the only customer name starting with A. If I change the pattern to '%a%', I get every name containing an "a" anywhere, which is a much wider net. If I use '_a%', I get names where the second letter is "a" — that would return Diana Retail.

Two things worth remembering about LIKE. It is case-sensitive in some databases and case-insensitive in others, so if case matters, check the behaviour of yours. And if you need to search for a literal % or _, you need to escape it — usually with a backslash — because otherwise the database treats it as a wildcard.

LIKE is powerful but not always fast. If the pattern starts with a wildcard, such as '%mart', the database cannot use a normal index to answer the query and has to scan every row. If you need substring search at scale, look into full-text search features rather than LIKE.

IN checks whether a column matches any value in a given list. You put the list in parentheses after the IN keyword:

SELECT * FROM customers
WHERE region IN ('North', 'South');

On our sample data, this returns five customers — everyone whose region is North or South. It is a cleaner alternative to writing WHERE region = 'North' OR region = 'South', and for longer lists it becomes much more readable.

The list does not have to be hard-coded. You can use a subquery:

SELECT * FROM customers
WHERE customer_id IN (
  SELECT customer_id FROM orders WHERE order_amount > 10000
);

That version finds every customer who has placed an order worth more than 10,000. The subquery runs first, produces a list of customer ids, and the outer query uses that list as its filter.

One gotcha worth knowing: if the list contains NULL, or the subquery returns NULL, IN will not match anything against the NULL — not even another NULL. It is the same NULL behaviour you get everywhere else in SQL, and it is worth having it in your mental model.

BETWEEN checks whether a value falls inside a range, including both endpoints. It is shorthand for value >= low AND value <= high:

SELECT * FROM orders
WHERE order_amount BETWEEN 5000 AND 20000;

On our sample data, this returns orders 1001 (12,000), 1003 (7,500), 1006 (18,000), and 1007 (9,000). Both endpoints are included — an order worth exactly 5,000 or 20,000 would also match.

BETWEEN works with numbers, dates, and strings. Dates are where it becomes easy to get wrong. If your column stores a timestamp, and you write WHERE created_on BETWEEN '2024-01-01' AND '2024-01-31', the second value is interpreted as midnight on January 31st, not the end of the day. An order placed at 3 PM on the 31st falls outside the range and gets dropped. This is one of the most common off-by-one errors in date filtering.

The fix is either to write the bound as a half-open range — BETWEEN '2024-01-01' AND '2024-02-01' — or to compare dates rather than timestamps by casting the column. Both work; the important thing is to be aware that the trap exists.

You use IS NULL or IS NOT NULL, and you absolutely cannot use = NULL. That is the single most important thing to remember here.

SELECT * FROM customers
WHERE credit_limit IS NULL;

On our sample data, this returns Fiona Mart — the only customer with a NULL credit limit.

Why does = NULL fail? Because in SQL, NULL does not mean "empty" or "zero." It means "unknown." And an unknown value cannot be equal to anything — not even another unknown value. The expression credit_limit = NULL evaluates to "unknown" rather than "true," so no rows match. This is not a bug in your database; it is the SQL standard, and every database follows it.

The same logic applies inside IN lists, NOT IN, and comparisons with <> and !=. If a value can be NULL, be deliberate about how you handle it, and use IS NULL or IS NOT NULL explicitly wherever the behaviour matters.

If you want to give NULL a display value — for example, showing 0 instead of an empty credit limit — use COALESCE(credit_limit, 0) or, in Oracle, NVL(credit_limit, 0). Both return the first non-NULL argument from a list.

SELECT INTO creates a new table and fills it with the rows that your query returns. In SQL Server and PostgreSQL the syntax looks like this:

SELECT * INTO customers_backup FROM customers;

That creates a new table called customers_backup with the same columns and all seven rows from the original. Oracle uses a slightly different form — CREATE TABLE customers_backup AS SELECT * FROM customers; — but the effect is the same.

There are three situations where I reach for this. First, taking a quick backup before a risky data change — grab a snapshot, run the change, and drop the copy once you are confident. Second, materialising a slow query into a fast one — copy the result set into a table, add an index, and query that instead. Third, spinning off a smaller dataset for development or testing without touching production.

Two things to note. The new table does not inherit indexes, constraints, or triggers from the original — it is just the shape and the data. And if you re-run the statement with the same target name, you get an error because the table already exists. Drop it first, or use a fresh name each time.

Conditions in the WHERE clause decide which rows make it into the result. Without them, you get the whole table. With them, you get only the rows that matter for the question you are asking.

SELECT * FROM orders
WHERE order_status = 'Delivered';

On our sample data, this returns four orders — the ones marked Delivered. Simple, but the reasoning behind why you would write a query like this is the interesting part.

In real work, filters exist for three reasons. The first is correctness — a report on last quarter's sales should not include last year's numbers. The second is performance — a filtered query that scans 5% of a table will be roughly twenty times faster than the same query that scans all of it. The third is relevance — you are answering a specific question, and the extra rows are just noise the reader has to mentally filter out.

Beyond simple equality, the operators worth having on hand are >, <, >=, <=, <>, LIKE, IN, BETWEEN, and IS NULL. Combine them with AND, OR, and NOT, but be careful with mixed logic — SQL evaluates AND before OR, so parenthesise when the intent is not obvious.

You put DISTINCT right after SELECT, before the column list, and the database removes rows where every selected column has the same value:

SELECT DISTINCT region FROM customers;

On our sample data, this collapses the seven rows into three unique regions: North, South, and West.

The part that catches people out is that DISTINCT works on the entire row you select, not on a single column. If you write SELECT DISTINCT region, credit_limit FROM customers, two rows are only considered duplicates if both the region and the credit limit match. On our sample data, no two rows have that same pair, so you get all seven rows back — which is rarely what someone new to DISTINCT expects the first time.

DISTINCT is different from GROUP BY in intent, even when it produces the same output. Reach for DISTINCT when you want to remove duplicates from a result set. Reach for GROUP BY when you want to compute aggregates per group. If you find yourself using DISTINCT on a query that also has a GROUP BY, that is usually a sign that something in the query can be simplified.

Mistakes I Keep Seeing in Interviews

These are the errors that come up again and again when I run training sessions or sit on interview panels. Every one of them is easy to make and easy to avoid once you know it is coming.

Mistake one: writing = NULL instead of IS NULL. The query runs without error, returns zero rows, and the candidate assumes the data is missing. In fact the data is there — the comparison was simply wrong. NULL cannot be equal to anything, including itself.
Mistake two: mixing up WHERE and HAVING. The candidate tries to filter an aggregate with WHERE COUNT(*) > 5 and gets a syntax error. The interviewer is watching to see whether they correct themselves in two seconds or flounder for thirty.
Mistake three: using SELECT * everywhere, including in production code. It works, but it signals a lack of care. Interviewers notice.
Mistake four: assuming COUNT(column) and COUNT(*) return the same number. On any table with NULLs, they do not. On our sample data, COUNT(*) is 7 and COUNT(credit_limit) is 6 — Fiona Mart is skipped because her credit limit is NULL.
Mistake five: forgetting that ORDER BY runs after SELECT. This is why you can sort by a column alias but not filter by one. Understanding the execution order rules out a whole class of small mistakes.
Mistake six: putting a filter in the wrong place on a LEFT JOIN. If the filter goes in ON, unmatched left rows survive. If it goes in WHERE, they do not. The two produce very different results, and the choice matters.

Follow-Up Questions Interviewers Ask

Getting the first answer right is only half of the interview. What usually decides the outcome is what happens next — the second and third question that go deeper into whatever you just said. Here is what to prepare for on the topics in this guide.

After "What is GROUP BY?" — expect

  • "What is the difference between GROUP BY and DISTINCT?"
  • "Can you use GROUP BY without an aggregate function? Why would you?"
  • "What happens if you put both WHERE and HAVING in the same query?"
  • "How does NULL behave inside a GROUP BY?"

After "What is a JOIN?" — expect

  • "When would you use LEFT JOIN instead of INNER JOIN?"
  • "What is the difference between ON and WHERE in a LEFT JOIN?"
  • "How would you find customers who have never placed an order?"
  • "What does a join condition without an index do to performance?"

After "How do you filter data?" — expect

  • "What is the difference between WHERE and HAVING?"
  • "Can you use LIKE against a numeric column?"
  • "How does BETWEEN behave with date columns that have a time component?"
  • "What does NOT IN do when the inner list contains NULL?"

For every question above, prepare a second-level answer. The pattern I see over and over is that candidates answer the first question correctly and then stumble on the follow-up. That is what separates a pass from a fail in technical interviews.

Quick Quiz

Three questions to check whether the topics above have landed.

1. Which clause filters rows in a SELECT statement?
2. What does SELECT DISTINCT region FROM customers; return?
3. Which clause filters groups after aggregation?