Filtering, NULL and Functions
More precise filtering with LIKE, IN and BETWEEN, the traps of NULL, and functions for text, numbers and dates.
Pattern matching with LIKE
The LIKE operator compares text
with a pattern containing two wildcards: % stands for
any run of characters (including none) and _ for
exactly one character. It is the tool for "starts with", "contains"
and "ends with" questions:
-- Names that start with "USB"
SELECT name FROM products WHERE name LIKE 'USB%' ORDER BY name;
-- Names that contain "cable" anywhere
SELECT name FROM products WHERE name LIKE '%cable%' ORDER BY name;
-- Last names whose second letter is "e"
SELECT first_name, last_name FROM customers WHERE last_name LIKE '_e%'
ORDER BY customer_id;
name --------------- USB-C cable 2 m USB-C hub name ------------------ Ethernet cable 5 m HDMI cable 3 m USB-C cable 2 m first_name last_name ---------- --------- Sanne de Vries Kwame Mensah
NOT LIKE reverses the test. To search for a literal
% or _, declare an escape character:
LIKE '%50!%%' ESCAPE '!' finds text containing "50%":
the ! makes the % after it an ordinary
character.
Case sensitivity differs
Here the products disagree, and it matters when you move a query
from one to another. In SQLite, LIKE ignores the
difference between upper and lower case, but only for the ASCII
letters A–Z; for a case-sensitive match SQLite offers
GLOB, which uses Unix-style wildcards (*
and ?):
SELECT name FROM products WHERE name LIKE 'usb%' ORDER BY name;
SELECT name FROM products WHERE name GLOB 'usb*' ORDER BY name;
name --------------- USB-C cable 2 m USB-C hub name ----
| Product | LIKE is |
The other variant |
|---|---|---|
| SQLite | Case-insensitive (ASCII letters only) | GLOB is case-sensitive |
| PostgreSQL | Case-sensitive | ILIKE is case-insensitive |
| MySQL, SQL Server | Depends on the column's collation; the default collations are case-insensitive | Choose a case-sensitive collation |
A portable way to search without regard to case is to convert both
sides: WHERE LOWER(name) LIKE '%usb%'.
IN and BETWEEN
IN tests whether a value is in a list: a shorter and
safer way to write several OR conditions on one column.
NOT IN is the opposite.
SELECT order_id, status
FROM orders
WHERE status IN ('pending', 'shipped')
ORDER BY order_id;
SELECT name, category
FROM products
WHERE category NOT IN ('Cables', 'Peripherals')
ORDER BY name;
order_id status -------- ------- 104 shipped 107 shipped 108 pending 110 pending name category ------------------------ ----------- 27-inch monitor Displays Laptop stand Accessories Noise-cancelling headset Audio USB-C hub Accessories
BETWEEN low AND high tests a range and is
inclusive: it means
>= low AND <= high. Both boundary values are part
of the result, as the prices 25 and 89 below show. It works for
numbers, text and ISO dates:
SELECT name, price
FROM products
WHERE price BETWEEN 25 AND 89
ORDER BY price;
SELECT order_id, order_date
FROM orders
WHERE order_date BETWEEN '2026-07-01' AND '2026-07-31'
ORDER BY order_date;
name price ------------------- ----- Wireless mouse 25 Laptop stand 35 USB-C hub 45 Webcam 1080p 59 Mechanical keyboard 89 order_id order_date -------- ---------- 104 2026-07-01 105 2026-07-09 106 2026-07-21
NULL: a missing value
One customer, Priya, has no city in the database. Her
city is NULL: a marker
meaning the value is unknown, missing or not applicable. NULL is not
zero, not an empty string and not the text 'NULL'; it
is the absence of a value. A column only accepts NULL if it is not
declared NOT NULL, which is why city can
be empty and email cannot.
Because NULL is unknown, no comparison with it can be true. Is an
unknown city equal to an unknown city? Unknown. So
= NULL never matches, not even for the row that is
NULL. SQL has a special test, IS NULL (and
IS NOT NULL):
-- Wrong: matches nothing
SELECT first_name, city FROM customers WHERE city = NULL;
-- Right
SELECT first_name, city FROM customers WHERE city IS NULL;
first_name city ---------- ---- first_name city ---------- ---- Priya NULL
The less obvious consequence: a row with NULL also fails the opposite test. Asked for "customers not in Utrecht", the database leaves Priya out, because it cannot know that her unknown city is not Utrecht:
SELECT first_name, city
FROM customers
WHERE city <> 'Utrecht'
ORDER BY customer_id;
first_name city ---------- --------- Kwame Rotterdam Mei Amsterdam Omar Antwerp Lena Cologne Ana Lisbon
Two customers live in Utrecht, so of the eight customers you might
expect six; you get five. If customers with an unknown city should
be included, say so:
WHERE city <> 'Utrecht' OR city IS NULL.
Three-valued logic
Ordinary Boolean logic has two values, true and false. SQL conditions have three: TRUE, FALSE and UNKNOWN, the result of any comparison involving NULL. This is three-valued logic. The rules follow from asking "could the unknown value change the outcome?":
| Expression | Result | Why |
|---|---|---|
NULL = NULL, NULL <> 1
|
UNKNOWN | Any comparison with an unknown value is unknown |
TRUE AND UNKNOWN |
UNKNOWN | The outcome depends on the unknown part |
FALSE AND UNKNOWN |
FALSE | One false part is enough to make AND false |
TRUE OR UNKNOWN |
TRUE | One true part is enough to make OR true |
FALSE OR UNKNOWN |
UNKNOWN | The outcome depends on the unknown part |
NOT UNKNOWN |
UNKNOWN | The opposite of unknown is still unknown |
SQLite shows TRUE as 1, FALSE as 0 and UNKNOWN as NULL, so you can see the rules at work:
SELECT NULL = NULL AS null_eq_null,
NULL <> 1 AS null_ne_1,
(NULL = 1) OR (1 = 1) AS unknown_or_true,
(NULL = 1) AND (1 = 2) AS unknown_and_false,
NULL IS NULL AS is_null;
null_eq_null null_ne_1 unknown_or_true unknown_and_false is_null ------------ --------- --------------- ----------------- ------- NULL NULL 1 0 1
The rule that ties it together: WHERE keeps a row only
when its condition is TRUE. FALSE and UNKNOWN are
both rejected. That is why Priya disappeared from both "in Utrecht"
and "not in Utrecht".
The NOT IN trap
city NOT IN ('Utrecht', 'Rotterdam') means
city <> 'Utrecht' AND city <> 'Rotterdam'.
If the list contains a NULL, one of those parts becomes
city <> NULL, which is UNKNOWN for every row, and
an AND with UNKNOWN can never be TRUE. The query
returns nothing at all:
SELECT first_name, city
FROM customers
WHERE city NOT IN ('Utrecht', 'Rotterdam')
ORDER BY customer_id;
SELECT first_name, city
FROM customers
WHERE city NOT IN ('Utrecht', 'Rotterdam', NULL)
ORDER BY customer_id;
first_name city ---------- --------- Mei Amsterdam Omar Antwerp Lena Cologne Ana Lisbon first_name city ---------- ----
Nobody types a NULL into a list on purpose. In practice the list comes from a subquery (a query inside a query, see Subqueries, CTEs and Views), and one NULL in its result silently empties the answer. "Customers who live in a city where no Dutch customer lives" should return Omar, Lena and Ana, but Priya's NULL city is among the Dutch cities:
-- Returns nothing: the subquery's result contains NULL
SELECT first_name, city
FROM customers
WHERE city NOT IN (SELECT city FROM customers WHERE country = 'NL')
ORDER BY customer_id;
-- Fixed: keep NULL out of the list
SELECT first_name, city
FROM customers
WHERE city NOT IN (SELECT city FROM customers
WHERE country = 'NL' AND city IS NOT NULL)
ORDER BY customer_id;
first_name city ---------- ---- first_name city ---------- ------- Omar Antwerp Lena Cologne Ana Lisbon
COALESCE and CASE
COALESCE: a fallback for NULL
COALESCE takes a list of values and returns the first one that is not NULL. With a column and a fallback, it replaces missing values in the output:
SELECT first_name, COALESCE(city, 'unknown') AS city
FROM customers
ORDER BY customer_id;
first_name city ---------- --------- Sanne Utrecht Kwame Rotterdam Mei Amsterdam Omar Antwerp Lena Cologne Priya unknown Tom Utrecht Ana Lisbon
The stored data does not change; only the result shows the fallback.
COALESCE is standard SQL and works everywhere. Products
also have two-argument versions with their own names:
IFNULL (SQLite, MySQL), ISNULL (SQL
Server) and NVL (Oracle).
CASE: choosing a value by conditions
A CASE expression is SQL's
if-then-else. The database tests the WHEN conditions in
order and returns the value of the first one that is true; if none
is, it returns the ELSE value (or NULL without an
ELSE). Here it sorts products into price bands:
SELECT name, price,
CASE
WHEN price < 20 THEN 'budget'
WHEN price < 100 THEN 'mid-range'
ELSE 'premium'
END AS price_band
FROM products
ORDER BY price;
name price price_band ------------------------ ----- ---------- Ethernet cable 5 m 7 budget USB-C cable 2 m 9 budget HDMI cable 3 m 12 budget Wireless mouse 25 mid-range Laptop stand 35 mid-range USB-C hub 45 mid-range Webcam 1080p 59 mid-range Mechanical keyboard 89 mid-range Noise-cancelling headset 129 premium 27-inch monitor 249 premium
Because the first true condition wins, the second test does not need
price >= 20 AND: cheaper products were already
caught by the first. When every condition compares the same value
with =, the shorter simple form names the
value once:
SELECT order_id, status,
CASE status
WHEN 'pending' THEN 'Waiting for payment'
WHEN 'shipped' THEN 'On its way'
WHEN 'delivered' THEN 'Delivered'
ELSE 'Cancelled'
END AS status_text
FROM orders
WHERE customer_id IN (1, 3, 4, 6)
ORDER BY order_id;
order_id status status_text -------- --------- ------------------- 101 delivered Delivered 103 delivered Delivered 104 shipped On its way 105 cancelled Cancelled 108 pending Waiting for payment 109 delivered Delivered
CASE is an expression, so it can appear anywhere a
value can: in SELECT, WHERE,
ORDER BY, and inside the aggregate functions of
Aggregation and Grouping.
Text functions
Functions take values and return a new value, row by row. The most used text functions, on the shop's Dutch customers:
SELECT UPPER(last_name) AS upper_name,
LOWER(email) AS lower_email,
LENGTH(first_name) AS name_length,
SUBSTR(first_name, 1, 3) AS short_name
FROM customers
WHERE country = 'NL'
ORDER BY customer_id;
upper_name lower_email name_length short_name ---------- ------------------------- ----------- ---------- DE VRIES sanne.devries@example.com 5 San MENSAH kwame.mensah@example.com 5 Kwa CHEN mei.chen@example.com 3 Mei PATEL priya.patel@example.com 5 Pri JANSEN tom.jansen@example.com 3 Tom
SUBSTR(text, start, length) counts from 1, not from 0
as Python does. TRIM removes spaces at both ends (LTRIM
and RTRIM at one end), which matters for data typed in
by people; REPLACE swaps every occurrence of a piece of
text:
SELECT '[' || TRIM(' Utrecht ') || ']' AS trimmed,
'[' || LTRIM(' Utrecht ') || ']' AS left_trimmed,
REPLACE('HDMI cable 3 m', 'cable', 'lead') AS replaced;
trimmed left_trimmed replaced --------- ------------ ------------- [Utrecht] [Utrecht ] HDMI lead 3 m
Joining text together
The standard operator for concatenation (joining text) is
||. Like almost every operator, it returns NULL when
one side is NULL (Oracle is the exception: there
'a' || NULL gives 'a'), so Priya's
location disappears completely:
SELECT first_name || ' ' || last_name AS full_name,
city || ', ' || country AS location
FROM customers
ORDER BY customer_id;
full_name location -------------- ------------- Sanne de Vries Utrecht, NL Kwame Mensah Rotterdam, NL Mei Chen Amsterdam, NL Omar Haddad Antwerp, BE Lena Fischer Cologne, DE Priya Patel NULL Tom Jansen Utrecht, NL Ana Silva Lisbon, PT
Wrap the column that may be missing in
COALESCE(city, 'unknown') to keep the rest. Many
products also have a CONCAT() function, and here the
dialects differ more than anywhere:
| Product | Concatenation | Other differences |
|---|---|---|
| SQLite |
||; CONCAT() only from version
3.44 (these examples run on 3.40, where it does not exist)
|
LENGTH counts characters; SUBSTR
|
| PostgreSQL |
||; CONCAT() skips NULL arguments
|
LENGTH counts characters;
SUBSTRING or SUBSTR
|
| MySQL |
CONCAT(), which returns NULL if any argument is
NULL; || means OR unless the
PIPES_AS_CONCAT mode is on
|
LENGTH counts bytes (é is two);
CHAR_LENGTH counts characters
|
| SQL Server |
+ and CONCAT(), which treats NULL
as empty text; SQL Server 2025, Azure SQL Database and Azure
SQL Managed Instance also accept || (which,
unlike +, always returns NULL if one side is
NULL)
|
LEN (ignores trailing spaces);
SUBSTRING
|
Numbers and arithmetic
+, -, *, / and
% (remainder) work as you would expect, with one trap:
dividing two integers gives an integer in SQLite, PostgreSQL and SQL
Server, discarding the fraction. Make one side a decimal number to
get a fractional result (MySQL's / always returns a
decimal result):
SELECT 9 / 2 AS int_division,
9 / 2.0 AS real_division,
9 % 2 AS remainder,
ROUND(9.0 / 2) AS rounded;
int_division real_division remainder rounded ------------ ------------- --------- ------- 4 4.5 1 5.0
ROUND(value, decimals) rounds to the given number of
decimal places. Why it is needed shows up as soon as you calculate
with fractions. Adding 21% value-added tax (VAT) to the cable
prices:
SELECT name, price,
price * 1.21 AS with_vat,
ROUND(price * 1.21, 2) AS with_vat_rounded
FROM products
WHERE category = 'Cables'
ORDER BY price;
name price with_vat with_vat_rounded ------------------ ----- ----------------- ---------------- Ethernet cable 5 m 7 8.469999999999999 8.47 USB-C cable 2 m 9 10.89 10.89 HDMI cable 3 m 12 14.52 14.52
7 × 1.21 is exactly 8.47, but SQLite calculates with binary
floating-point numbers, which cannot store 1.21 exactly. Rounding fixes the display; for
money, PostgreSQL, MySQL, SQL Server and Oracle store amounts in a
DECIMAL(10,2) column, which calculates exactly in
decimal (SQLite has no exact decimal type; there, store whole cents
as INTEGER; see
Designing Tables).
ABS returns the absolute value, for example the
distance between the stock level and a target of 50:
SELECT name, stock, ABS(stock - 50) AS distance_from_50
FROM products
WHERE category = 'Cables'
ORDER BY product_id;
name stock distance_from_50 ------------------ ----- ---------------- USB-C cable 2 m 120 70 HDMI cable 3 m 80 30 Ethernet cable 5 m 200 150
Dates
SQLite has no date data type. Dates are stored as text in the ISO
8601 format YYYY-MM-DD (or as numbers), and a set of
functions understands that text. Because the format puts the year
first and always uses two digits for month and day, text order is
date order, so =, < and
BETWEEN just work. date() calculates with
dates and strftime() formats them or extracts parts:
SELECT order_id, order_date,
date(order_date, '+30 days') AS return_deadline,
strftime('%Y-%m', order_date) AS order_month
FROM orders
WHERE order_id IN (101, 104, 107)
ORDER BY order_id;
order_id order_date return_deadline order_month -------- ---------- --------------- ----------- 101 2026-06-02 2026-07-02 2026-06 104 2026-07-01 2026-07-31 2026-07 107 2026-08-03 2026-09-02 2026-08
julianday() turns a date into a day number, so the
difference between two dates is a subtraction. Two details to know:
adding a month to 31 January lands in March (SQLite first makes "31
February" and then rolls over the extra days), and a date that is
not in the exact format is not recognized at all:
SELECT julianday('2026-06-18') - julianday('2026-06-02') AS days_between,
date('2026-01-31', '+1 month') AS one_month_later,
date('2026-6-2') AS not_padded;
days_between one_month_later not_padded ------------ --------------- ---------- 16.0 2026-03-03 NULL
date('now') and CURRENT_DATE return
today's date (in UTC), which is useful in queries such as "orders
from the last 30 days" but not in examples whose output must stay
the same.
Dates in other systems
PostgreSQL, MySQL and SQL Server have real date types
(DATE, plus types for date and time such as
TIMESTAMP, DATETIME or
DATETIME2). The database then validates every value and
refuses 2026-02-30, and date arithmetic uses dedicated
syntax. Adding a month to 31 January gives 28 February in all three.
The same return deadline and order month, in each dialect:
-- PostgreSQL
SELECT order_date + 30 AS return_deadline,
to_char(order_date, 'YYYY-MM') AS order_month,
EXTRACT(YEAR FROM order_date) AS order_year
FROM orders;
-- MySQL
SELECT DATE_ADD(order_date, INTERVAL 30 DAY) AS return_deadline,
DATE_FORMAT(order_date, '%Y-%m') AS order_month,
YEAR(order_date) AS order_year
FROM orders;
-- SQL Server
SELECT DATEADD(day, 30, order_date) AS return_deadline,
FORMAT(order_date, 'yyyy-MM') AS order_month,
YEAR(order_date) AS order_year
FROM orders;
Whatever the product, store dates in a date type (or, in SQLite, as
ISO 8601 text), never in a local format such as
02-06-2026: that sorts wrongly and is ambiguous (2 June
or 6 February?).