Readineer
PATTERN 1SQL Patterns / Foundations

SQL WHERE: filtering rows and combining conditions correctly

How WHERE filters rows, and how to combine conditions with AND / OR, IN, LIKE, and BETWEEN, plus the mistakes that return the wrong rows without ever raising an error.

8 min read10 sections6 predictions
01Foundation

What a WHERE clause does

A table can hold millions of rows, but a query usually needs only the ones that meet a requirement. The WHERE clause describes that requirement: SQL checks it against every candidate row and keeps the row only when its condition is TRUE.

The per-row decision
  • If the condition is TRUE, the row is included.
  • If it is FALSE, the row is left out.
  • If it is UNKNOWN, which can happen when NULL is involved, the row is also left out.

Suppose we need orders with an amount greater than 100.

Orders
order_idcustomeramount
1Asha120
2Ben80
3Carla200
4Dev60
Query
SELECT order_id, customer, amount
FROM orders
WHERE amount > 100
ORDER BY order_id;

SQL applies the condition amount > 100 to each row:

the per-row decision
customeramountamount > 100?included?
Asha120TRUEYes
Ben80FALSENo
Carla200TRUEYes
Dev60FALSENo
Result
order_idcustomeramount
1Asha120
3Carla200

Ben and Dev are left out because their amounts are not greater than 100.

Key idea
A WHERE clause includes only the rows for which its complete condition evaluates to TRUE.
02The building blocks

Comparison operators

Most WHERE conditions begin with a comparison.

comparison operators
operatormeaningexample
=equal tostatus = 'paid'
<>not equal tostatus <> 'cancelled'
>greater thanamount > 100
>=greater than or equal toamount >= 100
<less thanamount < 500
<=less than or equal toamount <= 500

A comparison handles one condition at a time. Real requirements often involve several conditions, patterns, lists, or ranges, so SQL adds AND and OR, IN and NOT IN, LIKE, and BETWEEN. Let’s look into how each one works, with examples, common mistakes, and a quick quiz.

03Combining conditions

AND and OR

Use AND when every connected condition must be true; use OR when at least one may be true.

We’ll use this table throughout.

Orders
order_idcountrystatus
1Indiapaid
2Indiacancelled
3UKpending
4Indiapending
5UAErefunded

Using AND: every condition must be true

Problem: Find paid orders from India.

Solution: Here the country must be India and the status must be paid.

Query
SELECT order_id, country, status
FROM orders
WHERE country = 'India'
  AND status = 'paid';
Result
order_idcountrystatus
1Indiapaid

Only Order 1 is from India with a status equal to PAID, so only this one is included in the result.

Using OR: at least one condition may be true

Problem: To find all paid or pending orders:

Query
SELECT order_id, country, status
FROM orders
WHERE status = 'paid'
   OR status = 'pending'
ORDER BY order_id;
Result
order_idcountrystatus
1Indiapaid
3UKpending
4Indiapending
04The featured failure

Combining AND and OR: AND runs first

Now a more specific requirement: return paid or pending orders from India. A natural first attempt lists the three conditions in order. It is valid SQL and raises no error.

PAUSE & PREDICTWhat does this query actually return?
Query
SELECT order_id, country, status
FROM orders
WHERE country = 'India'
  AND status = 'paid'
   OR status = 'pending';
Your prediction

Evaluate the grouped condition row by row and the stray UK order is obvious:

how SQL grouped it: (India AND paid) OR pending
order_idIndia AND paidpendingsurvives?
1TRUEFALSEYes
2FALSEFALSENo
3FALSETRUEYes
4FALSETRUEYes
5FALSEFALSENo

The database followed the query exactly. The query simply did not express the intended requirement, which is what makes a precedence mistake dangerous: it runs successfully while returning incorrect data.

05The fix

Parenthesise the OR

The two allowed statuses belong together, so group them. The country condition now applies to both, and the equivalent IN form reads even more directly.

Query
SELECT order_id, country, status
FROM orders
WHERE country = 'India'
  AND (status = 'paid' OR status = 'pending')
ORDER BY order_id;
Result
order_idcountrystatus
1Indiapaid
4Indiapending
Query
-- same intent, harder to get wrong
WHERE country = 'India'
  AND status IN ('paid', 'pending')
The practical rule
Whenever AND and OR appear in the same WHERE clause, add parentheses to make the grouping explicit, even when the default precedence would be correct. Parentheses document the intent for the next reader and make the query safer to modify later.

Quick check: AND / OR

Requirement: high-priority tickets from the data team whose status is open or pending.

Three candidate conditions: which one expresses it correctly?

PAUSE & PREDICTWhich condition correctly expresses the requirement?
Query
-- Option A
WHERE priority = 'high'
  AND team = 'data'
  AND status = 'open'
   OR status = 'pending'

-- Option B
WHERE priority = 'high'
  AND team = 'data'
  AND (status = 'open' OR status = 'pending')

-- Option C
WHERE (priority = 'high' AND team = 'data')
   OR (status = 'open' AND status = 'pending')
Your prediction
06Matching a list

IN and NOT IN

Use IN when a column may match any value in a list. Instead of a chain of ORs:

Query
WHERE status = 'paid'
   OR status = 'pending'
   OR status = 'processing'

write:

Query
WHERE status IN ('paid', 'pending', 'processing')

Using the earlier orders table:

Query
SELECT order_id, country, status
FROM orders
WHERE status IN ('paid', 'pending')
ORDER BY order_id;
Result
order_idcountrystatus
1Indiapaid
3UKpending
4Indiapending

IN performs exact-value matching; it does not do pattern matching. Use NOT IN when a value must not belong to a list.

The NOT IN + NULL trap

For a fixed list of known, non-null values, NOT IN is straightforward. The trap appears when the list comes from another table or subquery that can contain NULL.

Customers
customer_idcustomer
1Asha
2Ben
3Carla
Blocked customers
customer_id
2
NULL

We want customers who are not blocked. You might expect Asha and Carla.

PAUSE & PREDICTWhat does this query return?
Query
SELECT customer_id, customer
FROM customers
WHERE customer_id NOT IN (
    SELECT customer_id
    FROM blocked_customers
);
Your prediction

For Asha, NOT IN (2, NULL) behaves like a chain of not-equals joined by AND:

1 <> 2      → TRUE
1 <> NULL   → UNKNOWN

TRUE AND UNKNOWNUNKNOWN

WHERE UNKNOWN → row removed

The same thing happens to Carla. Because one value on the right is unknown, the whole condition is UNKNOWN, and the WHERE clause keeps only TRUE.

Safer approach: NOT EXISTS

When excluding rows based on a subquery, NOT EXISTS usually expresses the requirement more safely:

Query
SELECT c.customer_id, c.customer
FROM customers AS c
WHERE NOT EXISTS (
    SELECT 1
    FROM blocked_customers AS b
    WHERE b.customer_id = c.customer_id
);
Result
customer_idcustomer
1Asha
3Carla

NOT EXISTS asks a direct question for each customer: is there no blocked-customer row with the same id? The unrelated NULL in the blocked table does not make the condition unknown. You can also strip the nulls inside the subquery, but NOT EXISTS usually communicates the anti-matching intent more clearly:

Query
WHERE customer_id NOT IN (
    SELECT customer_id
    FROM blocked_customers
    WHERE customer_id IS NOT NULL
)

Two more IN gotchas

IN matches exact values, not patterns

WHERE country IN ('Ind%') does not find countries starting with Ind; IN treats 'Ind%' as literal text. Use LIKE for patterns: WHERE country LIKE 'Ind%'.

NULL on the left is never matched

A row with status = NULL satisfies neither status IN (…) nor status NOT IN (…). Test for missing values with IS NULL / IS NOT NULL.

Quick check: NOT IN

orders
order_idstatus
1paid
2pending
3cancelled
4NULL
PAUSE & PREDICTWhich rows does this query return?
Query
SELECT order_id, status
FROM orders
WHERE status NOT IN ('cancelled', 'refunded');
Your prediction
07Matching text patterns

LIKE

Use LIKE when you need to match a text pattern rather than one exact value. It uses two wildcards.

Customers
customer_idcustomer
1Asha
2Amit
3Anand
4Bhavna
5Sam
6Ali
wildcards
wildcardmeaning
%any sequence of zero or more characters
_exactly one character

Begins with, ends with, contains

Names beginning with A
SELECT customer_id, customer
FROM customers
WHERE customer LIKE 'A%'
ORDER BY customer_id;
Result
customer_idcustomer
1Asha
2Amit
3Anand
6Ali

Because % matches zero or more characters, position matters:

Query
LIKE 'an%'   -- begins with an
LIKE '%an'   -- ends with an
LIKE '%an%'  -- contains an anywhere

One character with the underscore

The underscore matches exactly one character. LIKE '_m%' means: any one character, then m, then anything, so Amit matches because its second character is m.

Query
SELECT customer_id, customer
FROM customers
WHERE customer LIKE '_m%';

Use NOT LIKE to exclude a pattern. As with other comparisons, a NULL value produces UNKNOWN and is left out.

LIKE gotchas

= does not understand wildcards. WHERE customer = 'A%' looks for the literal text A%. Choose the operator by intent: = for one exact value, IN for several, LIKE for a pattern.
LIKE normally matches the complete value. LIKE 'mit' matches only the exact string mit; to find it inside Amit, write LIKE '%mit%'.
A literal underscore or percent needs an escape

With codes like SAVE_10, SAVEA10, SAVEB10, the condition LIKE 'SAVE_10' treats _ as any one character and can match all three. Escape it to mean a literal underscore:

Query
WHERE code LIKE 'SAVE!_10' ESCAPE '!';

The same technique works for a literal %.

Case sensitivity depends on the database

Do not assume LIKE 'a%' always matches Asha; behaviour varies by database, collation, and configuration. When you need case-insensitive matching, make it explicit, e.g.

Query
WHERE LOWER(customer) LIKE 'a%'

Be aware that wrapping a column in a function can prevent a regular index from being used; on large datasets, check your database’s indexing options.

Quick check: LIKE

Which condition finds names whose second character is m?

PAUSE & PREDICTWhich condition matches a second character of m?
Query
-- Option A
WHERE customer LIKE 'm%'

-- Option B
WHERE customer LIKE '_m%'

-- Option C
WHERE customer LIKE '%_m'
Your prediction
08Matching ranges

BETWEEN

Use BETWEEN when a value must fall within a lower and upper boundary.

Orders
order_idamount
180
2100
3150
4300
5450
Query
SELECT order_id, amount
FROM orders
WHERE amount BETWEEN 100 AND 300
ORDER BY order_id;
Result
order_idamount
2100
3150
4300

amount BETWEEN 100 AND 300 is equivalent to amount >= 100 AND amount <= 300.

BETWEEN gotchas

BETWEEN is inclusive: both 100 and 300 are kept. If the rule is “above 100 but below 300,” use > 100 AND < 300; for “at least 100 but below 300,” use >= 100 AND < 300. Let the operators come from the business rule, not habit.
Write the lower boundary first. BETWEEN 300 AND 100 means amount >= 300 AND amount <= 100, which no ordinary value can satisfy.

Timestamps: prefer a half-open range

BETWEEN is misleading on timestamp columns. Suppose created_at stores a date and a time:

Orders
order_idcreated_at
12026-07-01 09:00:00
22026-07-31 00:00:00
32026-07-31 18:30:00
42026-08-01 00:00:00

To get every July order, BETWEEN '2026-07-01' AND '2026-07-31' looks reasonable, but the end may be read as 2026-07-31 00:00:00, which drops 2026-07-31 18:30:00. Use an inclusive start and an exclusive next boundary instead:

Query
SELECT order_id, created_at
FROM orders
WHERE created_at >= '2026-07-01'
  AND created_at <  '2026-08-01';
Half-open range
start <= value < next_start includes the complete final day, works regardless of timestamp precision, and prevents a boundary value from being counted in two adjacent periods. Use BETWEEN comfortably for clear numeric ranges; for timestamp periods, prefer the half-open form.

Quick check: BETWEEN

PAUSE & PREDICTWhich of 99, 100, 250, 300, 301 satisfy amount BETWEEN 100 AND 300?
Query
WHERE amount BETWEEN 100 AND 300
Your prediction
09Put it together

Translating a business requirement

Most filtering mistakes happen before the SQL is written, when the requirement has not been divided into clear logical groups. Take:

Key idea
Return Indian orders placed in July that are either paid or pending and have an amount between 100 and 500.

Break it into separate decisions, then write one condition for each:

  • Country must be India.
  • Order time must fall within July.
  • Status may be paid or pending.
  • Amount must be from 100 through 500.
Query
SELECT
    order_id,
    customer,
    country,
    status,
    amount,
    created_at
FROM orders
WHERE country = 'India'
  AND created_at >= '2026-07-01'
  AND created_at <  '2026-08-01'
  AND status IN ('paid', 'pending')
  AND amount BETWEEN 100 AND 500
ORDER BY order_id;

No parentheses are needed here because IN already groups its own list and every top-level condition is joined with AND. The query reads in the same order as the requirement (India, during July, paid or pending, amount 100 through 500), which makes it easy to verify.

10Summary

Keep this mental model

A WHERE clause determines which rows are included, and a row is included only when the complete condition evaluates to TRUE. Pick the condition that fits the requirement:

requirement → SQL condition
requirementSQL condition
match one exact value=
match one of several exact valuesIN
exclude several exact valuesNOT IN
require multiple conditionsAND
allow alternative conditionsOR
match a text patternLIKE
match an inclusive rangeBETWEEN
match a timestamp period>= start AND < next_start

And the gotchas worth remembering:

  • AND is evaluated before OR; parentheses make logical groups explicit.
  • IN matches exact values, not patterns.
  • NOT IN can behave unexpectedly when its list contains NULL; prefer NOT EXISTS.
  • % and _ are wildcards only with LIKE.
  • BETWEEN includes both boundaries; write the lower one first.
  • Timestamp ranges are safer with an inclusive start and an exclusive next boundary.
Key idea
A query can be valid SQL and still express the wrong requirement. Divide the requirement into logical groups, make those groups visible in the query, and test the boundary cases deliberately.

Go deeper

Ask Colearn about this pattern

Not sure why your own filter returns the wrong rows? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why does my NOT IN return no rows?

Almost always: the subquery inside NOT IN contains a NULL. NOT IN against a list that holds a NULL can never be TRUE — for every row it becomes UNKNOWN, and UNKNOWN rows are filtered out, so you get zero rows with no error.

Rewrite it with NOT EXISTS, which is NULL-safe: it checks each outer row against the subquery directly and stays correct even when the inner set has NULLs.

from the unit →
Why is my WHERE status != 'refunded' dropping rows?

Because the test is UNKNOWN — not TRUE — for every row where status IS NULL, and UNKNOWN rows are excluded. So rows with no status silently disappear from the result.

If you want those rows too, say so explicitly by also allowing the NULL case in the condition.

from the unit →
Why does BETWEEN miss the last day of the range?

On a timestamp column, BETWEEN a start date AND an end date stops at midnight of the end date — so everything that happened during the daytime of that last day is excluded.

Use a half-open range instead: greater-than-or-equal to the start and strictly-less-than the day after the end. It includes all of the last day and never double-counts a boundary.

from the unit →
Why does my WHERE with AND and OR match the wrong rows?

AND binds tighter than OR. So a condition like “a = 1 OR a = 2 AND b = 3” is read as “a = 1 OR (a = 2 AND b = 3)”, which is probably not what you meant.

Add parentheses to force the grouping you intend, e.g. “(a = 1 OR a = 2) AND b = 3”.

from the unit →
Why is my number or date filter comparing as text?

Comparing a string to a number or a date forces an implicit cast — the engine converts one side to match the other, and the result can order or filter wrongly. As text, “100” sorts before “9”; as numbers it does not.

Compare like with like: cast intentionally, or store and compare the column as its real type rather than as a string.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Continue learning

Related filtering patterns

The full pattern · Filtering

See all 6 traps in Filtering

This page covers one flagship trap; the pattern page maps them all, each with a lesson.

Open the pattern
P02Foundation · 5 minComparing with NULLWhy column = NULL and column <> NULL do not behave as expected.P11Foundation · 7 minDates & timestampsWhy an inclusive end date silently drops the last day of the range.P18Foundation · 7 minCasts & numbers as textWhy '85' can rank above '9' until you cast the column.

Ready to use the pattern?

A short assessment can identify which SQL patterns you already understand and which ones deserve practice.

Practise this patternCheck my SQL fundamentalsContinue to P02 →