Predicates — Learn Comparison, BETWEEN, LIKE, IS NULL, IN, and EXISTS from the Basics
BasicPredicatesBETWEEN / LIKEIS NULL / INEXISTS / NOT EXISTSCompound PredicatesPostgreSQL-ready5 questions
QUESTION 1
Comparison Predicates — Filter by Price and Stock with =, >=, <=, and AND
Comparison PredicatesAND / ORCompound ConditionsInventory Analysis
Background

Comparison predicates (=, <>, <, >, <=, >=) are SQL's most basic predicates. Multiple conditions are connected with AND (all true) or OR (at least one true).

SELECT * FROM products
WHERE  price >= 10000   -- lower bound (inclusive)
  AND  price <= 100000  -- upper bound (inclusive)
  AND  stock >= 1;       -- in stock
AND means "logical conjunction": A row remains only when every connected condition is TRUE. If even one condition is FALSE or UNKNOWN, the row is excluded. OR (logical disjunction) keeps a row when at least one condition is TRUE.
Problem

From the products table, retrieve products where price is at least 10,000 and at most 100,000 and stock is at least 1 (in stock). Return product_id, name, category, price, stock, ordered by price descending.

Tables used
▸ products
product_idnamecategorypricestock
1Smartphone XSmartphone8980050
2Smartbook ProPC1280000
3Tablet AirTablet6480030
4Wireless EarbudsAccessories12800100
5USB CableAccessories980200
6Gaming PC ProPC1980005
Expected Output

Expected output (price descending):

product_idnamecategorypricestock
1Smartphone XSmartphone8980050
3Tablet AirTablet6480030
4Wireless EarbudsAccessories12800100

Note: product 2 is excluded because stock=0; product 5 because price=980 (below the lower bound); and product 6 because price=198000 (above the upper bound).

Model Answer
SELECT
  product_id, name, category, price, stock
FROM   products
WHERE  price >= 10000    -- lower bound: at least 10,000 (inclusive)
  AND  price <= 100000   -- upper bound: at most 100,000 (inclusive)
  AND  stock >= 1         -- in stock (stock is greater than 0)
ORDER BY price DESC;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM products                 → read 6 rows
  2. WHERE price / stock ranges    → filter down to 3 rows
  3. SELECT product_id, name, ...  → select 5 columns
  4. ORDER BY price DESC           → sort by price descending
  */
Explanation (table transitions & key points)
SELECT product_id, name, category, price, stock FROM products WHERE price >= 10000 AND price <= 100000 AND stock >= 1 ORDER BY price DESC;
LEGEND
Rows read / loaded
① FROM products
FROM productsRead all 6 rows from the products table. These are the input data evaluated by the WHERE clause.
1 / 3
product_idnamepricestock
1Smartphone X8980050
2Smartbook Pro1280000
3Tablet Air6480030
4Wireless Earbuds12800100
5USB Cable980200
6Gaming PC Pro1980005
6 rows read
LEARNING POINTS
AND means "logical conjunction": A row remains only when every connected condition is TRUE. If any one condition is FALSE or UNKNOWN, it is excluded. OR (logical disjunction) keeps a row when at least one condition is TRUE.
How boundaries are included: >= includes the boundary value (at least 10000 means 10000 is included), while > excludes it (greater than 10000 means 10001 or more). Read specifications such as "at least," "greater than," "at most," and "less than" precisely.
Equivalent to BETWEEN: price >= 10000 AND price <= 100000 is equivalent to price BETWEEN 10000 AND 100000. BETWEEN includes both endpoints (covered in detail in Q2).
ANTI-PATTERNS
Mixing up AND and OR: WHERE category = 'PC' AND category = 'Tablet' means "PC and Tablet" and always returns 0 rows because one column cannot hold two values at once. Use OR or IN for multiple category values (see Q5).
Comparing with NULL: WHERE stock = NULL evaluates to UNKNOWN and always returns 0 rows. Always use IS NULL to check for NULL (covered in detail in Q4).
Practical column: Indexes and predicate selectivity
In practice, use EXPLAIN to check whether indexes are being used for each condition in the WHERE clause. Indexing a highly selective condition (for example, a price range) often improves performance. A low-selectivity condition such as stock >= 1, which is TRUE for most rows, has limited index benefit, and the optimizer may choose a full scan.
QUESTION 2
BETWEEN Predicates — Date Ranges and the TIMESTAMP Boundary Trap
BETWEENRange ConditionsDate FilteringBoundary Warning
Background

BETWEEN a AND b is equivalent to col >= a AND col <= b and specifies a range that includes both boundary values. It works with numbers, dates, and strings.

SELECT * FROM orders
WHERE  ordered_at BETWEEN '2024-01-01' AND '2024-03-31';
-- ↑ equivalent to ordered_at >= '2024-01-01' AND ordered_at <= '2024-03-31'
DATE vs. TIMESTAMP trap: When the column is TIMESTAMP, '2024-03-31' is interpreted as '2024-03-31 00:00:00'. Rows after 00:01 on March 31 are excluded, so ordered_at < '2024-04-01' is safer for TIMESTAMP columns.
Problem

From the orders table, retrieve orders where ordered_at falls from January 1 through March 31, 2024 (Q1). Return order_id, user_id, amount, status, ordered_at, ordered by ordered_at ascending.

Tables used
▸ orders
order_iduser_idamountstatusordered_at
10118000completed2024-01-15
10223500completed2024-02-20
103112000pending2024-04-05
10439500completed2023-12-01
10546000cancelled2024-03-10
10634500completed2024-01-28
Expected Output

Expected output (ordered_at ascending):

order_iduser_idamountstatusordered_at
10118000completed2024-01-15
10634500completed2024-01-28
10223500completed2024-02-20
10546000cancelled2024-03-10

Note: order 103 (2024-04-05: after the range) and order 104 (2023-12-01: before the range) are excluded.

Model Answer
SELECT
  order_id, user_id, amount, status, ordered_at
FROM   orders
WHERE  ordered_at BETWEEN '2024-01-01' AND '2024-03-31'
--                ↑ equivalent to >= '2024-01-01' AND <= '2024-03-31'
--                  DATE includes both endpoints. For TIMESTAMP, note that '2024-03-31 00:00:00' is the upper bound.
ORDER BY ordered_at;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM orders                            → read 6 rows
  2. WHERE ordered_at BETWEEN ...           → filter down to 4 rows
  3. SELECT order_id, user_id, amount, ...  → select 5 columns
  4. ORDER BY ordered_at                    → sort by date ascending
  */
Explanation (table transitions & key points)
SELECT order_id, user_id, amount, status, ordered_at FROM orders WHERE ordered_at BETWEEN '2024-01-01' AND '2024-03-31' ORDER BY ordered_at;
LEGEND
Rows read / loaded
① FROM orders
FROM ordersRead all 6 rows from the orders table. Notice the ordered_at values: 2023, Q1 2024, and Q2 2024 are mixed together.
1 / 4
order_iduser_idamountstatusordered_at
10118000completed2024-01-15
10223500completed2024-02-20
103112000pending2024-04-05
10439500completed2023-12-01
10546000cancelled2024-03-10
10634500completed2024-01-28
6 rows read
LEARNING POINTS
BETWEEN includes both endpoints: BETWEEN a AND b is exactly equivalent to col >= a AND col <= b. Always remember that the boundary values a and b also match.
A TIMESTAMP upper bound becomes 00:00:00: When the column is TIMESTAMP, '2024-03-31' is interpreted as '2024-03-31 00:00:00'. Orders after 00:01 on March 31 can be silently missed. Use the ordered_at < '2024-04-01' pattern for TIMESTAMP columns.
NOT BETWEEN is also available: NOT BETWEEN a AND b is equivalent to col < a OR col > b, so it expresses the reverse condition, "retrieve rows outside the range."
ANTI-PATTERNS
Reverse the lower and upper bounds: BETWEEN 100000 AND 10000 is mathematically impossible and always returns 0 rows. Always write smaller value AND larger value.
Use the month-end date as a TIMESTAMP upper bound: BETWEEN '2024-01-01' AND '2024-03-31' frequently misses March 31 rows when the column is TIMESTAMP. Changing the upper bound to < '2024-04-01' solves the problem.
Practical column: Safe date ranges in monthly and quarterly reports
For TIMESTAMP columns, the production standard is >= 'month start' AND < 'next month start' (for example, February uses >= '2024-02-01' AND < '2024-03-01'). This automatically handles leap years and avoids truncating times, making it the safest pattern. It is best to limit BETWEEN to DATE columns.
QUESTION 3
LIKE Predicates — Pattern Matching with Wildcards (Prefix, Suffix, and Infix)
LIKEPattern MatchingString SearchProduct-Name Filter
Background

The LIKE predicate is used for string pattern matching. % (percent) matches any string of zero or more characters, while _ (underscore) matches exactly one character.

WHERE name LIKE 'Smart%'  -- prefix match: starts with "Smart"
   OR name LIKE '%Pro'      -- suffix match: ends with "Pro"
   OR name LIKE '%Air%'     -- infix match: contains "Air" (cannot use an index)
   OR name LIKE '__blet';  -- _ is one arbitrary character (two characters + blet)
Performance note: A prefix match 'prefix%' can use a B-tree index. A suffix match '%suffix' and an infix match '%word%' cannot use an index and require a full scan. Be especially careful with large tables.
Problem

From the products table, retrieve products whose name starts with "Smart" or whose name ends with "Pro". Return product_id, name, category, price, ordered by price ascending.

Tables used
▸ products
product_idnamecategoryprice
1Smartphone XSmartphone89800
2Smartbook ProPC128000
3Tablet AirTablet64800
4Wireless EarbudsAccessories12800
5USB CableAccessories980
6Gaming PC ProPC198000
Expected Output

Expected output (price ascending):

product_idnamecategoryprice
1Smartphone XSmartphone89800
2Smartbook ProPC128000
6Gaming PC ProPC198000

Note: product 2 matches both "Smart%" and "%Pro". Because this is an OR condition, it is output once without duplication.

Model Answer
SELECT
  product_id, name, category, price
FROM   products
WHERE  name LIKE 'Smart%'  -- prefix match: starts with "Smart"
    OR name LIKE '%Pro'       -- suffix match: ends with "Pro"
ORDER BY price;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM products                 → read 6 rows
  2. WHERE name LIKE ... OR ...    → filter down to 3 rows
  3. SELECT product_id, name, ...  → select 4 columns
  4. ORDER BY price                → sort by price ascending
  */
Explanation (table transitions & key points)
SELECT product_id, name, category, price FROM products WHERE name LIKE 'Smart%' OR name LIKE '%Pro' ORDER BY price;
LEGEND
Rows read / loaded
① FROM products
FROM productsRead all 6 rows from the products table. Focus on the patterns in the name column.
1 / 3
product_idnamecategoryprice
1Smartphone XSmartphone89800
2Smartbook ProPC128000
3Tablet AirTablet64800
4Wireless EarbudsAccessories12800
5USB CableAccessories980
6Gaming PC ProPC198000
6 rows read
LEARNING POINTS
What the wildcards mean: There are two kinds: % (any string of zero or more characters) and _ (any one character). For example, 'A_C' matches "ABC" and "AXC", but not "AC" or "ABBC".
How indexes work: A prefix match 'prefix%' can use a B-tree index. A suffix match '%suffix' and an infix match '%word%' cannot use an index and require a full scan. For full-text search, consider pg_trgm (PostgreSQL) or FULLTEXT INDEX (MySQL).
Write exclusion conditions with NOT LIKE: WHERE name NOT LIKE '%Pro' extracts products that do not end with "Pro". To exclude multiple patterns, connect NOT LIKE '...' conditions with AND.
ANTI-PATTERNS
Use LIKE without wildcards as if it were =: LIKE 'Smartphone X' is equivalent to = 'Smartphone X', but index behavior differs and the optimizer may have a harder time optimizing it. Use = for exact matches.
Use infix matching on a large table: LIKE '%keyword%' requires a full scan on a table with millions of rows. Check with EXPLAIN before running it on a production table and consider a full-text search index if needed.
Practical column: Fuzzy search and SQL injection
When user input is used in a LIKE condition, literal % or _ characters can unintentionally broaden the pattern. Escape % to % and _ to _ in the application, then use the ESCAPE clause as in LIKE :q ESCAPE '\'. Concatenating strings directly instead of using placeholders creates a risk of SQL injection.
QUESTION 4
IS NULL Predicates — Three-Valued Logic and the Correct Way to Check NULL
IS NULLThree-Valued LogicNULL HandlingNULL Cannot Use =
Background

SQL uses TRUE / FALSE / UNKNOWN three-valued logic rather than two-valued TRUE / FALSE logic. NULL means "an unknown value": both NULL = NULL and NULL <> NULL evaluate to UNKNOWN. Always use IS NULL or IS NOT NULL to check for NULL.

-- ✗ Incorrect: = cannot be used with NULL (always UNKNOWN → 0 rows)
WHERE coupon_code = NULL

-- ✓ Correct: check with IS NULL
WHERE coupon_code IS NULL

-- ✓ Retrieve non-NULL rows
WHERE coupon_code IS NOT NULL
WHERE evaluation and three-valued logic: Only rows whose WHERE result is TRUE remain. FALSE and UNKNOWN both exclude a row. NOT UNKNOWN is still UNKNOWN.
Problem

From the orders table, retrieve orders where coupon_code is NULL (no coupon used). Return order_id, user_id, amount, coupon_code, ordered_at, ordered by order_id ascending.

Tables used
▸ orders
order_iduser_idamountcoupon_codeordered_at
10118000SUMMER102024-06-01
102212000NULL2024-06-03
10335000NULL2024-06-05
10419800WINTER202024-06-07
10543200NULL2024-06-10
Expected Output

Expected output (order_id ascending):

order_iduser_idamountcoupon_codeordered_at
102212000NULL2024-06-03
10335000NULL2024-06-05
10543200NULL2024-06-10

Note: orders 101 (SUMMER10) and 104 (WINTER20) are excluded because their coupons were used.

Model Answer
SELECT
  order_id, user_id, amount, coupon_code, ordered_at
FROM   orders
WHERE  coupon_code IS NULL   -- Always use IS NULL for a NULL check (= NULL is always UNKNOWN)
ORDER BY order_id;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM orders                    → read 5 rows
  2. WHERE coupon_code IS NULL      → filter down to 3 rows
  3. SELECT order_id, user_id, ...  → select 5 columns
  4. ORDER BY order_id              → sort by order_id ascending

  ▸ Three-valued logic reminder:
      'SUMMER10' IS NULL  → FALSE   → excluded
      NULL       IS NULL  → TRUE    → passes
      NULL = NULL          → UNKNOWN → excluded (a common mistake)
*/
Explanation (table transitions & key points)
SELECT order_id, user_id, amount, coupon_code, ordered_at FROM orders WHERE coupon_code IS NULL ORDER BY order_id;
LEGEND
Rows read / loaded
① FROM orders
FROM ordersRead all 5 rows from the orders table. The coupon_code column contains both NULL and strings.
1 / 3
order_iduser_idamountcoupon_codeordered_at
10118000SUMMER102024-06-01
102212000NULL2024-06-03
10335000NULL2024-06-05
10419800WINTER202024-06-07
10543200NULL2024-06-10
5 rows read
LEARNING POINTS
SQL's three-valued logic (3VL): SQL uses TRUE / FALSE / UNKNOWN. NULL means "unknown," and a comparison with NULL always becomes UNKNOWN. Because WHERE passes only TRUE rows, UNKNOWN is excluded just like FALSE.
IS NULL and IS NOT NULL: IS NULL is the only predicate that correctly detects NULL. IS NOT NULL retrieves rows where a value exists. A common pattern is to combine COALESCE with it, such as COALESCE(coupon_code, 'none'), to convert NULL to a default value.
The dangerous combination of NOT IN and NULL: If NULL appears in an IN or NOT IN list, evaluation can become UNKNOWN and produce unintended results (covered in Q6). When using NOT IN with a column that may contain NULL, always add WHERE col IS NOT NULL inside the subquery.
ANTI-PATTERNS
WHERE col = NULL: This is the most common mistake. col = NULL always becomes UNKNOWN, so 0 rows pass. Always use IS NULL.
WHERE col != NULL / <> NULL: These also always become UNKNOWN. Retrieve "non-NULL rows" with IS NOT NULL.
Practical column: Define the meaning of NULL during schema design
NULL may mix meanings such as "not entered," "unknown," and "not applicable" in the same application. Define what NULL means during design and document it in the column comment. For example, if NULL in coupon_code clearly means "no coupon used," both the application and SQL remain unambiguous. Ambiguous NULL semantics become a source of bugs later.
QUESTION 5
IN Predicates (Fixed Lists) — Filter Multiple Values Cleanly Without Repeating OR
INFixed ListsCategory FilteringAlternative to OR
Background

WHERE col IN (v1, v2, …) is shorthand for col = v1 OR col = v2 OR …. It expresses multiple matches cleanly by listing the accepted values.

WHERE category IN ('PC', 'Tablet')
-- ↑ equivalent to category = 'PC' OR category = 'Tablet'
Benefits of IN: It is easier to read than a list of OR conditions, and the condition can be changed by simply adding or removing values. You can also use NOT IN (v1, v2, …) to retrieve rows outside the list.
Problem

From the products table, retrieve products whose category is 'Smartphone', 'Tablet', or 'PC'. Return product_id, name, category, price, stock, ordered by category ascending and then price ascending.

Tables used
▸ products
product_idnamecategorypricestock
1Smartphone XSmartphone8980050
2Smartbook ProPC1280000
3Tablet AirTablet6480030
4Wireless EarbudsAccessories12800100
5USB CableAccessories980200
6Gaming PC ProPC1980005
Expected Output

Expected output (category ascending → price ascending):

product_idnamecategorypricestock
2Smartbook ProPC1280000
6Gaming PC ProPC1980005
1Smartphone XSmartphone8980050
3Tablet AirTablet6480030

Note: products 4 and 5 are excluded because their category is 'Accessories', which is outside the IN list.

Model Answer
SELECT
  product_id, name, category, price, stock
FROM   products
WHERE  category IN ('Smartphone', 'Tablet', 'PC')
ORDER BY category, price;                      -- ↑ equivalent to category = 'Smartphone' OR category = 'Tablet' OR category = 'PC'

/*
  Execution order (SQL's logical evaluation order):
  1. FROM products                 → read 6 rows
  2. WHERE category IN (...)       → filter down to 4 rows
  3. SELECT product_id, name, ...  → select 5 columns
  4. ORDER BY category, price      → category ascending → price ascending
  */
Explanation (table transitions & key points)
SELECT product_id, name, category, price, stock FROM products WHERE category IN ('Smartphone', 'Tablet', 'PC') ORDER BY category, price;
LEGEND
Rows read / loaded
① FROM products
FROM productsRead all 6 rows from the products table. Focus on the different values in the category column.
1 / 4
product_idnamecategoryprice
1Smartphone XSmartphone89800
2Smartbook ProPC128000
3Tablet AirTablet64800
4Wireless EarbudsAccessories12800
5USB CableAccessories980
6Gaming PC ProPC198000
6 rows read
LEARNING POINTS
IN is shorthand for OR: IN (v1, v2, v3) is equivalent to = v1 OR = v2 OR = v3. Even when the list grows, you only need to add values to the IN list, which is more readable than repeating OR.
Write the reverse condition with NOT IN: WHERE category NOT IN ('Accessories') retrieves products other than Accessories. However, if the NOT IN list contains NULL, a trap causes 0 rows to be returned, so take care (covered in Q6).
A subquery can also be used inside IN: Instead of a fixed list, use a subquery such as IN (SELECT category FROM ... to generate a list dynamically based on the state of a table (covered in Q6).
ANTI-PATTERNS
Let the IN list grow to tens of thousands of values: Embedding a huge list such as IN (id1, id2, ... id50000) generated by the application greatly increases parsing and evaluation costs. Change the design to use a temporary table or JOIN.
Put multiple columns inside IN: IN (col1, col2) is a value list, not a list of columns. For matching combinations of multiple columns, use the row-value constructor syntax (col1, col2) IN ((v1, v2), (v3, v4)) (supported by PostgreSQL).
Practical column: Connecting filter UIs to SQL
For a category filter where users can select multiple values, the application receives an array and safely builds a parameterized condition such as WHERE category = ANY($1) (PostgreSQL) or WHERE category IN (?) (multiple bound parameters). This safely handles a variable number of values.