Filtering & Sorting
The Three Tools
| Clause | Job | Example |
|---|---|---|
WHERE | Keep only the rows you need | Employees in Pune |
ORDER BY | Put the result in a fixed order | Highest salary first |
LIKE | Match text by pattern instead of exact value | Emails ending in @gmail.com |
Think of a stack of job applications. WHERE throws out the ones that don't qualify, ORDER BY sorts the rest by score, and LIKE finds the ones whose name starts with "A" when you don't remember the full name.
Sample Table
We'll use a bigger version of the Employees table from the JOINs page.
Employees
| employee_id | name | department_id | salary | hire_date | city | |
|---|---|---|---|---|---|---|
| 1 | Aakash | aakash@company.com | 101 | 85000 | 2022-03-15 | Bengaluru |
| 2 | Priya | priya.hr@company.com | 102 | 60000 | 2023-07-01 | Pune |
| 3 | Rahul | rahul@company.com | 101 | 95000 | 2021-11-20 | Bengaluru |
| 4 | Neha | neha_s@company.com | NULL | 55000 | 2024-01-10 | Delhi |
| 5 | Arjun | arjun@gmail.com | 103 | 72000 | 2024-06-05 | Pune |
| 6 | Ananya | ananya@company.com | 101 | 95000 | 2020-09-12 | Hyderabad |
The Order SQL Server Runs a Query
You write SELECT first, but SQL Server processes the clauses in a different order:
11. FROM / JOIN → pick the tables22. WHERE → filter rows33. GROUP BY → make groups44. HAVING → filter groups55. SELECT → choose columns, create aliases66. ORDER BY → sort77. TOP / OFFSET → cut the resultThis explains two rules:
- A column alias made in
SELECTcan't be used inWHERE(WHERE runs earlier), but can be used inORDER BY(runs later). WHEREruns before sorting, so filtering early means fewer rows to sort.
WHERE: The Basics
-- comparisonSELECT name, salary FROM Employees WHERE salary >= 85000;-- several values: IN is shorter than many ORsSELECT name FROM Employees WHERE city IN ('Pune', 'Delhi');-- range: BETWEEN includes both endsSELECT name FROM Employees WHERE salary BETWEEN 60000 AND 85000;-- NULL: use IS NULL, never = NULLSELECT name FROM Employees WHERE department_id IS NULL; -- Neha
AND runs before OR. Use parentheses to say what you mean:
-- read as: Pune OR (Bengaluru AND salary > 80000)SELECT name FROM EmployeesWHERE city = 'Pune' OR city = 'Bengaluru' AND salary > 80000;-- Aakash, Priya, Rahul, Arjun (Pune employees with any salary slip in)-- what we meant: (Pune OR Bengaluru) AND salary > 80000SELECT name FROM EmployeesWHERE (city = 'Pune' OR city = 'Bengaluru') AND salary > 80000;-- Aakash, Rahul
Two NULL traps:
-- 1. = NULL never matches. Comparing anything with NULL gives UNKNOWN, not true.SELECT name FROM Employees WHERE department_id = NULL; -- 0 rows-- 2. NOT IN with a NULL in the list returns NOTHINGSELECT name FROM Employees WHERE department_id NOT IN (102, NULL); -- 0 rows-- each row is checked as: department_id <> 102 AND department_id <> NULL-- "<> NULL" is UNKNOWN, so no row passes-- safe version with a subquery: NOT EXISTSSELECT e.nameFROM Employees eWHERE NOT EXISTS (SELECT 1 FROM Departments dWHERE d.department_id = e.department_idAND d.department_name = 'HR');
WHERE Optimization
Index Seek vs Index Scan
Assume these indexes exist:
CREATE INDEX IX_Employees_hire_date ON Employees (hire_date);CREATE INDEX IX_Employees_email ON Employees (email);CREATE INDEX IX_Employees_dept_salaryON Employees (department_id, salary)INCLUDE (name); -- name stored in the index too, so no table lookup
In the execution plan (the steps SQL Server chose), look for:
| Plan step | Meaning | Speed on a big table |
|---|---|---|
| Index Seek | Jumps straight to matching rows using the index | Fast |
| Index Scan / Table Scan | Reads the whole index or table | Slow |
| Key Lookup | Found the row in the index, then goes back to the table for missing columns | OK for few rows, slow for many |
Write SARGable Conditions
A condition is SARGable (from Search ARGument able) when SQL Server can use an index to seek for it. The main rule: keep the column alone on one side. If you wrap the column in a function or calculation, SQL Server must compute it for every row, so it scans.
| Not SARGable (scan) | SARGable (seek) |
|---|---|
WHERE YEAR(hire_date) = 2024 | WHERE hire_date >= '2024-01-01' AND hire_date < '2025-01-01' |
WHERE salary * 12 > 1000000 | WHERE salary > 1000000 / 12.0 |
WHERE LEFT(name, 2) = 'Aa' | WHERE name LIKE 'Aa%' |
WHERE ISNULL(department_id, 0) = 101 | WHERE department_id = 101 |
WHERE UPPER(email) = 'ARJUN@GMAIL.COM' | WHERE email = 'arjun@gmail.com' (default collation ignores case) |
WHERE CAST(employee_id AS VARCHAR(10)) = '5' | WHERE employee_id = 5 |
WHERE email LIKE '%@gmail.com' | Leading wildcard can't seek; see the LIKE section |
-- scan: YEAR() runs on every rowSELECT name FROM EmployeesWHERE YEAR(hire_date) = 2024;-- seek: the column is left alone, the range is on the other sideSELECT name FROM EmployeesWHERE hire_date >= '2024-01-01'AND hire_date < '2025-01-01'; -- Neha, Arjun
Using >= and < for date ranges also avoids a BETWEEN bug: with a datetime column, BETWEEN '2024-01-01' AND '2024-12-31' misses anything after midnight on 31 December.
Other Tips
| Tip | Why |
|---|---|
| Match data types | Comparing a varchar column with an nvarchar value (like N'abc', the default string type in many drivers, including Node.js mssql) makes SQL Server convert the column on every row, which can turn a seek into a scan. Pass parameters with the column's exact type |
| Select only needed columns | SELECT * forces Key Lookups; with only indexed or INCLUDEd columns, the index answers the query alone (a covering index) |
OR across different columns | WHERE email = 'x' OR city = 'Pune' often scans; two queries joined with UNION ALL can each seek |
| Composite index column order | (department_id, salary) helps WHERE department_id = 101 AND salary > 80000, but not WHERE salary > 80000 alone (leftmost prefix rule) |
| Measure, don't guess | Run SET STATISTICS IO, TIME ON; and compare logical reads before and after a change |
SET STATISTICS IO, TIME ON;SELECT name, salaryFROM EmployeesWHERE department_id = 101 AND salary > 80000;-- Messages tab: "logical reads 2" = good; thousands of reads = scanning-- Execution plan (Ctrl + M in SSMS): Index Seek on IX_Employees_dept_salary
ORDER BY
ORDER BY sorts the result. ASC (ascending, smallest first) is the default; DESC is largest first.
Without ORDER BY, the row order is not guaranteed. It may look stable today and change after an index change or a bigger dataset.
-- highest salary first; same salary -> alphabetical by nameSELECT name, salaryFROM EmployeesORDER BY salary DESC, name ASC;
| name | salary |
|---|---|
| Ananya | 95000 |
| Rahul | 95000 |
| Aakash | 85000 |
| Arjun | 72000 |
| Priya | 60000 |
| Neha | 55000 |
The second column, name, only matters when the first is tied: Ananya and Rahul both earn 95000.
Sorting by Aliases and Expressions
-- alias works in ORDER BY (it runs after SELECT)SELECT name, salary * 12 AS annual_salaryFROM EmployeesORDER BY annual_salary DESC;-- but NOT in WHERE: error "Invalid column name 'annual_salary'"-- SELECT name, salary * 12 AS annual_salary FROM Employees WHERE annual_salary > 1000000;-- custom order with CASE: Bengaluru first, then Pune, then the restSELECT name, cityFROM EmployeesORDER BYCASE cityWHEN 'Bengaluru' THEN 1WHEN 'Pune' THEN 2ELSE 3END,name;
NULLs in ORDER BY
In SQL Server, NULL sorts first in ascending order and last in descending order. There's no NULLS LAST keyword (PostgreSQL has one), so use a CASE:
-- default: Neha (NULL) comes firstSELECT name, department_id FROM Employees ORDER BY department_id;-- push NULLs to the endSELECT name, department_idFROM EmployeesORDER BYCASE WHEN department_id IS NULL THEN 1 ELSE 0 END,department_id;
TOP, WITH TIES, and Pagination
-- top 2 earnersSELECT TOP (2) name, salaryFROM EmployeesORDER BY salary DESC; -- Ananya, Rahul-- WITH TIES: also return rows tied with the last oneSELECT TOP (1) WITH TIES name, salaryFROM EmployeesORDER BY salary DESC; -- Ananya AND Rahul (both 95000)-- pagination: page 2, 3 rows per page (OFFSET requires ORDER BY)SELECT name, salaryFROM EmployeesORDER BY employee_idOFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY; -- Neha, Arjun, Ananya
TOP without ORDER BY returns any rows, not the "first" ones. For deep pages, OFFSET gets slow, because SQL Server still reads and skips every earlier row. Keyset pagination fixes this (WHERE employee_id > @last_id ORDER BY employee_id), as covered on the Pagination page.
Sorting Cost
Sorting many rows is expensive: it uses memory, and if the sort doesn't fit, it spills to tempdb on disk. An index already in the right order removes the Sort step from the plan:
1WHERE department_id = 101 ORDER BY salary DESC2 with index (department_id, salary) → Index Seek, rows already in salary order, no Sort3 without it → Scan → Sort → slowerLIKE
LIKE matches text against a pattern. Patterns use wildcards: special characters that stand for unknown characters.
| Wildcard | Matches | Example | Matches in our table |
|---|---|---|---|
% | Any number of characters (including none) | 'A%' | Aakash, Arjun, Ananya |
_ | Exactly one character | '_r%' | Priya, Arjun (second letter is r) |
[ ] | One character from a set or range | '[AR]%' | Aakash, Rahul, Arjun, Ananya |
[^ ] | One character not in the set | '[^A]%' | Priya, Rahul, Neha |
[ ] and [^ ] are SQL Server features; they don't exist in standard SQL or PostgreSQL's LIKE.
-- starts withSELECT name FROM Employees WHERE name LIKE 'A%'; -- Aakash, Arjun, Ananya-- ends withSELECT name FROM Employees WHERE name LIKE '%a'; -- Priya, Neha, Ananya-- containsSELECT name, email FROM Employees WHERE email LIKE '%.hr@%'; -- Priya-- exact length: 5 charactersSELECT name FROM Employees WHERE name LIKE '_____'; -- Priya, Rahul, Arjun-- exclude a patternSELECT name, email FROM Employees WHERE email NOT LIKE '%@company.com'; -- Arjun
Searching for a Literal % or _
_ is a wildcard, so '%_%' matches every non-empty email. To search for a real underscore, put it in brackets or use ESCAPE:
-- wrong: _ means "any one character"SELECT email FROM Employees WHERE email LIKE '%_%'; -- all 6 rows-- right: [_] means a literal underscoreSELECT email FROM Employees WHERE email LIKE '%[_]%'; -- neha_s@company.com-- same with ESCAPE: the character after ! is taken literallySELECT email FROM Employees WHERE email LIKE '%!_%' ESCAPE '!';
Case Sensitivity
Whether LIKE (and =) ignores case depends on the column's collation (the rules for comparing and sorting text). The common default, SQL_Latin1_General_CP1_CI_AS, is CI = case-insensitive, so 'a%' also matches Aakash. To force a case-sensitive match:
SELECT name FROM EmployeesWHERE name LIKE 'a%' COLLATE Latin1_General_CS_AS; -- CS = case-sensitive: 0 rows
Applying COLLATE to the column has the same effect as a function: SQL Server can't seek on the index.
LIKE and Indexes
| Pattern | Can use an index seek? | Why |
|---|---|---|
'Aak%' (fixed start) | Yes | The index is sorted, so SQL Server jumps to "Aak" and reads forward |
'%@gmail.com' (leading %) | No | Matches could be anywhere in the sorted order, so it scans |
'%rah%' (contains) | No | Same reason |
For searching words inside large text, like product descriptions or reviews, LIKE '%word%' doesn't scale. SQL Server's Full-Text Search (CONTAINS, FREETEXT) uses a special word index built for that.
LIKE From Application Code
When the search text comes from a user, parameterize it, and escape their wildcards so a typed % or _ is treated as text:
-- @search comes from the app as a parameter, never glued into the SQL stringDECLARE @search NVARCHAR(50) = 'rah';SELECT nameFROM EmployeesWHERE name LIKE @search + '%'; -- starts with: index-friendly-- escape user wildcards before using themSET @search = REPLACE(REPLACE(REPLACE(@search, '[', '[[]'), '%', '[%]'), '_', '[_]');
Interview Angle
How to Explain it in an Interview
Common mistakes:
WHERE column = NULLinstead ofIS NULL.NOT INwith a list or subquery that can containNULL.- Wrapping indexed columns in functions (
YEAR(),ISNULL(),UPPER()). - Expecting a fixed order without
ORDER BY, or usingTOPwithout it. - Forgetting that
_is a wildcard inLIKE.
Summary
1Query order → FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → TOP/OFFSET2WHERE → AND before OR (use parentheses), IS NULL, avoid NOT IN with NULLs3SARGable → column alone on one side → Index Seek4 → no functions/maths on the column, match data types, ranges for dates5Measure → execution plan (Seek vs Scan) + SET STATISTICS IO6ORDER BY → only way to guarantee order, ASC default, ties broken by next column7 → NULLs first in ASC (use CASE to move), alias allowed here8TOP / OFFSET → need ORDER BY; WITH TIES includes equal values9LIKE → % any, _ one, [ ] set, [^ ] not in set10 → [_] or ESCAPE for literal wildcards11 → 'abc%' can seek, '%abc' can't → Full-Text Search for word searchNext, GROUP BY and HAVING show how to summarise rows, like average salary per department, and how HAVING filters those groups.