SQL (PostgreSQL, MySQL, SQLite) Module 2 – Filtering and sorting rows
LIKE and pattern matching
Match text in SQL with LIKE and its % and _ wildcards, search for a literal % or _ with ESCAPE, and choose between LIKE, ILIKE, GLOB and regular expressions.
What you will learn
- Match text with the LIKE wildcards % and _
- Write patterns that match a literal % or _ by declaring an ESCAPE character
- Choose between LIKE, ILIKE, GLOB and regular expressions for each engine
Before you start
On this page
= answers “is this text exactly that text?”. Real questions are often looser: names that start with “Sri”, product
codes that end in “500G”, email addresses on a particular domain. LIKE compares a value with a pattern, a
string in which two characters stand for “something here”.
The two wildcards
In a LIKE pattern:
%stands for any run of characters, including none at all;_stands for exactly one character;- every other character stands for itself.
The pattern has to describe the whole value, not just a part of it. 'Sri%' therefore means “starts with Sri”,
'%Rao' means “ends with Rao”, '%sri%' means “contains sri somewhere”, and five underscores mean “exactly five
characters long”.
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO customers VALUES
(1, 'Srinivas Rao'), (2, 'Sridevi Nair'), (3, 'Asrith Varma'),
(4, 'Sunil Sri'), (5, 'Srini'), (6, 'Priya Iyer');
-- Each column is one pattern: 1 means the name matches it, 0 that it does not.
SELECT name,
name LIKE 'Sri%' AS starts_sri,
name LIKE '%sri%' AS has_sri,
name LIKE '%Rao' AS ends_rao,
name LIKE '_____' AS five_chars
FROM customers;
-- The same patterns filter rows in WHERE.
SELECT id, name
FROM customers
WHERE name LIKE 'Sri%'; Output
┌──────────────┬────────────┬─────────┬──────────┬────────────┐ │ name │ starts_sri │ has_sri │ ends_rao │ five_chars │ ├──────────────┼────────────┼─────────┼──────────┼────────────┤ │ Srinivas Rao │ 1 │ 1 │ 1 │ 0 │ │ Sridevi Nair │ 1 │ 1 │ 0 │ 0 │ │ Asrith Varma │ 0 │ 1 │ 0 │ 0 │ │ Sunil Sri │ 0 │ 1 │ 0 │ 0 │ │ Srini │ 1 │ 1 │ 0 │ 1 │ │ Priya Iyer │ 0 │ 0 │ 0 │ 0 │ └──────────────┴────────────┴─────────┴──────────┴────────────┘ ┌────┬──────────────┐ │ id │ name │ ├────┼──────────────┤ │ 1 │ Srinivas Rao │ │ 2 │ Sridevi Nair │ │ 5 │ Srini │ └────┴──────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < wildcards.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The first table puts each pattern in a column of its own, so you can see which names it matches (1) and which it does
not (0). Two results deserve a second look. “Asrith Varma” contains sri in the middle of a word, and '%sri%' does
not care about word boundaries. And '%sri%' matched names that start with a capital S: in SQLite, LIKE ignores the
difference between capital and small letters, which is the subject of the next section. The second query uses a
pattern the usual way, as a condition in WHERE.
A pattern can also be tried on a single value, without any table, which is a quick way to test one before you use it:
-- Each _ stands for exactly one character; % for any number of them, even none.
SELECT 'Sri' LIKE 'S_i' AS one,
'Sri' LIKE 'S__i' AS two,
'Sri' LIKE 'S%i' AS pct; Output
┌─────┬─────┬─────┐ │ one │ two │ pct │ ├─────┼─────┼─────┤ │ 1 │ 0 │ 1 │ └─────┴─────┴─────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < underscores.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
'S__i' asks for four characters, an S, any two and an i, so it does not match the three letters of Sri. % may
stand for nothing at all, so 'S%i' matches.
Letter case: the engines disagree again
How LIKE treats capital and small letters is one of the differences you meet first when moving a query between
engines:
- SQLite ignores letter case in
LIKE, but only for the 26 letters of the English alphabet. Accented and other non-English letters must match exactly. - PostgreSQL makes
LIKEcase-sensitive with its default collations. Its own operatorILIKEignores case, using the case rules of the active locale.ILIKEis a PostgreSQL extension, not part of the SQL standard. - MySQL compares with the collation of the column, and its default collation is case-insensitive, so
LIKEignores case there unless the column or the pattern uses a case-sensitive or binary collation.
-- SQLite's LIKE ignores the case of the 26 ASCII letters only.
SELECT 'ECLAIR' LIKE 'eclair' AS ascii_letters,
'ÉCLAIR' LIKE 'éclair' AS accented_letters,
lower('ÉCLAIR') AS lower_result;
-- GLOB never ignores case.
SELECT 'Pune' GLOB 'P*' AS capital_p,
'Pune' GLOB 'p*' AS small_p; Output
┌───────────────┬──────────────────┬──────────────┐ │ ascii_letters │ accented_letters │ lower_result │ ├───────────────┼──────────────────┼──────────────┤ │ 1 │ 0 │ Éclair │ └───────────────┴──────────────────┴──────────────┘ ┌───────────┬─────────┐ │ capital_p │ small_p │ ├───────────┼─────────┤ │ 1 │ 0 │ └───────────┴─────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < letter_case.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
'ÉCLAIR' LIKE 'éclair' is false in SQLite because É is outside the ASCII range, and lower() has the same limit:
it turned ÉCLAIR into Éclair, lowering only the English letters. GLOB, SQLite’s other pattern operator, never
ignores case.
For a pattern that should ignore case in every engine, lower both sides yourself: lower(email) LIKE '%.in'. That
works in PostgreSQL, MySQL and SQLite alike, within SQLite’s ASCII limit.
Searching for a real % or _
Product codes, file names and discount labels often contain _ or % themselves. Because those are wildcards, a
pattern meant to find them can match far too much. This is the most common LIKE bug:
CREATE TABLE products (code TEXT PRIMARY KEY, label TEXT NOT NULL);
INSERT INTO products VALUES
('TEA_500G', 'Assam tea, 50% off'),
('TEAX500G', 'Tea sampler'),
('TEA-1KG', 'Nilgiri tea, 5% cashback'),
('COFFEE_1K', 'Filter coffee');
-- _ means "any one character", so TEA_% also matches TEAX and TEA-.
-- With ESCAPE '!', the pattern TEA!_% looks for a real underscore.
SELECT code,
code LIKE 'TEA_%' AS any_character,
code LIKE 'TEA!_%' ESCAPE '!' AS real_underscore
FROM products;
-- Labels that contain a real percent sign.
SELECT label
FROM products
WHERE label LIKE '%!%%' ESCAPE '!'; Output
┌───────────┬───────────────┬─────────────────┐ │ code │ any_character │ real_underscore │ ├───────────┼───────────────┼─────────────────┤ │ COFFEE_1K │ 0 │ 0 │ │ TEA-1KG │ 1 │ 0 │ │ TEAX500G │ 1 │ 0 │ │ TEA_500G │ 1 │ 1 │ └───────────┴───────────────┴─────────────────┘ ┌──────────────────────────┐ │ label │ ├──────────────────────────┤ │ Assam tea, 50% off │ │ Nilgiri tea, 5% cashback │ └──────────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < escape.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
'TEA_%' was meant to find codes that start with TEA_, but its _ matched the X of TEAX500G and the - of
TEA-1KG as well. The fix is an escape character: the clause ESCAPE '!' says that in this pattern !_ means a
real underscore and !% a real percent sign (and !! a real exclamation mark). The second query uses '%!%%':
anything, then a real %, then anything.
The engines do not agree on what happens when you leave out ESCAPE:
- PostgreSQL and MySQL treat a backslash as the escape character by default, so
'TEA\_%'works there. - SQLite has no default escape character, which is also what the SQL standard prescribes. Without an
ESCAPEclause a backslash is an ordinary character, and'TEA\_%'would look for a real backslash. - MySQL also treats a backslash inside any string literal as the start of an escape sequence, so the one-character
string
'\'has to be written'\\'there.
Writing the clause yourself with a character such as !, which needs no special treatment anywhere, gives the same
result in all three engines.
GLOB in SQLite
SQLite has a second operator, GLOB, with the wildcards of file names on Unix systems: * for any run of
characters, ? for exactly one, and square brackets for one character from a set, such as [0-9] for a digit or
[A-Z] for a capital letter. GLOB always respects letter case.
CREATE TABLE skus (code TEXT PRIMARY KEY);
INSERT INTO skus VALUES ('SKU-042'), ('SKU-42'), ('sku-042'), ('SKU-0A2'), ('SKU-1234');
-- GLOB: * any run of characters, ? exactly one, [0-9] one digit. Case counts.
SELECT code,
code GLOB 'SKU-[0-9][0-9][0-9]' AS three_digits,
code GLOB 'SKU-*' AS any_suffix,
code GLOB '???-???' AS seven_chars
FROM skus; Output
┌──────────┬──────────────┬────────────┬─────────────┐ │ code │ three_digits │ any_suffix │ seven_chars │ ├──────────┼──────────────┼────────────┼─────────────┤ │ SKU-042 │ 1 │ 1 │ 1 │ │ SKU-42 │ 0 │ 1 │ 0 │ │ sku-042 │ 0 │ 0 │ 1 │ │ SKU-0A2 │ 0 │ 1 │ 1 │ │ SKU-1234 │ 0 │ 1 │ 0 │ └──────────┴──────────────┴────────────┴─────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < glob.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Only SKU-042 has exactly three digits after the dash. sku-042 fails every pattern that spells out SKU because
GLOB respects case, while ???-??? is about length and position only. LIKE cannot say “a digit” at all, which is
why GLOB is handy for checking the shape of codes. GLOB is SQLite’s own operator; in PostgreSQL and MySQL the
same checks are written as regular expressions.
Missing values and invisible spaces
Two edge cases surprise people who clean data with LIKE:
CREATE TABLE customers (id INTEGER PRIMARY KEY, email TEXT);
INSERT INTO customers VALUES
(1, 'asha@mail.in'),
(2, 'farhan@mail.com'),
(3, NULL), -- no email given
(4, 'joseph@mail.in ') -- typed with a space at the end
;
SELECT count(*) AS all_rows FROM customers;
SELECT count(*) AS ends_in FROM customers WHERE email LIKE '%.in';
SELECT count(*) AS not_ends_in FROM customers WHERE email NOT LIKE '%.in';
SELECT count(*) AS ends_in_trimmed FROM customers WHERE trim(email) LIKE '%.in'; Output
┌──────────┐ │ all_rows │ ├──────────┤ │ 4 │ └──────────┘ ┌─────────┐ │ ends_in │ ├─────────┤ │ 1 │ └─────────┘ ┌─────────────┐ │ not_ends_in │ ├─────────────┤ │ 2 │ └─────────────┘ ┌─────────────────┐ │ ends_in_trimmed │ ├─────────────────┤ │ 2 │ └─────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < nulls_and_spaces.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
There are four customers, but the LIKE and NOT LIKE counts add up to three. The customer without an email (NULL)
is in neither: when the value is missing, the database cannot say whether it matches, so both conditions are unknown
and WHERE drops the row. The next lesson is about exactly this. The other surprise is Joseph, whose address was saved
with a space at the end: the pattern has to match the whole value, space included. trim() removes spaces at both ends
before the comparison.
When another tool is the right one
Patterns that LIKE cannot express, such as “two to four digits, then a capital letter”, need regular expressions.
They are written differently in each engine, and SQLite has none of its own:
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO customers VALUES (1, 'Srinivas Rao'), (2, 'Priya Iyer');
-- PostgreSQL's case-insensitive ILIKE is not SQLite (or MySQL) syntax.
SELECT name FROM customers WHERE name ILIKE 'sri%';
-- SQLite knows the REGEXP operator, but only a program that adds a regexp()
-- function can run it (the sqlite3 shell does; the browser runner does not).
SELECT name FROM customers WHERE name REGEXP '^Sri';
-- Plain LIKE runs everywhere.
SELECT name FROM customers WHERE name LIKE 'sri%'; Output (exit status 1)
┌──────────────┐ │ name │ ├──────────────┤ │ Srinivas Rao │ └──────────────┘
Printed as an error (standard error)
Parse error near line 5: near "ILIKE": syntax error Parse error near line 9: no such function: REGEXP
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < other_dialects.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
SQLite rejects ILIKE with a syntax error. It accepts the REGEXP operator but has no regexp() function to run it
unless the program using SQLite adds one. The sqlite3 command-line program adds one, so the REGEXP query works
there; the browser runner here does not. The script carries on after each error, so the plain LIKE at the end
still prints its row. Regular expressions get a lesson of their own in the text-search module.
Pattern matching at a glance
- SQLite 3.49:
LIKEignores the case of the 26 English letters only; there is no escape character unless you declare one withESCAPE;GLOBadds character classes such as[0-9]; regular expressions need aregexp()function that the application provides (thesqlite3shell has one). - PostgreSQL 18:
LIKErespects letter case andILIKEignores it; the backslash is the default escape character;SIMILAR TOand the regular-expression operators~and~*cover character classes. - MySQL 8.4:
LIKEfollows the collation, which ignores case by default; the backslash is the default escape character unless the SQL modeNO_BACKSLASH_ESCAPESis on; regular expressions useREGEXP, also spelledRLIKE.
Patterns and indexes
An index keeps a column’s values sorted, like the names in a phone book. A pattern with a fixed start, such as
'Sri%', tells the database where in that order to start reading. A pattern that starts with a wildcard, such as
'%vas', could match anywhere, so the database has to read every row. SQLite can show its plan:
-- An index sorted without regard to case, which SQLite's LIKE can use.
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE INDEX customers_by_name ON customers (name COLLATE NOCASE);
-- The last column, detail, says how SQLite will find the rows.
EXPLAIN QUERY PLAN SELECT id FROM customers WHERE name LIKE 'Sri%';
EXPLAIN QUERY PLAN SELECT id FROM customers WHERE name LIKE '%vas'; Output
┌────┬────────┬─────────┬─────────────────────────────────────────────────────────────────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼─────────────────────────────────────────────────────────────────────────────┤ │ 2 │ 0 │ 156 │ SEARCH customers USING COVERING INDEX customers_by_name (name>? AND name<?) │ └────┴────────┴─────────┴─────────────────────────────────────────────────────────────────────────────┘ ┌────┬────────┬─────────┬────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼────────────────┤ │ 2 │ 0 │ 216 │ SCAN customers │ └────┴────────┴─────────┴────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < index_plan.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
SEARCH … USING COVERING INDEX means SQLite would read only the part of the index between two values;
SCAN customers means it would read the whole table. SQLite uses an index for LIKE only in particular cases: here
the index is sorted without regard to case (COLLATE NOCASE), to match the case-insensitive LIKE, and the pattern
does not start with a wildcard. PostgreSQL has a similar rule for its B-tree indexes: one can serve name LIKE 'Sri%'
but not name LIKE '%vas', and in a database that does not use the C locale the index needs a special operator class
for it. The performance module shows how to read these plans properly.
Key takeaways
%matches any run of characters and_exactly one; aLIKEpattern has to match the whole value.- Letter case: SQLite’s
LIKEignores it for English letters only, PostgreSQL’s respects it (useILIKE), MySQL’s follows the collation. - To find a real
%or_, write an escape character withESCAPE '!'; it behaves the same in all three engines. NULLmatches neitherLIKEnorNOT LIKE, and a stray space at the end is part of the value.- SQLite’s
GLOBadds character classes such as[0-9]; regular expressions differ by engine.
Exercise
Exercise · Easy · SQL
Find the .in email addresses for a newsletter
A shop wants to send its newsletter to customers with an email address on an Indian domain. The table customers has these columns:
id: the customer's number;name: the customer's name;email: the email address, or NULL when the customer gave none;marketing_ok:1when the customer agreed to receive emails,0when they did not.
Write a query that returns the id and email of every customer whose address ends with .in, whatever mix of capital and small letters it is typed in (.in, .IN or .In), and who agreed to receive emails.
An address such as tina@mail.india.com contains .in but does not end with it, so it does not count. The order of the rows does not matter. Your query should also work in PostgreSQL, whose LIKE respects letter case: the last sample test switches SQLite's LIKE to respect letter case too.
Starter code · in_emails.sql
-- Customers who agreed to emails (marketing_ok = 1) and whose address
-- ends with .in, in any letter case.
SELECT id, email
FROM customers; The sample tests · tests.yaml
# Sample tests: each one builds a small customers table, runs your query on it and compares the rows it returns
# (in any order).
tests:
- name: finds .in at the end of the address in any letter case
setup: |
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, email TEXT, marketing_ok INTEGER);
INSERT INTO customers VALUES
(1, 'Asha', 'asha@mail.in', 1),
(2, 'Ravi', 'RAVI@MAIL.IN', 1),
(3, 'Meera', 'meera@college.ac.in', 1),
(4, 'Tina', 'tina@mail.india.com', 1),
(5, 'Sam', 'sam@mailin', 1),
(6, 'Karan', 'karan@mail.co.In', 1);
expected:
columns: [id, email]
rows:
- [1, asha@mail.in]
- [2, RAVI@MAIL.IN]
- [3, meera@college.ac.in]
- [6, karan@mail.co.In]
- name: leaves out customers who did not agree and customers without an email
setup: |
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, email TEXT, marketing_ok INTEGER);
INSERT INTO customers VALUES
(1, 'Divya', 'divya@mail.in', 0),
(2, 'Imran', NULL, 1),
(3, 'Nisha', 'nisha@mail.in', 1),
(4, 'Omar', 'OMAR@MAIL.IN', 0);
expected:
columns: [id, email]
rows:
- [3, nisha@mail.in]
- name: returns no rows when no address ends with .in
setup: |
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, email TEXT, marketing_ok INTEGER);
INSERT INTO customers VALUES
(1, 'Leo', 'leo@mail.com', 1),
(2, 'Mira', 'mira@india.com', 1);
expected:
columns: [id, email]
rows: []
- name: still works when LIKE respects letter case, as it does in PostgreSQL
setup: |
-- This setting makes SQLite's LIKE respect letter case for the rest of the test, like PostgreSQL's LIKE.
PRAGMA case_sensitive_like = ON;
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, email TEXT, marketing_ok INTEGER);
INSERT INTO customers VALUES
(1, 'Uma', 'uma@mail.in', 1),
(2, 'Vikram', 'VIKRAM@MAIL.IN', 1),
(3, 'Wasim', 'wasim@mail.In', 1),
(4, 'Xavier', 'xavier@mail.IN', 0);
expected:
columns: [id, email]
rows:
- [1, uma@mail.in]
- [2, VIKRAM@MAIL.IN]
- [3, wasim@mail.In] A hint
The pattern '%.in' means "anything, then .in at the very end"; the dot is an ordinary character in LIKE. Lowering the column first, lower(email) LIKE '%.in', ignores letter case in every engine. Join the second condition, marketing_ok = 1, with AND.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- PostgreSQL 18: Pattern Matching (LIKE, SIMILAR TO, regular expressions) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Index Types (B-tree and pattern matching) (The PostgreSQL Global Development Group)
- SQL Language Expressions (LIKE, GLOB, REGEXP) (SQLite)
- Built-In Scalar SQL Functions (lower, trim, like, glob) (SQLite)
- The SQLite Query Optimizer Overview (the LIKE optimization) (SQLite)
- Command Line Shell For SQLite (REGEXP in the sqlite3 shell) (SQLite)
- MySQL 8.4: String Comparison Functions and Operators (Oracle Corporation)
- MySQL 8.4: String Literals (Oracle Corporation)
- MySQL 8.4: Regular Expressions (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress