SQL WHERE & ORDER BY · Guide + 24 Interview Questions

Restricting and Sorting Data in SQL: WHERE and ORDER BY Explained

Almost every query you write needs two things: a way to say which rows you want, and a way to control the order they come back in. That is WHERE and ORDER BY. They look easy, and for the first few weeks they are. Then you hit your first NULL sorting problem, or you write a filter that quietly returns the wrong rows, or someone asks you in an interview why you cannot put COUNT(*) in a WHERE clause. This guide walks through both clauses in the depth they deserve, using the same customer and order tables throughout, and finishes with the twenty-four interview questions that come up most often.

🎯 Key Takeaways
24 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 WHERE and ORDER BY Really Do

Picture a query as a pipeline. Rows go in one end and come out the other, and every clause along the way does something to them. WHERE sits early in that pipeline — it throws away rows that do not match what you asked for. ORDER BY sits near the end — it takes whatever survived and puts it in the sequence you want.

That timing is the whole story. Every interview question about these two clauses, and every bug you will hit in production, comes back to when each one runs.

Performance lives here too. A tight WHERE clause means the rest of the query has fewer rows to work with — often twenty or thirty times fewer. An ORDER BY that matches an existing index lets the database skip the sort step entirely. When someone asks why a report takes forty seconds instead of four hundred milliseconds, the answer is almost always sitting in one of these two clauses.

The Full Syntax in One Place

Here is where WHERE and ORDER BY sit inside a full SELECT. Only SELECT and FROM are required — everything else is optional and gets added when the query needs it.

SELECT   column1, column2, ...
FROM     table_name [alias]
WHERE    condition
GROUP BY column1, column2, ...
HAVING   aggregate_condition
ORDER BY column1 [ASC | DESC] [NULLS FIRST | NULLS LAST], ...
LIMIT    number [OFFSET offset];

The rules worth memorising

  • WHERE runs before GROUP BY. ORDER BY runs after everything else except LIMIT.
  • WHERE cannot hold aggregate functions. If you need to filter on SUM, AVG, or COUNT, that belongs in HAVING.
  • ASC is the default sort direction. You write DESC only when you want the reverse.
  • ORDER BY can sort by columns that never appear in the SELECT list. The sort happens after SELECT, so it sees the full row.
  • NULLS FIRST and NULLS LAST are supported in Oracle and PostgreSQL. MySQL and SQL Server behave differently — check before relying on a default.
An easy mistake to avoid: writing WHERE COUNT(*) > 5 and getting an error about aggregate functions not being allowed. That error is your cue to move the condition into HAVING. Interviewers watch for how quickly you recognise it.

The Order SQL Actually Runs Your Query

You write a query top to bottom. The database runs it in a different order entirely. That single fact explains most "why does this not work" moments you will run into.

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

For the two clauses in this guide, three positions matter:

  • WHERE runs second. It sees one row at a time, before any grouping has happened. That is why COUNT(*) does not exist yet, and why WHERE COUNT(*) > 5 fails outright.
  • ORDER BY runs sixth. By this point the database has fully computed the result — including column aliases. That is why ORDER BY line_total works even when line_total is only an alias defined in the SELECT list.
  • LIMIT runs last. It trims the sorted result. Forget the ORDER BY and you get ten arbitrary rows that can change between runs.
From a debugging session that still makes me smile: A colleague once spent twenty minutes chasing a query that returned the top ten orders correctly until she added a WHERE clause, after which the wrong orders started appearing. The problem was not the WHERE at all. It was that she had pictured LIMIT 10 running first and the sort happening on those ten rows. In reality, the database sorts everything, then trims. Once she saw the pipeline drawn out on a whiteboard, she never made that mistake again.

The Sample Data We Use in Every Example

Every example below runs against the same two tables. Keeping the data consistent means you can compare results side by side and see exactly what each clause changes. If you want to follow along, set up the same rows and the numbers 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 the orders table used in the join and aggregation 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

Four things in this dataset are deliberate. They appear in real interviews almost every time, so it is worth knowing why they are there.

  • Fiona Mart has a NULL credit limit. Every question about NULL handling needs a case like this, and here it is.
  • Alice Corp shows up three times in the orders table. Useful when you want to demonstrate GROUP BY or DISTINCT on something that actually has repeats.
  • Regions repeat across customers. That gives you a natural grouping key and a reason to sort by more than one column.
  • Order statuses span Delivered, Shipped, and Pending. Text filtering examples need at least three distinct values to be interesting.

WHERE — Filtering Rows

The WHERE clause decides which rows make it into the rest of the query. It runs right after FROM, before any grouping or aggregation. Everything it can see is a single row from the tables you selected. Nothing else exists yet.

Comparison operators

SELECT customer_name, credit_limit
FROM   customers
WHERE  credit_limit > 50000;

Three rows come back: Alice Corp, Charlie Ltd, and George Wholesale. Everyone else is gone before the rest of the query even runs. The same shape works with <, >=, <=, =, and <> (or !=).

Logical operators — AND, OR, NOT

SELECT customer_name, region, credit_limit
FROM   customers
WHERE  region = 'North' AND credit_limit > 60000;

Only Charlie Ltd and George Wholesale survive — both are in the North region and both have a credit limit above 60,000. Swap AND for OR and the net widens: every customer in the North region or with a credit limit above 60,000 comes back.

Watch the precedence: SQL evaluates AND before OR. That means A OR B AND C is read as A OR (B AND C), not (A OR B) AND C. When your intent is mixed, add parentheses. Silent logic bugs from missing parentheses have broken more production reports than almost any other SQL mistake.

The LIKE operator — pattern matching

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

Two wildcards matter. The percent sign % stands in for any sequence of characters, including none. The underscore _ stands in for exactly one character. 'A%' finds every name starting with A. '%a%' finds every name with an "a" anywhere in it. '_a%' finds names where the second letter is "a".

The IN operator — matching a list

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

Everyone in the North or South region comes back. It reads cleaner than three OR conditions and scales better when the list grows. The list can also be a subquery — any SELECT that returns a single column works.

The BETWEEN operator — matching a range

SELECT order_id, order_amount
FROM   orders
WHERE  order_amount BETWEEN 5000 AND 20000;

Orders 1001, 1003, 1006, and 1007 come back. Both endpoints are included — an order worth exactly 5,000 or exactly 20,000 would also match. It is a shorter way of writing value >= 5000 AND value <= 20000.

The BETWEEN date trap: if your column stores timestamps, then BETWEEN '2024-01-01' AND '2024-01-31' stops at midnight on the 31st. Anything that happened later that day falls outside the range. Either end the range at '2024-02-01', or cast the timestamp column to a plain date before comparing.

NULL checks — the rule that catches everyone

SELECT customer_name
FROM   customers
WHERE  credit_limit IS NULL;

Fiona Mart comes back — she is the only customer with no credit limit recorded. Now try the same query with = NULL instead of IS NULL and you get zero rows. No error, no warning, just an empty result. NULL is not equal to anything, not even another NULL, so the comparison silently fails. Always use IS NULL or IS NOT NULL.

ORDER BY — Sorting the Result

The ORDER BY clause sorts the final result set. It runs near the end of the query, after SELECT has computed everything, which means it can see column aliases and even columns that never appear in the output.

Basic ascending and descending

SELECT customer_name, credit_limit
FROM   customers
ORDER BY credit_limit DESC;

George Wholesale (80,000) is at the top, then Charlie Ltd (75,000), then Ethan Supplies (55,000), and so on down. Change DESC to ASC — or just leave the direction off — and the smallest credit limit floats to the top instead. ASC is what you get when you write no direction at all.

Multiple sort keys

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

Region first, then credit limit inside each region. This is the pattern behind almost every report anyone has ever asked for: one column to group by, one column to rank within the group. The database applies sort keys left to right — the second one only matters when the first has a tie.

Sorting by a column you did not select

SELECT customer_name, credit_limit
FROM   customers
ORDER BY created_on ASC;

The output shows name and credit limit, but the order follows created_on, which never appears on screen. That works because ORDER BY runs after SELECT — at that point the database still has access to every original column plus the computed ones. You get a clean output without giving up the sort you need.

Sorting by an alias

SELECT customer_name, credit_limit * 1.1 AS adjusted_limit
FROM   customers
ORDER BY adjusted_limit DESC;

You can sort by an alias defined in the SELECT list. You cannot do the same thing in the WHERE clause — the alias does not exist yet when WHERE runs. That asymmetry is one of the cleanest demonstrations of the execution order in the whole language.

Performance — indexes make sorting fast

When the column you sort by has an index, the database can read the rows in the order you want and skip the sort step completely. On a small table you will never notice. On a table with tens of millions of rows it is the difference between a query that returns in milliseconds and one that takes minutes. Whenever a report suddenly gets slow, look at ORDER BY first.

How NULL Behaves in Sorting

NULL is not zero, not an empty string, and not any comparable value. It represents the absence of a value, which means it cannot be compared like a normal number or word. That changes how it sorts.

Database NULL in ascending sort NULL in descending sort Override keywords
Oracle Last First NULLS FIRST, NULLS LAST
PostgreSQL Last First NULLS FIRST, NULLS LAST
MySQL First Last Sort a computed column instead
SQL Server First Last Sort a computed column instead

Say you are running a report on customers by credit limit and you do not want the ones with missing limits floating to the top. You write:

SELECT customer_name, credit_limit
FROM   customers
ORDER BY credit_limit ASC NULLS LAST;

Customers with a limit come first, lowest to highest. Fiona Mart, with no credit limit on file, lands at the bottom. On Oracle and PostgreSQL that is where she would have gone anyway, but on MySQL and SQL Server the default puts her at the top. Being explicit about NULL placement means the query behaves the same way everywhere.

Sorting by a NULL column plus another column

SELECT customer_name, credit_limit, created_on
FROM   customers
ORDER BY credit_limit ASC NULLS LAST, created_on ASC;

Two levels. Credit limit first, with NULLs pushed to the end. Any ties — including all the customers with no credit limit — get sorted again by created_on. Two-level sorting like this is standard practice when the primary sort column is optional.

WHERE vs HAVING — The Distinction That Matters

This is the question interviewers use to tell candidates who memorised SQL from candidates who actually understand it. Both clauses filter. Both look similar. They could not be more different in what they do.

Aspect WHERE HAVING
When it runs Before GROUP BY After GROUP BY
What it filters Individual rows Groups
Can it use aggregates? No Yes
Typical example WHERE order_amount > 5000 HAVING AVG(order_amount) > 5000
What happens if you swap them Syntax error Syntax error

You will often want both in the same query.

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

The WHERE clause trims to delivered orders only, which happens before grouping. Then HAVING keeps only the customers whose delivered average exceeds 5,000. Filtering early with WHERE also means fewer rows have to be grouped in the first place, which is a small performance win that compounds on large tables.

24 Interview Questions and Answers

These are the questions I have seen come up most often across interviews and training sessions. Every answer stands on its own — read any of them in any order. If you are preparing for an interview, read straight through once, then use the search box and the quiz at the bottom to test yourself.

🔍

No matching questions found

Try a keyword like NULL, ORDER BY, or LIKE.

WHERE is how you tell SQL which rows you actually want. Without it, a query hands you back the entire table. With it, you keep only the rows that match your condition.

SELECT * FROM orders WHERE order_amount > 5000;

That query returns orders above 5,000. Everything below sits on disk untouched. It runs right after FROM and before any grouping, which makes it the earliest chance the database has to throw rows away. That is also why it is the single biggest performance lever in most real-world queries — a tight filter reduces work for everything downstream.

The conditions themselves can be anything: an exact value, a range, a pattern in text, or a NULL check. Whether you are narrowing down orders by status, customers by region, or products by category, this is the clause that does the actual cutting.

ORDER BY puts the result set in a specific sequence. You name one or more columns, and optionally give each a direction — ASC or DESC.

SELECT * FROM orders ORDER BY order_amount DESC;

Highest-value orders come first. Leave off the direction and the sort defaults to ascending, lowest to highest.

Sorting is not decoration. A list of a hundred orders sorted by amount tells you something in two seconds that the same list in arbitrary order takes two minutes to read. Reports live and die on this clause. And because ORDER BY runs after SELECT, it can see the full result — including aliases — which means you can sort by things that never appear on screen.

Ascending. If you write ORDER BY credit_limit with no direction, SQL sorts from smallest to largest.

Text goes A to Z. Numbers go smallest to largest. Dates go oldest to newest. Every major database behaves the same way, which is why you almost never see ASC written out — most developers just leave it off and let the default work.

To reverse the order you have to say so explicitly:

SELECT * FROM customers ORDER BY credit_limit DESC;

Now the biggest credit limit comes first. There is no shortcut and no ambiguity. The default is ascending, and descending requires the keyword.

Yes, and you do not need any special syntax for it. Each column in the ORDER BY list carries its own direction.

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

Region sorts alphabetically. Inside each region, credit limit drops from highest to lowest. The database applies the keys left to right, which means the second one only kicks in when the first has a tie.

This is the pattern behind most ranking reports: one column to organise by, one column to rank within. Region and revenue. Product category and units sold. Department and headcount. Once you internalise it, you will write fewer queries with mental gymnastics and more queries that just do the obvious thing.

The difference is timing, and everything else follows from that.

WHERE filters rows before any grouping has happened. When you write WHERE order_amount > 5000, the database throws away every order that does not clear 5,000 before the rest of the query even sees it.

HAVING filters groups after GROUP BY has produced them. If you want customers whose average order value is above 5,000, that average does not exist yet when WHERE runs — it is computed per group later. The condition has to go in HAVING:

SELECT customer_id, AVG(order_amount) AS avg_order
FROM   orders
GROUP BY customer_id
HAVING AVG(order_amount) > 5000;

Short version: WHERE picks rows, HAVING picks groups. Try to swap them and the database stops you immediately with an error about aggregate functions not being allowed in WHERE. That error is not the database being difficult — it is pointing out that the value does not exist yet.

BETWEEN filters a column down to a range. Instead of writing two separate comparisons and joining them with AND, you write one clause that reads the way you would say it out loud.

SELECT * FROM orders WHERE order_amount BETWEEN 5000 AND 20000;

Every order between 5,000 and 20,000, inclusive of both endpoints. Exactly the same as WHERE order_amount >= 5000 AND order_amount <= 20000, just shorter.

It works with numbers, dates, and text, and it is most useful when the user thinks in ranges — prices, ages, timestamps. There is one trap worth knowing: on timestamp columns, BETWEEN '2024-01-01' AND '2024-01-31' stops at midnight on the 31st, not at the end of that day. Anything that happened during the day falls outside the range. End the range at '2024-02-01', or cast the timestamp to a date, and the problem goes away.

LIKE is how you search text when you do not know the exact value you are looking for. You give it a pattern, and it returns anything that fits.

Two wildcards do the work:

  • % matches any sequence of characters, including none.
  • _ matches exactly one character.
SELECT * FROM customers WHERE customer_name LIKE 'A%';

Every customer name starting with A comes back — Alice Corp in our sample. Change the pattern to '%Traders' and you find names ending in "Traders." Change it to '%a%' and you get every name that contains the letter a, anywhere.

Two caveats worth remembering. Case sensitivity depends on the database — some are strict, some are not — so verify before relying on a match. And if your pattern starts with a wildcard, like '%mart', the database cannot use a normal index and has to scan every row. That is fine on small tables but painful at scale.

IN checks whether a column matches any value from a list. It is the cleaner version of a long chain of OR conditions.

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

Everyone in one of those three regions comes back. Written with OR, the same query would be:

SELECT * FROM customers
WHERE region = 'North'
   OR region = 'South'
   OR region = 'West';

Both work, but the IN version is easier to read and scales better when the list grows past four or five values. The list can also be a subquery — a nested SELECT that returns one column.

One thing to know: if the list contains NULL, IN will not match against it. NULL never equals anything, not even another NULL, so the comparison silently fails.

Each of the three does something different, and together they turn a single condition into a filter that actually matches what you have in mind.

  • AND requires every condition to be true.
  • OR requires at least one condition to be true.
  • NOT flips the truth value of the condition that follows.
SELECT * FROM orders
WHERE order_status = 'Delivered' AND order_amount > 5000;

Only delivered orders above 5,000 come back. Now switch the AND to an OR and the net widens considerably:

SELECT * FROM orders
WHERE order_status = 'Delivered' OR order_status = 'Shipped';

Every delivered or shipped order. And with NOT:

SELECT * FROM orders
WHERE NOT order_status = 'Pending';

Everything that is not pending. The one thing to remember is that SQL evaluates AND before OR, just like multiplication before addition in arithmetic. When your intent mixes the two, put parentheses where you mean them. Missing parentheses are the quietest and most expensive SQL bugs in production.

No, and the reason is entirely about timing. Aggregate functions — SUM, AVG, COUNT, MIN, MAX — cannot appear in a WHERE clause.

WHERE runs second in the pipeline. It sees one row at a time. At that moment, no grouping has happened, no aggregation has happened, and the count of rows in a group simply does not exist yet. You cannot evaluate a condition against a value that has not been computed.

-- This runs fine — it filters rows
SELECT * FROM orders WHERE order_amount > 5000;

-- This fails — the average has not been computed yet
SELECT * FROM orders WHERE AVG(order_amount) > 5000;

When you actually need to filter on an aggregate, use HAVING, which runs after GROUP BY and therefore sees the grouped results:

SELECT customer_id, AVG(order_amount)
FROM   orders
GROUP BY customer_id
HAVING AVG(order_amount) > 5000;

Same wording, different clause. The database knows which stage of the pipeline to run it at, and that is the whole trick.

NULL does not compare like a normal value. It represents an unknown, and you cannot say that an unknown is greater than or less than a known number. So SQL has to make a rule for where NULLs go when you sort, and the rule differs between databases.

In Oracle and PostgreSQL the defaults are:

  • ORDER BY column ASC puts NULLs last.
  • ORDER BY column DESC puts NULLs first.

MySQL and SQL Server do the opposite — NULLs come first in ascending order and last in descending. This is exactly the kind of inconsistency that causes cross-database bugs.

SELECT * FROM customers
ORDER BY credit_limit ASC NULLS FIRST;

That forces NULL credit limits to the top of the result, overriding the Oracle and PostgreSQL defaults. NULLS LAST does the opposite. Use them whenever the position of NULLs actually matters in your report — which, in practice, is almost always.

It depends on the database, and there is no universal answer. The two camps are:

  • Oracle and PostgreSQL: NULLs last in ascending order, first in descending.
  • MySQL and SQL Server: NULLs first in ascending order, last in descending.

Under the hood, this is just a choice about whether NULL counts as the smallest possible value or the largest. Neither is wrong. The two systems simply picked different models, and both are consistent within themselves.

Because of that, any query where NULL placement matters should be explicit about it. NULLS FIRST and NULLS LAST work in Oracle and PostgreSQL. On MySQL and SQL Server, you sort a computed column instead — something like ISNULL(credit_limit, 999999) — to force the placement you want.

In Oracle and PostgreSQL, you attach the keyword directly to the column inside ORDER BY.

SELECT * FROM customers
ORDER BY credit_limit ASC NULLS LAST;

NULL credit limits go to the bottom. Flip the keyword and they go to the top:

SELECT * FROM customers
ORDER BY credit_limit ASC NULLS FIRST;

Both keywords work independently of the direction — DESC NULLS FIRST sorts high to low and puts NULLs at the top, which is what you want when you are hunting for incomplete records rather than hiding them.

MySQL and SQL Server do not support these keywords. There you sort a computed column instead. On SQL Server, ORDER BY ISNULL(credit_limit, 1) gets NULLs to the bottom. On MySQL, ORDER BY credit_limit IS NULL, credit_limit does the same thing. Different mechanics, same result.

Yes, and it comes up more than you would expect once you are working with real data. Optional fields — a credit limit, a bonus, a middle name — regularly contain NULLs, and reports still need to sort by them.

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

The database sorts by credit limit first. Customers with a value come in ascending order of that value. Customers with a NULL limit are grouped together wherever the default rule for the database places them — Oracle and PostgreSQL put them at the bottom, MySQL and SQL Server at the top.

Where ties happen — including between all the NULL rows — the second column (customer_name) breaks the tie alphabetically. That gives you a fully deterministic result with no ambiguity left on the table.

If you want NULLs at the bottom regardless of which database you are on:

SELECT * FROM customers
ORDER BY credit_limit ASC NULLS LAST, customer_name ASC;

Now the list is sorted primarily by credit limit, secondarily by name, and every customer with an unknown credit limit sits at the end where nobody has to trip over them.

That works fine. You can sort by any column in the source tables, whether or not it appears in the SELECT list.

The reason is the execution order. ORDER BY runs after SELECT, and by that point the database still has access to every original column plus the ones you computed. So the output can be narrower than what the sort uses.

SELECT customer_id, customer_name
FROM   customers
ORDER BY created_on ASC;

The result shows customer id and name only, but the order follows created_on, which never appears on screen. That gives you a clean report without giving up the ordering the reader expects.

This pattern shows up all the time in real reporting. The reader wants names and ids. You want the sort to reflect when the customer was created, or their most recent order, or something else that would just be noise in the output. Nothing forces you to expose the sort column.

SELECT *
FROM   customers
ORDER BY region ASC NULLS LAST;

Every customer comes back, sorted by region alphabetically, with any rows whose region is NULL pushed to the end.

Now, on Oracle and PostgreSQL, that explicit clause happens to match the default — ascending order already puts NULLs last. So why write it? Portability, mainly. On MySQL and SQL Server, the default puts NULLs first, and without the explicit clause the query would produce a very different report. Writing NULLS LAST makes the intent visible and the behaviour predictable, no matter which engine eventually runs the query.

This is the shape you would use for any report where customers are grouped by region and the unassigned ones are deliberately listed at the end.

SELECT order_id, customer_id, order_amount, order_date
FROM   orders
WHERE  order_amount > 5000
ORDER BY order_date ASC NULLS LAST, order_amount DESC;

The query has three moving parts.

  • WHERE order_amount > 5000 drops every order below 5,000 before anything else happens. Only the meaningful ones survive.
  • ORDER BY order_date ASC NULLS LAST sorts the surviving rows oldest to newest. Orders with no date are pushed to the bottom so they do not crowd the top of the report.
  • order_amount DESC handles ties. When two orders share a date, the larger one comes first.

This is the shape of a real report. The WHERE clause reduces the noise. The ORDER BY puts the useful information at the top. The tie-breaker decides what happens when the primary sort key is not unique — which, in real data, is almost always.

SELECT *
FROM   products
WHERE  price BETWEEN 50 AND 500
ORDER BY price DESC;

The WHERE clause keeps only products with a price in that range, inclusive of both endpoints. The ORDER BY then sorts them from most expensive to least, so the top of the list is the most valuable product in the range.

Two clauses, two jobs. Filter the range, then order it so the reader gets what they actually wanted without having to scroll through noise. It is the same pattern behind any ranked-range report — top products by revenue, top customers by spend, top students by score. Once you have the pattern, the queries write themselves.

Both organise data, but they do completely different things to it.

GROUP BY merges rows that share the same values in the columns you name. It reduces the result — you start with many rows and end up with one per group.

SELECT region, SUM(credit_limit) AS total_credit
FROM   customers
GROUP BY region;

That returns one row per region, with the total credit limit for each. The individual customers disappear into their region groups.

ORDER BY sorts rows. It does not reduce them.

SELECT region, SUM(credit_limit) AS total_credit
FROM   customers
GROUP BY region
ORDER BY total_credit DESC;

Now the same groups come back, but sorted with the biggest total at the top. GROUP BY shapes the result. ORDER BY presents it. When you see them together in a query, that is exactly what they are doing — one reshaping, the other sequencing.

A useful rule of thumb: if you are summarising, you want GROUP BY. If you are just rearranging what is already there, you want ORDER BY.

COUNT() returns a number: the number of rows in a result, or the number of non-NULL values in a column.

SELECT COUNT(*) FROM orders;

Every row in the orders table is counted, NULLs included. On our sample data, that returns 7.

SELECT COUNT(credit_limit) FROM customers WHERE region = 'North';

That counts North-region customers whose credit_limit column is not NULL. If Fiona Mart were in the North, she would not be counted — she has no credit limit on file. This is the difference between COUNT(*) and COUNT(column): the wildcard counts rows, the column counts values.

COUNT pairs most often with GROUP BY, where it tells you how many records are in each group.

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

One row per region, with the number of customers in each. Combine that with HAVING and you can filter groups by size — show me only the regions with more than two customers, and so on.

The single biggest win is an index on the columns you sort by. If the index already stores rows in the order you want, the database can walk through it in sequence and skip the sort step entirely.

CREATE INDEX idx_customers_credit ON customers (credit_limit);

After that index exists, SELECT * FROM customers ORDER BY credit_limit runs from the index directly. The database does not have to load every row and sort them in memory. That difference is often the gap between a query that takes a second and one that takes forty.

Three other habits worth building:

  • Filter before you sort. A tight WHERE clause reduces the number of rows that have to be ordered. Sorting ten rows is free. Sorting ten million is not.
  • Limit the result if you can. If the report only needs the top ten, add LIMIT 10. The database can stop after finding ten matching rows instead of ordering the full result set.
  • Match the index order to the sort order. If you frequently sort by region, credit_limit, build the index in that exact order. A single-column index on credit_limit will not help once region is the primary key.

None of these are advanced tricks. They are the everyday moves that separate a fast report from a slow one. When a query that used to run in a second suddenly takes a minute, ORDER BY is the first place to look.

Knowing the difference matters because they do completely different things, and confusing them produces either the wrong result or a query that is far more complicated than it needs to be.

ORDER BY sets the sequence of the rows in the result. It does not change what is in the result — just the order. Every row you started with is still there.

GROUP BY merges rows that share values in the columns you name. It is almost always paired with an aggregate function — SUM, AVG, COUNT — because its purpose is to summarise. You start with many rows and end up with one per group.

-- ORDER BY: same 7 customers, reordered
SELECT customer_name, credit_limit FROM customers ORDER BY credit_limit DESC;

-- GROUP BY: 3 regions, credit limits collapsed into totals
SELECT region, SUM(credit_limit) FROM customers GROUP BY region;

When you see both in the same query, they are working together. GROUP BY decides what shape the result takes. ORDER BY decides how it is presented. Understanding which is doing what is most of what separates someone who writes SQL from someone who writes SQL well.

NULL gets special treatment in ORDER BY because it is not a value in the ordinary sense. It represents missing information, and missing information cannot be compared.

In Oracle and PostgreSQL:

  • Sorting ASC — NULLs come last.
  • Sorting DESC — NULLs come first.

MySQL and SQL Server flip both of those. That alone is a reason to be explicit about NULL placement in any query that might run on more than one database.

SELECT * FROM customers
ORDER BY credit_limit ASC NULLS LAST;

That forces NULL credit limits to the bottom no matter the engine. Swap to NULLS FIRST when you want them at the top — for example, when you are running a data-quality report and want to see all the incomplete records immediately.

The bigger point is that missing data is common in real systems. Handling it consistently in reports is part of writing SQL that other people can trust.

This is a common point of confusion, and the confusion comes from what DISTINCT actually removes. It removes duplicate rows — not duplicate values from one column while keeping others.

SELECT DISTINCT region FROM customers;

That returns a unique list of regions. If three customers share the North region, North appears once. Nothing else is in the output. Just the unique regions.

Add a second column and de-duplication applies to the pair:

SELECT DISTINCT region, credit_limit FROM customers;

Two rows are only considered duplicates if both region and credit_limit match. Two customers in the same region with different credit limits both come back, because the pair is different.

If what you actually want is one row per region with a value from another column — say, the count of customers — reach for GROUP BY:

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

One row per region, with the customer count for each. That is the correct tool when your goal is one row per key rather than de-duplication. DISTINCT and GROUP BY look similar in simple cases but solve different problems.

Mistakes I Keep Seeing in Interviews

These are the errors that come up over and over when I run training sessions or sit on interview panels. None of them are exotic. All of them are easy to avoid once you know they exist.

Mistake one: putting an aggregate in WHERE. Writing WHERE COUNT(*) > 5 or WHERE AVG(order_amount) > 5000 produces a syntax error, because aggregates do not exist when WHERE runs. Move the condition into HAVING.
Mistake two: assuming ORDER BY behaves the same way on NULLs everywhere. Oracle and PostgreSQL put them last in ascending order. MySQL and SQL Server put them first. If your report depends on NULL placement, use NULLS FIRST or NULLS LAST.
Mistake three: using BETWEEN on a timestamp column and expecting the end date to include the whole day. It stops at midnight. Use a half-open range or cast the column to a date.
Mistake four: writing WHERE credit_limit = NULL. That returns zero rows silently — no error, no warning, no hint that anything is wrong. Use IS NULL. This is the single most common SQL mistake in the world, and the fix is one word.
Mistake five: mixing AND and OR without parentheses. SQL evaluates AND first, so A OR B AND C means A OR (B AND C), not (A OR B) AND C. When your intent is mixed, add parentheses. This mistake has probably broken more production reports than any other single thing in SQL.
Mistake six: assuming ORDER BY runs after LIMIT. The pipeline sorts first, then trims. Most people's intuition gets this backwards, and the queries still work — until the day the intuition matters and it does not.

Follow-Up Questions Interviewers Ask

Getting the first answer right is only half of the interview. The second and third questions are where candidates usually separate themselves — some handle the follow-up well, some stumble. Here is what to prepare for on the topics in this guide.

After "What does WHERE do?" — expect

  • "What is the difference between WHERE and HAVING?"
  • "Can you use an aggregate function in WHERE? Why not?"
  • "What is the execution order of a SQL query?"
  • "How does WHERE affect performance?"

After "How does ORDER BY work?" — expect

  • "What is the default sort direction?"
  • "How does SQL handle NULLs when sorting?"
  • "Can you sort by a column you did not select?"
  • "How would you optimize an ORDER BY on a huge table?"

After "What is the difference between GROUP BY and ORDER BY?" — expect

  • "Can you use both in the same query? Give an example."
  • "What happens if you use ORDER BY without GROUP BY on an aggregated query?"
  • "Which one runs first?"
  • "Where does DISTINCT fit in — is it the same as GROUP BY?"

For every question above, prepare a second-level answer. The candidates who pass are not the ones who memorised the first answer — they are the ones who kept going for another thirty seconds and said something useful about why it matters.

Quick Quiz

Three questions to check whether the topics above have landed.

1. What is the default sort order when you write ORDER BY with no direction?
2. Which clause filters groups after aggregation?
3. How would you force NULL values to the bottom of an ascending sort?