SQL Date and Time Functions

SQL Tutorial › Functions & Grouping › Date & Time Functions

SQL Date and Time Functions

IntermediateLesson 28 of 6913 min read9 queries run on MySQL 8.4

Dates drive most business questions: orders this month, customers who joined last year, employees’ years of service, days until delivery. MySQL has a rich set of date and time functions. This lesson shows the ones you will reach for every week, run on the sample orders and employees.

In this lesson
  • Get the current date and time
  • Extract parts with YEAR, MONTH, DAYNAME and EXTRACT
  • Calculate with DATE_ADD, DATEDIFF and TIMESTAMPDIFF
  • Format with DATE_FORMAT and group by month

Current date and time

MySQL
SELECT NOW(), CURDATE(), CURTIME(), UTC_TIMESTAMP();
ResultMySQL 8.4
NOW()CURDATE()CURTIME()UTC_TIMESTAMP()
2026-11-02 09:34:542026-11-0209:34:542026-11-02 09:34:54
1 row in set
FunctionReturns
NOW()date and time when the statement started
CURDATE()date only
CURTIME()time only
UTC_TIMESTAMP()date and time in UTC (the server above runs in UTC, so it equals NOW())

The result above is from the moment this page was built. The values change every time you run the query, so the examples below use fixed dates and the dates stored in the sample tables.

Sample data

orders · 10 rows
order_idcustomer_idorder_datestatus
100112025-01-05Delivered
100222025-01-17Delivered
100312025-02-02Delivered
100432025-02-14Cancelled
100542025-03-08Delivered
100652025-03-22Shipped
100722025-04-10Delivered
100812025-04-28Pending
100932025-05-15Delivered
101052025-06-01Delivered

Extract parts of a date

MySQL
SELECT order_id, order_date,       YEAR(order_date)    AS yr,       MONTH(order_date)   AS mon,       MONTHNAME(order_date) AS month_name,       DAY(order_date)     AS dy,       DAYNAME(order_date) AS weekday,       QUARTER(order_date) AS qtrFROM ordersLIMIT 4;
ResultMySQL 8.4
order_idorder_dateyrmonmonth_namedyweekdayqtr
10012025-01-0520251January5Sunday1
10022025-01-1720251January17Friday1
10032025-02-0220252February2Sunday1
10042025-02-1420252February14Friday1
4 rows in set
How this query works, step by step
  1. YEAR, MONTH and DAY return numbers.
  2. MONTHNAME and DAYNAME return English names.
  3. QUARTER returns 1 to 4. EXTRACT(MONTH FROM order_date) is the standard-SQL way to write MONTH(order_date).

Add and subtract time

Expected delivery is 5 days after the order; the return window ends 1 month after delivery:

MySQL
SELECT order_id, order_date,       DATE_ADD(order_date, INTERVAL 5 DAY)                    AS expected_delivery,       order_date + INTERVAL 5 DAY + INTERVAL 1 MONTH           AS return_deadline,       DATE_SUB(order_date, INTERVAL 1 WEEK)                    AS week_beforeFROM ordersWHERE order_id IN (1001, 1002, 1004);
ResultMySQL 8.4
order_idorder_dateexpected_deliveryreturn_deadlineweek_before
10012025-01-052025-01-102025-02-102024-12-29
10022025-01-172025-01-222025-02-222025-01-10
10042025-02-142025-02-192025-03-192025-02-07
3 rows in set
How this query works, step by step
  1. DATE_ADD(date, INTERVAL n unit) adds; DATE_SUB subtracts.
  2. date + INTERVAL ... is a shorter way to write the same thing.
  3. Units include DAY, WEEK, MONTH, QUARTER, YEAR, HOUR, MINUTE and SECOND.

Difference between dates

Years of service on a fixed reference date, 31 Dec 2025:

MySQL
SELECT first_name, hire_date,       DATEDIFF('2025-12-31', hire_date)                AS days_employed,       TIMESTAMPDIFF(MONTH, hire_date, '2025-12-31')     AS months_employed,       TIMESTAMPDIFF(YEAR, hire_date, '2025-12-31')      AS full_yearsFROM employeesORDER BY hire_dateLIMIT 5;
ResultMySQL 8.4
first_namehire_datedays_employedmonths_employedfull_years
Meera2015-01-05401313110
Vikram2017-11-202963978
Asha2018-04-122820927
Sneha2019-02-252501826
Rahul2019-07-012375776
5 rows in set
How this query works, step by step
  1. DATEDIFF(later, earlier) returns whole days.
  2. TIMESTAMPDIFF(unit, earlier, later) counts complete units. Note the reversed argument order.
  3. TIMESTAMPDIFF(YEAR, ...) counts only full years, so it is the right way to calculate age or service.
Tip

In a live system, replace '2025-12-31' with CURDATE().

Format dates with DATE_FORMAT

MySQL
SELECT order_date,       DATE_FORMAT(order_date, '%d/%m/%Y')     AS indian_style,       DATE_FORMAT(order_date, '%d %b %Y')     AS short_text,       DATE_FORMAT(order_date, '%W, %M %e')    AS long_text,       DATE_FORMAT(order_date, '%Y-%m')        AS month_labelFROM ordersWHERE order_id IN (1001, 1006);
ResultMySQL 8.4
order_dateindian_styleshort_textlong_textmonth_label
2025-01-0505/01/202505 Jan 2025Sunday, January 52025-01
2025-03-2222/03/202522 Mar 2025Saturday, March 222025-03
2 rows in set
Note

year_month would be a natural alias here, but it is a reserved word in MySQL (it is an INTERVAL unit), so it would cause a syntax error.

SpecifierMeaningExample
%Y / %y4- / 2-digit year2025 / 25
%m / %cmonth 01-12 / 1-1203 / 3
%M / %bmonth name / shortMarch / Mar
%d / %eday 01-31 / 1-3108 / 8
%W / %aweekday / shortSaturday / Sat
%H:%i:%s24-hour time14:05:09

STR_TO_DATE does the reverse, turning text into a date:

MySQL
SELECT STR_TO_DATE('31/01/2026', '%d/%m/%Y') AS parsed_date;
ResultMySQL 8.4
parsed_date
2026-01-31
1 row in set

Group by month

Orders and revenue per month, the most common date report:

MySQL
SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month,       COUNT(DISTINCT o.order_id)          AS orders,       SUM(oi.quantity * p.price)          AS revenueFROM orders oJOIN order_items oi ON oi.order_id = o.order_idJOIN products p     ON p.product_id = oi.product_idWHERE o.status <> 'Cancelled'GROUP BY monthORDER BY month;
ResultMySQL 8.4
monthordersrevenue
2025-01213743
2025-0214298
2025-0328783
2025-04221795
2025-0513499
2025-0611145
6 rows in set
How this query works, step by step
  1. Each order date becomes a 'YYYY-MM' label.
  2. Rows with the same label form one group.
  3. COUNT(DISTINCT o.order_id) counts orders, not lines; SUM totals the revenue.

Filter a date range efficiently

Orders in March 2025:

SQL
SELECT order_id, order_dateFROM ordersWHERE order_date >= '2025-03-01' AND order_date < '2025-04-01';
ResultMySQL 8.4
order_idorder_date
10052025-03-08
10062025-03-22
2 rows in set
Watch out

WHERE MONTH(order_date) = 3 gives the same rows here, but wrapping the column in a function stops MySQL from using an index on order_date, and it would also match March of every year. Use a range on the raw column.

Useful helpers

MySQL
SELECT LAST_DAY('2024-02-10')                      AS last_day_feb_2024,       DAYOFWEEK('2025-03-08')                     AS day_of_week,       WEEK('2025-03-08')                          AS week_no,       DATE('2025-03-08 17:45:00')                 AS date_part,       MAKEDATE(2026, 32)                          AS day_32_of_2026;
ResultMySQL 8.4
last_day_feb_2024day_of_weekweek_nodate_partday_32_of_2026
2024-02-29792025-03-082026-02-01
1 row in set
How this query works, step by step
  1. LAST_DAY returns the last day of the month: 2024 is a leap year, so 29 February.
  2. DAYOFWEEK returns 1 for Sunday to 7 for Saturday; 8 March 2025 was a Saturday.
  3. DATE() strips the time from a datetime.
  4. MAKEDATE(year, n) builds the nth day of a year: day 32 is 1 February.

Exercise: Orders per weekday

Count the orders placed on each weekday name.

Expected resultMySQL 8.4
weekdayorders
Sunday3
Friday2
Saturday2
Thursday2
Monday1
5 rows in set
Hint 1

Group by DAYNAME(order_date).

Exercise: Joined in 2024

List customers who joined in 2024, with the month name they joined.

Expected resultMySQL 8.4
customer_namejoined_onjoined_month
Ananya Sharma2024-01-15January
Rohit Kumar2024-02-03February
Fatima Khan2024-03-21March
David Fernandes2024-05-09May
Ishita Bose2024-07-30July
5 rows in set
Hint 1

Filter with a date range, show MONTHNAME(joined_on).

Quick quiz

1. Which function returns today’s date without the time?

2. What does DATEDIFF('2025-03-10', '2025-03-01') return?

3. Which is the best way to calculate full years of service?

4. Which format string produces 08/03/2025?

Date functions FAQ

How do I get the current date in MySQL?

CURDATE() returns the current date; NOW() returns the date and time.

How do I calculate the difference between two dates in SQL?

In MySQL use DATEDIFF(end, start) for days, or TIMESTAMPDIFF(unit, start, end) for months, years, hours and other units.

How do I format a date in MySQL?

Use DATE_FORMAT(date, format), for example DATE_FORMAT(order_date, '%d/%m/%Y').

How do I group rows by month in SQL?

Group by DATE_FORMAT(date_col, '%Y-%m'), or by YEAR(date_col), MONTH(date_col).

SQL IFNULL, COALESCE and NULLIF

SQL Tutorial › Functions & Grouping › IFNULL & COALESCE

SQL IFNULL, COALESCE and NULLIF

IntermediateLesson 29 of 698 min read6 queries run on MySQL 8.4

NULL breaks arithmetic, string concatenation and averages (NULL Values). Three small functions fix that: COALESCE and IFNULL replace NULL with a value you choose, and NULLIF does the opposite, turning a value into NULL, which is the standard trick to avoid division by zero.

In this lesson
  • Replace NULL with a default using COALESCE and IFNULL
  • Pick the first non-NULL value from several columns
  • Avoid division by zero with NULLIF
  • Know the difference between COALESCE and IFNULL

Sample data

employees · 12 rows (some columns)
emp_idfirst_namedept_idmanager_idsalarycommission
100MeeraNULLNULL250000NULL
101Asha1100125000NULL
102Rahul110198000NULL
103Priya110172000NULL
104Karan110272000NULL
105Vikram210010500012000
106Neha2105540006000
107Arjun2105480004500
108Kavya310088000NULL
109Rohan310845000NULL
110Sneha410078000NULL
111Amit110125000NULL

COALESCE

COALESCE(a, b, c, ...) returns the first argument that is not NULL:

SQL
SELECT first_name, commission,       COALESCE(commission, 0)          AS commission_or_zero,       salary + COALESCE(commission, 0) AS total_payFROM employeesWHERE dept_id IN (2, 3);
ResultMySQL 8.4
first_namecommissioncommission_or_zerototal_pay
Vikram1200012000117000
Neha6000600060000
Arjun4500450052500
KavyaNULL088000
RohanNULL045000
5 rows in set
How this query works, step by step
  1. For sales staff commission has a value, so COALESCE returns it.
  2. For Marketing it is NULL, so COALESCE moves on to the next argument: 0.
  3. The total pay is now a real number for everyone.

With several arguments it picks the first available value. Show the best contact for each customer: email, otherwise city:

SQL
SELECT customer_name, email, city,       COALESCE(email, CONCAT('no email - ', city)) AS contactFROM customersWHERE customer_id IN (3, 4, 5);
ResultMySQL 8.4
customer_nameemailcitycontact
Fatima Khanfatima@example.comHyderabadfatima@example.com
David FernandesNULLGoano email – Goa
Ishita Boseishita@example.comKolkataishita@example.com
3 rows in set

IFNULL

IFNULL(a, b) is MySQL’s two-argument version: a if it is not NULL, otherwise b.

SQL
SELECT first_name,       IFNULL(dept_id, 'none')    AS dept,       IFNULL(manager_id, 'top')  AS reports_toFROM employeesWHERE emp_id IN (100, 101, 102);
ResultMySQL 8.4
first_namedeptreports_to
Meeranonetop
Asha1100
Rahul1101
3 rows in set
How this query works, step by step
  1. Meera (100) has no department and no manager, so both columns show the replacement text.
  2. Asha and Rahul have values, which are shown unchanged.
COALESCEIFNULL
Arguments2 or moreexactly 2
Standard SQLyes, works everywhereMySQL (SQL Server: ISNULL, Oracle: NVL)

Prefer COALESCE unless you are sure the query will only ever run on MySQL.

NULLIF: avoid division by zero

NULLIF(a, b) returns NULL when a = b, otherwise a. Divide by NULLIF(x, 0) and a zero divisor gives NULL instead of a problem:

MySQL
SELECT product_name, price, stock,       price / NULLIF(stock, 0) AS price_per_unit_in_stockFROM productsWHERE category = 'Electronics';
ResultMySQL 8.4
product_namepricestockprice_per_unit_in_stock
Wireless Mouse7991206.6583
Mechanical Keyboard34994577.7556
USB-C Hub19990NULL
3 rows in set
How this query works, step by step
  1. For products with stock, NULLIF(stock, 0) returns the stock and the division works.
  2. The USB-C Hub has stock = 0, so NULLIF returns NULL, and dividing by NULL gives NULL.
Note

MySQL returns NULL (with a warning) for division by zero in a plain SELECT, but in strict mode an INSERT or UPDATE that divides by zero fails. Other databases such as PostgreSQL and SQL Server raise an error even in SELECT. NULLIF makes the intent explicit everywhere.

NULLIF also turns “empty” placeholder values into real NULLs, so they are skipped by aggregates:

MySQL
CREATE TABLE survey (respondent INT, score INT);INSERT INTO survey VALUES (1, 8), (2, 0), (3, 6), (4, 0);SELECT AVG(score) AS avg_with_zeros, AVG(NULLIF(score, 0)) AS avg_answered_onlyFROM survey;
ResultMySQL 8.4
mysql> CREATE TABLE survey (respondent INT, score INT)
Query OK, 0 rows affected
mysql> INSERT INTO survey VALUES (1, 8), (2, 0), (3, 6), (4, 0)
Query OK, 4 rows affected
mysql> SELECT AVG(score) AS avg_with_zeros, AVG(NULLIF(score, 0)) AS avg_answ…
avg_with_zerosavg_answered_only
3.50007.0000
1 row in set
How this query works, step by step
  1. In this survey, 0 means “did not answer”.
  2. AVG(score) counts the zeros: (8 + 0 + 6 + 0) / 4 = 3.5.
  3. NULLIF(score, 0) turns them into NULL, so AVG uses only the real answers: (8 + 6) / 2 = 7.

IF()

MySQL’s IF(condition, then, else) is handy for one quick test:

SQL
SELECT first_name, IF(commission IS NULL, 'Salary only', 'Salary + commission') AS pay_typeFROM employeesWHERE dept_id = 2 OR emp_id = 101;
ResultMySQL 8.4
first_namepay_type
AshaSalary only
VikramSalary + commission
NehaSalary + commission
ArjunSalary + commission
4 rows in set

For more than one condition use CASE.

Exercise: Readable departments

Show each employee’s first_name and the department id, but print 0 instead of NULL. Order by that column.

Expected resultMySQL 8.4
first_namedept
Meera0
Amit1
Asha1
Karan1
Priya1
Rahul1
Arjun2
Neha2
Vikram2
Kavya3
Rohan3
Sneha4
12 rows in set
Hint 1

COALESCE(dept_id, 0).

Quick quiz

1. What does COALESCE(NULL, NULL, 5, 7) return?

2. What does NULLIF(10, 10) return?

3. Which function is standard SQL and works in every database?

COALESCE and IFNULL FAQ

What is the difference between COALESCE and IFNULL?

IFNULL takes exactly two arguments and is MySQL-specific. COALESCE takes any number of arguments, returns the first non-NULL one, and is standard SQL.

How do I replace NULL with 0 in SQL?

Use COALESCE(column, 0), or IFNULL(column, 0) in MySQL.

How do I avoid a divide-by-zero error in SQL?

Divide by NULLIF(divisor, 0). When the divisor is 0 the result becomes NULL instead of an error; wrap it in COALESCE(..., 0) if you prefer 0.

What does NULLIF do?

NULLIF(a, b) returns NULL if a equals b, otherwise it returns a.

SQL GROUP BY

SQL Tutorial › Functions & Grouping › GROUP BY

SQL GROUP BY

IntermediateLesson 24 of 6912 min read9 queries run on MySQL 8.4

GROUP BY splits rows into groups that share a value and runs an aggregate on each group: headcount per department, revenue per month, orders per customer. It is the single most important clause for reporting, and this lesson shows exactly how MySQL builds the groups.

In this lesson
  • Group rows and aggregate each group
  • Group by several columns and by expressions
  • Combine GROUP BY with joins and ORDER BY
  • Add subtotals with WITH ROLLUP

GROUP BY syntax

MySQL
SELECT group_column, AGGREGATE(column)FROM table_name[WHERE row_condition]GROUP BY group_column[HAVING group_condition][ORDER BY ...];

Sample data

employees · 12 rows (some columns)
emp_idfirst_namedept_idjob_titlesalarycity
100MeeraNULLCEO250000Mumbai
101Asha1Engineering Manager125000Bengaluru
102Rahul1Senior Developer98000Bengaluru
103Priya1QA Engineer72000Pune
104Karan1Developer72000Bengaluru
105Vikram2Sales Manager105000Mumbai
106Neha2Sales Executive54000Mumbai
107Arjun2Sales Executive48000Kolkata
108Kavya3Marketing Lead88000Pune
109Rohan3Content Writer45000Pune
110Sneha4HR Manager78000Delhi
111Amit1Intern25000Bengaluru

Group by one column

Headcount and payroll per department:

SQL
SELECT dept_id,       COUNT(*)    AS staff,       SUM(salary) AS payrollFROM employeesGROUP BY dept_idORDER BY dept_id;
ResultMySQL 8.4
dept_idstaffpayroll
NULL1250000
15392000
23207000
32133000
4178000
5 rows in set
How this query works, step by step
  1. FROM employees reads all 12 rows.
  2. GROUP BY dept_id sorts the rows into piles with the same dept_id: NULL (the CEO), 1, 2, 3 and 4.
  3. COUNT(*) and SUM(salary) run once per pile.
  4. The result has one row per group: 5 rows.

Picture the piles for departments 2 and 3:

dept_idrows in the groupCOUNT(*)SUM(salary)
2Vikram 105000, Neha 54000, Arjun 480003207000
3Kavya 88000, Rohan 450002133000

Show names instead of ids

Join the departments table and group by the name:

SQL
SELECT d.dept_name,       COUNT(*)           AS staff,       ROUND(AVG(e.salary)) AS avg_salaryFROM employees eJOIN departments d ON d.dept_id = e.dept_idGROUP BY d.dept_nameORDER BY staff DESC;
ResultMySQL 8.4
dept_namestaffavg_salary
Engineering578400
Sales369000
Marketing266500
HR178000
4 rows in set
How this query works, step by step
  1. The inner join drops the CEO (no department) and adds dept_name to each employee row.
  2. GROUP BY d.dept_name makes one group per department name.
  3. ORDER BY staff DESC lists the largest department first.

Group by several columns

One group for every combination of department and city:

SQL
SELECT dept_id, city, COUNT(*) AS staffFROM employeesWHERE dept_id IS NOT NULLGROUP BY dept_id, cityORDER BY dept_id, city;
ResultMySQL 8.4
dept_idcitystaff
1Bengaluru4
1Pune1
2Kolkata1
2Mumbai2
3Pune2
4Delhi1
6 rows in set
How this query works, step by step
  1. WHERE removes the CEO before grouping.
  2. Rows with the same dept_id and the same city form one group.
  3. Engineering is split in two: 4 people in Bengaluru and 1 in Pune.

Group by an expression

Orders per month. DATE_FORMAT turns each date into a 'YYYY-MM' label, and the label is the group:

MySQL
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month,       COUNT(*) AS ordersFROM ordersGROUP BY monthORDER BY month;
ResultMySQL 8.4
monthorders
2025-012
2025-022
2025-032
2025-042
2025-051
2025-061
6 rows in set

Revenue per product category:

SQL
SELECT p.category,       SUM(oi.quantity)           AS units,       SUM(oi.quantity * p.price) AS revenueFROM order_items oiJOIN products p ON p.product_id = oi.product_idGROUP BY p.categoryORDER BY revenue DESC;
ResultMySQL 8.4
categoryunitsrevenue
Furniture750493
Electronics716093
Stationery245676
3 rows in set
How this query works, step by step
  1. The join attaches the category and price to each of the 16 order lines.
  2. The lines are grouped into Electronics, Furniture and Stationery.
  3. For each category, SUM adds up units and quantity × price.

The ONLY_FULL_GROUP_BY rule

Every column in SELECT must either be in GROUP BY or be inside an aggregate. Otherwise MySQL cannot know which row’s value to show:

MySQL
SELECT dept_id, first_name, COUNT(*)FROM employeesGROUP BY dept_id;
ResultMySQL 8.4
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'company.employees.first_name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
How this query works, step by step
  1. Department 1 has five employees, so “the” first_name of the group is undefined.
  2. MySQL 8.4 refuses the query (error 1055). Older versions silently picked a random name.
  3. Fix it by aggregating (GROUP_CONCAT(first_name)) or by grouping by first_name too.
SQL
SELECT dept_id, COUNT(*) AS staff, GROUP_CONCAT(first_name ORDER BY first_name) AS namesFROM employeesGROUP BY dept_id;
ResultMySQL 8.4
dept_idstaffnames
NULL1Meera
15Amit,Asha,Karan,Priya,Rahul
23Arjun,Neha,Vikram
32Kavya,Rohan
41Sneha
5 rows in set

WHERE vs HAVING

  • WHERE filters rows before grouping.
  • HAVING filters groups after grouping.
SQL
SELECT dept_id, COUNT(*) AS staffFROM employeesWHERE salary >= 50000GROUP BY dept_idHAVING COUNT(*) >= 2;
ResultMySQL 8.4
dept_idstaff
14
22
2 rows in set

Subtotals with WITH ROLLUP

WITH ROLLUP adds a total row after the groups:

MySQL
SELECT category, COUNT(*) AS products, SUM(stock) AS stockFROM productsGROUP BY category WITH ROLLUP;
ResultMySQL 8.4
categoryproductsstock
Electronics3165
Furniture465
Stationery2550
NULL9780
4 rows in set
How this query works, step by step
  1. The first three rows are the normal groups.
  2. The extra last row has NULL in category and the totals over all products.
  3. Use GROUPING(category) to tell a rollup NULL from a real NULL.

Logical order with GROUP BY

FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Because SELECT runs after GROUP BY, aggregates are available to SELECT, HAVING and ORDER BY, but not to WHERE.

Exercise: Orders per customer

For each customer_id in orders, show the number of orders and the date of the latest order. Sort by the number of orders, highest first.

Expected resultMySQL 8.4
customer_idorderslast_order
132025-04-28
222025-04-10
322025-05-15
522025-06-01
412025-03-08
5 rows in set
Hint 1

COUNT(*) and MAX(order_date) grouped by customer_id.

Exercise: Stock value per category

For each product category, show the total stock value (price * stock).

Expected resultMySQL 8.4
categorystock_value
Furniture298335
Electronics253335
Stationery124450
3 rows in set
Hint 1

SUM(price * stock) grouped by category.

Quick quiz

1. How many rows does SELECT dept_id, COUNT(*) FROM employees GROUP BY dept_id return?

2. Which clause filters groups by an aggregate value?

3. Why does SELECT dept_id, first_name, COUNT(*) ... GROUP BY dept_id fail in MySQL 8?

4. What does WITH ROLLUP add?

GROUP BY FAQ

What does GROUP BY do in SQL?

It groups rows that have the same values in the listed columns, so aggregate functions such as COUNT and SUM return one result per group.

Can I GROUP BY multiple columns?

Yes. GROUP BY dept_id, city creates one group for each distinct combination of the two columns.

What is the difference between GROUP BY and ORDER BY?

GROUP BY combines rows into groups for aggregation. ORDER BY only sorts the final result. They are often used together.

Does GROUP BY sort the results?

Not reliably. MySQL 8 no longer sorts grouped results implicitly. Add ORDER BY when you need a specific order.

SQL HAVING Clause

SQL Tutorial › Functions & Grouping › HAVING

SQL HAVING Clause

IntermediateLesson 25 of 699 min read6 queries run on MySQL 8.4

WHERE filters rows; HAVING filters groups. Use it when the condition is about an aggregate: departments with more than two people, customers who spent over 10,000, emails that appear more than once. It runs after GROUP BY, so it can see COUNT, SUM and the other aggregates.

In this lesson
  • Filter groups with HAVING
  • Understand exactly when to use WHERE and when HAVING
  • Combine both in one query
  • Find duplicate values with GROUP BY ... HAVING COUNT(*) > 1

HAVING syntax

MySQL
SELECT group_column, AGGREGATE(column)FROM table_nameWHERE row_conditionGROUP BY group_columnHAVING group_condition;

Sample data

orders · 10 rows
order_idcustomer_idorder_datestatus
100112025-01-05Delivered
100222025-01-17Delivered
100312025-02-02Delivered
100432025-02-14Cancelled
100542025-03-08Delivered
100652025-03-22Shipped
100722025-04-10Delivered
100812025-04-28Pending
100932025-05-15Delivered
101052025-06-01Delivered

Filter groups by COUNT

Customers with more than one order:

SQL
SELECT customer_id, COUNT(*) AS ordersFROM ordersGROUP BY customer_idHAVING COUNT(*) > 1ORDER BY customer_id;
ResultMySQL 8.4
customer_idorders
13
22
32
52
4 rows in set
How this query works, step by step
  1. GROUP BY customer_id makes one group per customer: five groups.
  2. COUNT(*) counts the orders in each group.
  3. HAVING COUNT(*) > 1 keeps the groups with 2 or more orders. Customer 4 has only one and is dropped.

Filter groups by SUM

Customers who spent more than 5,000 on non-cancelled orders:

SQL
SELECT o.customer_id,       SUM(oi.quantity * p.price) AS spentFROM orders oJOIN order_items oi ON oi.order_id = o.order_idJOIN products p     ON p.product_id = oi.product_idWHERE o.status <> 'Cancelled'GROUP BY o.customer_idHAVING spent > 5000ORDER BY spent DESC;
ResultMySQL 8.4
customer_idspent
130038
29798
56642
3 rows in set
How this query works, step by step
  1. The joins give every order line its customer and price.
  2. WHERE o.status <> 'Cancelled' removes the cancelled order’s line before grouping.
  3. GROUP BY o.customer_id totals each customer’s lines.
  4. HAVING spent > 5000 keeps only the big spenders. MySQL allows the alias here; standard SQL would repeat SUM(oi.quantity * p.price).

WHERE vs HAVING

WHEREHAVING
Filtersindividual rowsgroups
Runsbefore GROUP BYafter GROUP BY
Can use aggregatesnoyes
Can use plain columnsyesonly grouped columns

Using an aggregate in WHERE is an error:

MySQL
SELECT dept_id, COUNT(*)FROM employeesWHERE COUNT(*) > 2GROUP BY dept_id;
ResultMySQL 8.4
ERROR 1111 (HY000): Invalid use of group function
Tip

If a condition does not need an aggregate, put it in WHERE. Filtering rows early means fewer rows to group, which is faster.

WHERE and HAVING together

Departments where at least two employees earn 50,000 or more:

SQL
SELECT dept_id, COUNT(*) AS well_paidFROM employeesWHERE salary >= 50000GROUP BY dept_idHAVING COUNT(*) >= 2ORDER BY dept_id;
ResultMySQL 8.4
dept_idwell_paid
14
22
2 rows in set
How this query works, step by step
  1. WHERE salary >= 50000 removes the three salaries below 50,000 first.
  2. The remaining rows are grouped by department and counted.
  3. HAVING COUNT(*) >= 2 keeps departments with two or more such people: Engineering (4) and Sales (2).

Find duplicates

GROUP BY ... HAVING COUNT(*) > 1 is the standard way to find duplicate values:

SQL
SELECT salary, COUNT(*) AS people, GROUP_CONCAT(first_name) AS namesFROM employeesGROUP BY salaryHAVING COUNT(*) > 1;
ResultMySQL 8.4
salarypeoplenames
720002Priya,Karan
1 row in set
How this query works, step by step
  1. Rows are grouped by salary.
  2. Only salaries shared by more than one employee survive: 72,000 (Priya and Karan).

HAVING with AVG

Product categories whose average price is above 3,000:

SQL
SELECT category, ROUND(AVG(price)) AS avg_price, COUNT(*) AS productsFROM productsGROUP BY categoryHAVING AVG(price) > 3000;
ResultMySQL 8.4
categoryavg_priceproducts
Furniture80744
1 row in set

Exercise: Popular products

List the product_ids that appear in two or more order lines, with the number of lines.

Expected resultMySQL 8.4
product_idorder_lines
23
63
12
42
72
82
6 rows in set
Hint 1

Group order_items by product_id and filter with HAVING COUNT(*) >= 2.

Exercise: Cities with one employee

Find the cities where exactly one employee works.

Expected resultMySQL 8.4
citystaff
Kolkata1
Delhi1
2 rows in set
Hint 1

HAVING COUNT(*) = 1.

Quick quiz

1. When does HAVING run?

2. Which clause can use COUNT(*) > 2?

3. How do you find duplicate emails in a users table?

HAVING FAQ

What is the difference between WHERE and HAVING in SQL?

WHERE filters rows before they are grouped and cannot use aggregate functions. HAVING filters groups after GROUP BY and can use aggregates such as COUNT(*) or SUM(amount).

Can HAVING be used without GROUP BY?

Yes. Without GROUP BY the whole table is one group, so SELECT COUNT() FROM orders HAVING COUNT() > 5 returns one row or none.

Can I use a column alias in HAVING?

In MySQL, yes. Standard SQL and some other databases require you to repeat the aggregate expression.

How do I find duplicate records in SQL?

Group by the columns that should be unique and keep groups with HAVING COUNT(*) > 1.

SQL Upsert: INSERT ON DUPLICATE KEY UPDATE and REPLACE

SQL Tutorial › Modifying Data › Upsert & REPLACE

SQL Upsert: INSERT ON DUPLICATE KEY UPDATE and REPLACE

IntermediateLesson 21 of 6910 min read5 queries run on MySQL 8.4

An upsert means “insert this row, or update it if it already exists”. Syncing a product feed, counting page views and saving user settings all need it. MySQL offers three tools, INSERT ... ON DUPLICATE KEY UPDATE, INSERT IGNORE and REPLACE, and they behave very differently when a row already exists.

In this lesson
  • Upsert with INSERT ... ON DUPLICATE KEY UPDATE
  • Refer to the new values with a row alias
  • Skip duplicates with INSERT IGNORE
  • Why REPLACE deletes before it inserts

What counts as a duplicate?

All three tools react to a clash on a PRIMARY KEY or UNIQUE index. Without such a key there is no “existing row” to detect, and every insert simply adds a row.

products · 9 rows
product_idproduct_namecategorypricestock
1Wireless MouseElectronics799120
2Mechanical KeyboardElectronics349945
3USB-C HubElectronics19990
4Office ChairFurniture899912
5Standing DeskFurniture189995
6Notebook PackStationery249300
7Gel PensStationery199250
8Desk LampFurniture149930
9Monitor ArmFurniture279918

INSERT … ON DUPLICATE KEY UPDATE

A supplier sends a price list. Product 2 exists (update it), product 10 is new (insert it):

MySQL
INSERT INTO products (product_id, product_name, category, price, stock)VALUES (2, 'Mechanical Keyboard', 'Electronics', 3299, 60),       (10, 'Webcam HD', 'Electronics', 2499, 40) AS newON DUPLICATE KEY UPDATE  price = new.price,  stock = new.stock; SELECT product_id, product_name, price, stock FROM products WHERE product_id IN (2, 10);
ResultMySQL 8.4
mysql> INSERT INTO products (product_id, product_name, category, price, stock)
Query OK, 3 rows affected
mysql> SELECT product_id, product_name, price, stock FROM products WHERE prod…
product_idproduct_namepricestock
2Mechanical Keyboard329960
10Webcam HD249940
2 rows in set
How this query works, step by step
  1. MySQL tries to insert each row.
  2. Product 2 clashes with the existing primary key, so instead of failing, MySQL runs the UPDATE part on that existing row.
  3. AS new is a row alias: new.price means “the price from the row I tried to insert”.
  4. Product 10 has no clash, so it is inserted normally.
  5. MySQL reports 3 rows affected: it counts 1 for each inserted row and 2 for each updated row.
Note

Older code writes price = VALUES(price). That form still works but is deprecated since MySQL 8.0.20; use the row alias.

Counters with ON DUPLICATE KEY

A classic use: count events per day without checking first whether the row exists:

MySQL
CREATE TABLE page_views (page VARCHAR(40) PRIMARY KEY, views INT NOT NULL);INSERT INTO page_views VALUES ('sql-tutorial', 1) ON DUPLICATE KEY UPDATE views = views + 1;INSERT INTO page_views VALUES ('sql-tutorial', 1) ON DUPLICATE KEY UPDATE views = views + 1;INSERT INTO page_views VALUES ('sql-joins', 1)    ON DUPLICATE KEY UPDATE views = views + 1;SELECT * FROM page_views;
ResultMySQL 8.4
mysql> CREATE TABLE page_views (page VARCHAR(40) PRIMARY KEY, views INT NOT N…
Query OK, 0 rows affected
mysql> INSERT INTO page_views VALUES ('sql-tutorial', 1) ON DUPLICATE KEY UPD…
Query OK, 1 row affected
mysql> INSERT INTO page_views VALUES ('sql-tutorial', 1) ON DUPLICATE KEY UPD…
Query OK, 2 rows affected
mysql> INSERT INTO page_views VALUES ('sql-joins', 1) ON DUPLICATE KEY UPD…
Query OK, 1 row affected
mysql> SELECT * FROM page_views
pageviews
sql-joins1
sql-tutorial2
2 rows in set
How this query works, step by step
  1. The first insert creates sql-tutorial with 1 view.
  2. The second clashes, so views = views + 1 makes it 2.
  3. sql-joins is new and starts at 1.
  4. Each statement is a single atomic operation, so two users hitting the page at the same moment cannot lose a count.

INSERT IGNORE

INSERT IGNORE silently skips rows that would cause a duplicate-key error and inserts the rest:

MySQL
INSERT IGNORE INTO departments (dept_id, dept_name, location)VALUES (1, 'Engineering', 'Chennai'),       (6, 'Legal', 'Delhi');SHOW WARNINGS;SELECT * FROM departments;
ResultMySQL 8.4
mysql> INSERT IGNORE INTO departments (dept_id, dept_name, location)
Query OK, 1 row affected, 1 warning
mysql> SHOW WARNINGS
LevelCodeMessage
Warning1062Duplicate entry '1' for key 'departments.PRIMARY'
1 row in set
mysql> SELECT * FROM departments
dept_iddept_namelocation
1EngineeringBengaluru
2SalesMumbai
3MarketingPune
4HRDelhi
5FinanceMumbai
6LegalDelhi
6 rows in set
How this query works, step by step
  1. Department 1 already exists, so that row is skipped and becomes a warning; its location stays Bengaluru.
  2. Department 6 is new and is inserted: 1 row affected, 1 warning.
  3. SHOW WARNINGS explains which row was ignored.
Watch out

IGNORE also turns other errors into warnings, such as values that are too long (they are cut short). Use it only when you really want “insert if new, otherwise do nothing”.

REPLACE INTO

REPLACE works like INSERT, but if the key exists it deletes the old row and inserts the new one:

MySQL
REPLACE INTO customers (customer_id, customer_name, city)VALUES (6, 'Manoj Pillai', 'Kochi'); SELECT * FROM customers WHERE customer_id = 6;
ResultMySQL 8.4
mysql> REPLACE INTO customers (customer_id, customer_name, city)
Query OK, 2 rows affected
mysql> SELECT * FROM customers WHERE customer_id = 6
customer_idcustomer_nameemailcityjoined_on
6Manoj PillaiNULLKochiNULL
1 row in set
How this query works, step by step
  1. Customer 6 exists, so MySQL deletes that row and inserts the new one: 2 rows affected.
  2. The new row has only the columns given. email and joined_on were not supplied, so the old values are lost and become NULL.

REPLACE fails if other tables reference the row through a foreign key, because it really deletes it. Customer 1 has orders:

MySQL
REPLACE INTO customers (customer_id, customer_name, city) VALUES (1, 'Ananya Sharma', 'Pune');
ResultMySQL 8.4
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`company`.`orders`, CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`customer_id`))

Which one should I use?

ToolRow is newRow existsTypical use
INSERT ... ON DUPLICATE KEY UPDATEinsertupdate chosen columnssync, counters, settings
INSERT IGNOREinsertdo nothing (warning)load data, skip duplicates
REPLACEinsertdelete + insertrarely; full overwrite of a standalone table

In other databases the same idea is INSERT ... ON CONFLICT DO UPDATE (PostgreSQL, SQLite) or MERGE (SQL Server, Oracle).

Exercise: Upsert stock levels

Products 3 and 11 arrive: product 3 (USB-C Hub, Electronics, 1999) with 30 units, product 11 (Sticky Notes, Stationery, 99) with 500 units. Write one statement that inserts new products and, for existing ones, sets stock to the new value. Show both products.

Expected resultMySQL 8.4
mysql> INSERT INTO products (product_id, product_name, category, price, stock)
Query OK, 3 rows affected
mysql> SELECT product_id, product_name, stock FROM products WHERE product_id …
product_idproduct_namestock
3USB-C Hub30
11Sticky Notes500
2 rows in set
Hint 1

Use a row alias and update only stock.

Quick quiz

1. What triggers the UPDATE part of ON DUPLICATE KEY UPDATE?

2. How many rows does MySQL report as affected when ON DUPLICATE KEY UPDATE changes one existing row?

3. What happens to columns you do not list in a REPLACE?

Upsert FAQ

How do I insert or update in MySQL?

Use INSERT INTO t (...) VALUES (...) AS new ON DUPLICATE KEY UPDATE col = new.col; It inserts new rows and updates rows whose primary or unique key already exists.

What is the difference between REPLACE and INSERT ON DUPLICATE KEY UPDATE?

REPLACE deletes the existing row and inserts a new one, losing unlisted columns and firing delete triggers. ON DUPLICATE KEY UPDATE changes only the columns you name in the existing row.

Does MySQL support MERGE?

No. MySQL uses INSERT ... ON DUPLICATE KEY UPDATE instead of the standard MERGE statement.

Is INSERT IGNORE safe?

It is safe for skipping duplicate keys, but it also hides other problems by turning errors into warnings. Check SHOW WARNINGS after using it.

SQL CASE Expression

SQL Tutorial › Querying Data › CASE Expression

SQL CASE Expression

IntermediateLesson 17 of 6912 min read7 queries run on MySQL 8.4

CASE is SQL’s if-then-else. It looks at each row, checks conditions in order, and returns the value of the first one that is true. Use it to label rows (“High”, “Low”), to sort in a custom order, and, combined with SUM or COUNT, to turn rows into report columns.

In this lesson
  • Write searched CASE WHEN ... THEN ... ELSE ... END
  • Use the short simple CASE form
  • Sort with a custom order
  • Count and pivot with SUM(CASE ...)

CASE syntax

MySQL
CASE  WHEN condition1 THEN result1  WHEN condition2 THEN result2  ...  ELSE default_resultEND
  • Conditions are checked top to bottom; the first true one wins.
  • If none is true and there is no ELSE, the result is NULL.
  • CASE is an expression, so it can be used anywhere a value can: SELECT, WHERE, ORDER BY, GROUP BY, inside functions.

Sample data

employees · 12 rows (some columns)
emp_idfirst_namedept_idsalarycommission
100MeeraNULL250000NULL
101Asha1125000NULL
102Rahul198000NULL
103Priya172000NULL
104Karan172000NULL
105Vikram210500012000
106Neha2540006000
107Arjun2480004500
108Kavya388000NULL
109Rohan345000NULL
110Sneha478000NULL
111Amit125000NULL

Label rows: salary bands

SQL
SELECT first_name, salary,       CASE         WHEN salary >= 100000 THEN 'High'         WHEN salary >= 60000  THEN 'Medium'         ELSE 'Entry'       END AS salary_bandFROM employeesORDER BY salary DESC;
ResultMySQL 8.4
first_namesalarysalary_band
Meera250000High
Asha125000High
Vikram105000High
Rahul98000Medium
Kavya88000Medium
Sneha78000Medium
Priya72000Medium
Karan72000Medium
Neha54000Entry
Arjun48000Entry
Rohan45000Entry
Amit25000Entry
12 rows in set
How this query works, step by step
  1. For each row, MySQL tests salary >= 100000 first. Meera, Asha and Vikram get 'High'.
  2. Rows that failed the first test are checked against salary >= 60000: these get 'Medium'. A salary of 125,000 never reaches this test, so order matters.
  3. Everything else falls through to ELSE 'Entry'.
  4. AS salary_band names the new column.
Watch out

Put the most specific condition first. If WHEN salary >= 60000 came first, every high earner would be labelled ‘Medium’.

Simple CASE

When every test compares the same expression for equality, the short form is easier to read:

SQL
SELECT order_id, status,       CASE status         WHEN 'Delivered' THEN 'Closed'         WHEN 'Cancelled' THEN 'Closed'         ELSE 'Open'       END AS ticket_stateFROM orders;
ResultMySQL 8.4
order_idstatusticket_state
1001DeliveredClosed
1002DeliveredClosed
1003DeliveredClosed
1004CancelledClosed
1005DeliveredClosed
1006ShippedOpen
1007DeliveredClosed
1008PendingOpen
1009DeliveredClosed
1010DeliveredClosed
10 rows in set
How this query works, step by step
  1. CASE status names the expression once.
  2. Each WHEN 'value' is compared with status using =.
  3. Pending and Shipped orders fall through to 'Open'.
Note

The simple form cannot test NULL (CASE x WHEN NULL is never true) or ranges. Use the searched form for those.

CASE with NULL

SQL
SELECT first_name,       CASE         WHEN commission IS NULL THEN 'No commission'         WHEN commission >= 10000 THEN 'Top seller'         ELSE 'Seller'       END AS sales_roleFROM employeesWHERE dept_id IN (2, 3);
ResultMySQL 8.4
first_namesales_role
VikramTop seller
NehaSeller
ArjunSeller
KavyaNo commission
RohanNo commission
5 rows in set

CASE in ORDER BY

Sort products so that out-of-stock items come last, and the rest by price:

SQL
SELECT product_name, stock, priceFROM productsORDER BY CASE WHEN stock = 0 THEN 1 ELSE 0 END, price;
ResultMySQL 8.4
product_namestockprice
Gel Pens250199
Notebook Pack300249
Wireless Mouse120799
Desk Lamp301499
Monitor Arm182799
Mechanical Keyboard453499
Office Chair128999
Standing Desk518999
USB-C Hub01999
9 rows in set
How this query works, step by step
  1. The CASE gives 0 to products in stock and 1 to out-of-stock products.
  2. Sorting by it first moves the USB-C Hub to the end.
  3. price orders the rest from cheapest to dearest.

Conditional counts with SUM(CASE …)

How many orders does each customer have, and how many of them were delivered?

SQL
SELECT customer_id,       COUNT(*) AS orders,       SUM(CASE WHEN status = 'Delivered' THEN 1 ELSE 0 END) AS delivered,       SUM(CASE WHEN status <> 'Delivered' THEN 1 ELSE 0 END) AS not_deliveredFROM ordersGROUP BY customer_idORDER BY customer_id;
ResultMySQL 8.4
customer_idordersdeliverednot_delivered
1321
2220
3211
4110
5211
5 rows in set
How this query works, step by step
  1. GROUP BY customer_id makes one group per customer.
  2. Inside each group, the CASE turns every delivered order into 1 and every other order into 0.
  3. SUM of those ones and zeros is the number of delivered orders.
  4. This pattern, conditional aggregation, is one of the most useful tricks in SQL reports.

Pivot rows into columns

Headcount per department, with one column per city group:

SQL
SELECT d.dept_name,       SUM(CASE WHEN e.city = 'Bengaluru' THEN 1 ELSE 0 END) AS bengaluru,       SUM(CASE WHEN e.city = 'Pune'      THEN 1 ELSE 0 END) AS pune,       SUM(CASE WHEN e.city NOT IN ('Bengaluru', 'Pune') THEN 1 ELSE 0 END) AS otherFROM departments dJOIN employees e ON e.dept_id = d.dept_idGROUP BY d.dept_nameORDER BY d.dept_name;
ResultMySQL 8.4
dept_namebengalurupuneother
Engineering410
HR001
Marketing020
Sales003
4 rows in set
How this query works, step by step
  1. The join gives each employee their department name.
  2. Each SUM(CASE ...) counts the employees of one city group.
  3. The result has one row per department and one column per city group: a pivot table.

IF() in MySQL

For a single condition MySQL also offers IF(condition, then, else):

SQL
SELECT product_name, IF(stock > 0, 'In stock', 'Sold out') AS availabilityFROM products;
ResultMySQL 8.4
product_nameavailability
Wireless MouseIn stock
Mechanical KeyboardIn stock
USB-C HubSold out
Office ChairIn stock
Standing DeskIn stock
Notebook PackIn stock
Gel PensIn stock
Desk LampIn stock
Monitor ArmIn stock
9 rows in set

IF() is MySQL-only. CASE works in every database, so prefer it in code that might move.

Exercise: Price labels

Label each product as 'Budget' (price under 1,000), 'Standard' (1,000 to 4,999) or 'Premium' (5,000 and above). Show product_name, price and the label as price_label, cheapest first.

Expected resultMySQL 8.4
product_namepriceprice_label
Gel Pens199Budget
Notebook Pack249Budget
Wireless Mouse799Budget
Desk Lamp1499Standard
USB-C Hub1999Standard
Monitor Arm2799Standard
Mechanical Keyboard3499Standard
Office Chair8999Premium
Standing Desk18999Premium
9 rows in set
Hint 1

Check the cheapest condition first, then the next, then ELSE.

Exercise: Orders by status

In one row, count the delivered, cancelled and other orders.

Expected resultMySQL 8.4
deliveredcancelledother
712
1 row in set
Hint 1

Three SUM(CASE ...) columns and no GROUP BY.

Quick quiz

1. A CASE has no ELSE and no condition matches. What is returned?

2. Two WHEN conditions are both true for a row. Which result is used?

3. What does SUM(CASE WHEN status = 'Delivered' THEN 1 ELSE 0 END) calculate?

4. Can CASE be used in ORDER BY?

CASE statement FAQ

How do you write IF-THEN-ELSE in SQL?

Use CASE WHEN condition THEN value ELSE other_value END. MySQL also has IF(condition, value, other_value) for a single test.

What is the difference between simple CASE and searched CASE?

Simple CASE x WHEN 1 THEN ... compares one expression with values using equality. Searched CASE WHEN condition THEN ... can test any condition, including ranges and IS NULL.

Can I use CASE in a WHERE clause?

Yes, because CASE returns a value, but a plain combination of AND and OR is usually clearer and can use indexes better.

How do I pivot rows to columns in MySQL?

Group the rows and use one SUM(CASE WHEN ... THEN 1 ELSE 0 END) (or MAX(CASE ...)) column per value you want as a column.

SQL NULL Values

SQL Tutorial › Querying Data › NULL Values

SQL NULL Values

BeginnerLesson 15 of 6911 min read9 queries run on MySQL 8.4

NULL means no value: unknown, missing or not applicable. It is not zero and not an empty string, and it does not behave like a normal value. Comparisons with NULL are never true, arithmetic with NULL gives NULL, and aggregates skip it. Most “my query lost some rows” bugs come from NULLs.

In this lesson
  • Find missing values with IS NULL and IS NOT NULL
  • Why = NULL never matches
  • How NULL affects arithmetic, COUNT, DISTINCT and sorting
  • The MySQL NULL-safe operator <=>

What NULL means

In the sample data, NULL appears where a value does not exist:

  • the CEO has no department and no manager;
  • only sales staff have a commission;
  • David Fernandes has no email.
employees · 12 rows (some columns)
emp_idfirst_namedept_idmanager_idcommission
100MeeraNULLNULLNULL
101Asha1100NULL
102Rahul1101NULL
103Priya1101NULL
104Karan1102NULL
105Vikram210012000
106Neha21056000
107Arjun21054500
108Kavya3100NULL
109Rohan3108NULL
110Sneha4100NULL
111Amit1101NULL

IS NULL and IS NOT NULL

Employees without a commission:

SQL
SELECT first_name, commissionFROM employeesWHERE commission IS NULL;
ResultMySQL 8.4
first_namecommission
MeeraNULL
AshaNULL
RahulNULL
PriyaNULL
KaranNULL
KavyaNULL
RohanNULL
SnehaNULL
AmitNULL
9 rows in set
How this query works, step by step
  1. IS NULL is the only correct way to test for a missing value.
  2. Nine employees have no commission.

Employees with a commission:

SQL
SELECT first_name, commissionFROM employeesWHERE commission IS NOT NULL;
ResultMySQL 8.4
first_namecommission
Vikram12000
Neha6000
Arjun4500
3 rows in set

Why = NULL never works

SQL
SELECT first_nameFROM employeesWHERE commission = NULL;
ResultMySQL 8.4
Empty set
How this query works, step by step
  1. commission = NULL asks “is this value equal to an unknown value?”
  2. The answer is always UNKNOWN, never TRUE, even when commission itself is NULL.
  3. WHERE keeps only TRUE rows, so the result is an Empty set, with no error to warn you.

You can see the three-valued logic directly:

MySQL
SELECT NULL = NULL   AS equals,       NULL <> 1     AS not_equal,       NULL IS NULL  AS is_null,       NULL <=> NULL AS null_safe_equals;
ResultMySQL 8.4
equalsnot_equalis_nullnull_safe_equals
NULLNULL11
1 row in set
How this query works, step by step
  1. NULL = NULL and NULL <> 1 are both NULL (unknown).
  2. IS NULL returns a real true value: 1.
  3. <=> is MySQL’s NULL-safe equal: it treats two NULLs as equal and returns 1.

NULL in arithmetic

Any arithmetic with NULL gives NULL:

SQL
SELECT first_name, salary, commission,       salary + commission AS total_payFROM employeesWHERE dept_id IN (1, 2)ORDER BY emp_id;
ResultMySQL 8.4
first_namesalarycommissiontotal_pay
Asha125000NULLNULL
Rahul98000NULLNULL
Priya72000NULLNULL
Karan72000NULLNULL
Vikram10500012000117000
Neha54000600060000
Arjun48000450052500
Amit25000NULLNULL
8 rows in set
How this query works, step by step
  1. For the three sales staff, salary + commission is a real number.
  2. For the engineers, commission is NULL, so salary + NULL is NULL: their total pay disappears.
  3. Fix it with salary + IFNULL(commission, 0) or COALESCE(commission, 0); see IFNULL & COALESCE.
SQL
SELECT first_name, salary + COALESCE(commission, 0) AS total_payFROM employeesWHERE dept_id IN (1, 2)ORDER BY emp_id;
ResultMySQL 8.4
first_nametotal_pay
Asha125000
Rahul98000
Priya72000
Karan72000
Vikram117000
Neha60000
Arjun52500
Amit25000
8 rows in set

NULL in aggregates

Aggregate functions skip NULLs, except COUNT(*), which counts rows:

MySQL
SELECT COUNT(*)          AS all_rows,       COUNT(commission) AS with_commission,       SUM(commission)   AS total_commission,       AVG(commission)   AS avg_commissionFROM employees;
ResultMySQL 8.4
all_rowswith_commissiontotal_commissionavg_commission
123225007500.0000
1 row in set
How this query works, step by step
  1. COUNT(*) counts all 12 rows.
  2. COUNT(commission) counts only the 3 non-NULL values.
  3. SUM adds 12,000 + 6,000 + 4,500 = 22,500.
  4. AVG divides by 3 (the non-NULL count), not by 12: 7,500.
Watch out

If NULL really means zero in your data, AVG(column) gives the wrong answer. Use AVG(COALESCE(commission, 0)) to average over every row.

NULL in sorting and DISTINCT

  • In MySQL, NULLs sort first in ascending order and last in descending order (ORDER BY).
  • DISTINCT and GROUP BY treat all NULLs as one group (DISTINCT).
SQL
SELECT dept_id, COUNT(*) AS staffFROM employeesGROUP BY dept_idORDER BY dept_id;
ResultMySQL 8.4
dept_idstaff
NULL1
15
23
32
41
5 rows in set

NULL vs empty string vs zero

SQL
CREATE TABLE contacts (name VARCHAR(20), phone VARCHAR(15));INSERT INTO contacts VALUES ('Asha', '98450 12345'), ('Ravi', ''), ('Mona', NULL);SELECT name,       phone IS NULL  AS is_null,       phone = ''     AS is_empty,       LENGTH(phone)  AS lenFROM contacts;
ResultMySQL 8.4
mysql> CREATE TABLE contacts (name VARCHAR(20), phone VARCHAR(15))
Query OK, 0 rows affected
mysql> INSERT INTO contacts VALUES ('Asha', '98450 12345'), ('Ravi', ''), ('M…
Query OK, 3 rows affected
mysql> SELECT name,
nameis_nullis_emptylen
Asha0011
Ravi010
Mona1NULLNULL
3 rows in set
How this query works, step by step
  1. Ravi’s phone is an empty string: a value that has zero characters. = '' is true and the length is 0.
  2. Mona’s phone is NULL: no value. IS NULL is true, and both = '' and LENGTH() return NULL.
  3. Decide on one meaning for “no phone” in your design, usually NULL, and stick to it.

NOT NULL columns

If a column must always have a value, declare it NOT NULL when you create the table, and the database will refuse missing values (NOT NULL constraint).

Exercise: Customers without email

List the customers who have no email address.

Expected resultMySQL 8.4
customer_idcustomer_name
4David Fernandes
1 row in set
Hint 1

Use IS NULL, not = NULL.

Exercise: Total pay for everyone

Show every employee’s first_name and total_pay = salary plus commission, treating a missing commission as 0. Sort by total_pay descending.

Expected resultMySQL 8.4
first_nametotal_pay
Meera250000
Asha125000
Vikram117000
Rahul98000
Kavya88000
Sneha78000
Priya72000
Karan72000
Neha60000
Arjun52500
Rohan45000
Amit25000
12 rows in set
Hint 1

COALESCE(commission, 0) returns 0 when commission is NULL.

Quick quiz

1. Which condition finds rows where email is missing?

2. What is 100 + NULL?

3. A column has the values 10, NULL, 20. What is AVG(column)?

4. What does NULL <=> NULL return in MySQL?

NULL values FAQ

What is NULL in SQL?

NULL is a marker for a missing or unknown value. It is different from zero and from an empty string.

How do I check for NULL in SQL?

Use column IS NULL or column IS NOT NULL. Comparisons such as = NULL or <> NULL are never true.

Does COUNT include NULL values?

COUNT(*) counts all rows, including those with NULLs. COUNT(column) counts only rows where that column is not NULL.

How do I replace NULL with a default value?

Use COALESCE(column, default) (standard SQL) or IFNULL(column, default) in MySQL.

SQL Aliases (AS)

SQL Tutorial › Querying Data › Aliases

SQL Aliases (AS)

BeginnerLesson 16 of 698 min read6 queries run on MySQL 8.4

An alias is a temporary name you give to a column or a table for the length of one query. Column aliases make result headers readable; table aliases make joins short and are required when a table appears twice. Neither changes anything in the database.

In this lesson
  • Rename result columns with AS
  • Give tables short aliases in joins
  • Quote aliases that contain spaces
  • Know which clauses can see an alias

Column aliases

SQL
SELECT first_name AS name,       salary * 12 AS annual_salary,       hire_date   AS joinedFROM employeesWHERE dept_id = 3;
ResultMySQL 8.4
nameannual_salaryjoined
Kavya10560002020-09-09
Rohan5400002024-01-22
2 rows in set
How this query works, step by step
  1. first_name AS name shows the column under the header name.
  2. salary * 12 AS annual_salary names a calculated column; without the alias its header would be salary * 12.
  3. The table’s real column names do not change.

AS is optional: salary * 12 annual_salary works the same. Writing AS makes the intent obvious and avoids the missing-comma trap.

Aliases with spaces

Wrap an alias in quotes or backticks when it contains spaces:

SQL
SELECT customer_name AS 'Customer Name',       city          AS "Home City"FROM customersLIMIT 3;
ResultMySQL 8.4
Customer NameHome City
Ananya SharmaMumbai
Rohit KumarDelhi
Fatima KhanHyderabad
3 rows in set
Tip

Use spaces in aliases only for final reports. In application code use snake_case aliases such as annual_salary, so they are easy to read in code.

Table aliases

A table alias is a short name for a table, written after it in FROM or JOIN. Columns are then written as alias.column:

SQL
SELECT e.first_name, d.dept_name, d.locationFROM employees AS eJOIN departments AS d ON d.dept_id = e.dept_idWHERE d.location = 'Pune';
ResultMySQL 8.4
first_namedept_namelocation
KavyaMarketingPune
RohanMarketingPune
2 rows in set
How this query works, step by step
  1. employees AS e and departments AS d give the tables one-letter names.
  2. e.first_name means “the first_name column of employees“.
  3. The join pairs each employee with their department; WHERE keeps the department located in Pune (Marketing).

With aliases, a column that exists in both tables, such as dept_id, is never ambiguous. Without a prefix MySQL refuses the query:

MySQL
SELECT dept_id, first_name, dept_nameFROM employeesJOIN departments ON departments.dept_id = employees.dept_id;
ResultMySQL 8.4
ERROR 1052 (23000): Column 'dept_id' in field list is ambiguous

When an alias is required: self join

When the same table appears twice, aliases are the only way to tell the copies apart. Each employee with their manager’s name:

SQL
SELECT e.first_name AS employee,       m.first_name AS managerFROM employees eJOIN employees m ON m.emp_id = e.manager_idORDER BY e.emp_id;
ResultMySQL 8.4
employeemanager
AshaMeera
RahulAsha
PriyaAsha
KaranRahul
VikramMeera
NehaVikram
ArjunVikram
KavyaMeera
RohanKavya
SnehaMeera
AmitAsha
11 rows in set
How this query works, step by step
  1. employees e is the employee copy; employees m is the manager copy.
  2. m.emp_id = e.manager_id pairs each employee with the row of their manager.
  3. The CEO has no manager, so she does not appear (that is how an inner join works); see SELF JOIN.

Where can an alias be used?

ClauseColumn aliasTable alias
SELECTdefined hereyes
FROM / JOIN ... ONnodefined here
WHEREnoyes
GROUP BY / HAVINGyes in MySQL (not standard)yes
ORDER BYyesyes
SQL
SELECT category, COUNT(*) AS productsFROM productsGROUP BY categoryHAVING products >= 3ORDER BY products DESC;
ResultMySQL 8.4
categoryproducts
Furniture4
Electronics3
2 rows in set
How this query works, step by step
  1. COUNT(*) AS products counts the products of each category.
  2. MySQL lets HAVING and ORDER BY use the alias products. Standard SQL would require HAVING COUNT(*) >= 3.
  3. Stationery has only 2 products, so it is filtered out; Furniture (4) and Electronics (3) remain.

Exercise: Readable report

Show each product’s product_name as Product, price as Price (INR) and the stock value price * stock as Stock Value.

Expected resultMySQL 8.4
ProductPrice (INR)Stock Value
Wireless Mouse79995880
Mechanical Keyboard3499157455
USB-C Hub19990
Office Chair8999107988
Standing Desk1899994995
Notebook Pack24974700
Gel Pens19949750
Desk Lamp149944970
Monitor Arm279950382
9 rows in set
Hint 1

Aliases with spaces or brackets need quotes.

Quick quiz

1. Does an alias change the column name in the table?

2. Why does WHERE annual_salary > 1000000 fail when annual_salary is a SELECT alias?

3. When is a table alias required?

Alias FAQ

What is an alias in SQL?

An alias is a temporary name for a column or table, created with AS, that is valid only for one query.

Is the AS keyword required for aliases?

No. SELECT salary * 12 annual works, but writing AS is clearer. Oracle does not allow AS before table aliases.

Can I use a column alias in the WHERE clause?

No. WHERE is evaluated before SELECT. Repeat the expression or wrap the query in a subquery or CTE.

Why use table aliases?

They shorten queries with joins, make it clear which table each column comes from, and are required in self joins.

SQL IN and BETWEEN Operators

SQL Tutorial › Querying Data › IN & BETWEEN

SQL IN and BETWEEN Operators

BeginnerLesson 13 of 699 min read10 queries run on MySQL 8.4

IN checks whether a value is in a list, and BETWEEN checks whether it lies in a range. Both are shorter and clearer than chains of OR and AND, and both hide one surprise each: BETWEEN includes both ends, and NOT IN breaks when the list contains NULL.

In this lesson
  • Match a list of values with IN and NOT IN
  • Filter number and date ranges with BETWEEN
  • Remember that BETWEEN is inclusive
  • Avoid the NOT IN + NULL trap

Sample data

orders · 10 rows
order_idcustomer_idorder_datestatus
100112025-01-05Delivered
100222025-01-17Delivered
100312025-02-02Delivered
100432025-02-14Cancelled
100542025-03-08Delivered
100652025-03-22Shipped
100722025-04-10Delivered
100812025-04-28Pending
100932025-05-15Delivered
101052025-06-01Delivered

IN: match any value in a list

Orders from customers 1, 3 and 5:

MySQL
SELECT order_id, customer_id, statusFROM ordersWHERE customer_id IN (1, 3, 5);
ResultMySQL 8.4
order_idcustomer_idstatus
10011Delivered
10031Delivered
10081Pending
10043Cancelled
10093Delivered
10065Shipped
10105Delivered
7 rows in set
How this query works, step by step
  1. For each order, MySQL checks whether customer_id equals any value in the list.
  2. It is the same as customer_id = 1 OR customer_id = 3 OR customer_id = 5, but shorter.
  3. Customer 1 has three orders, customer 3 two and customer 5 two: 7 rows.

IN works with text too:

SQL
SELECT order_id, statusFROM ordersWHERE status IN ('Pending', 'Shipped');
ResultMySQL 8.4
order_idstatus
1006Shipped
1008Pending
2 rows in set

NOT IN

SQL
SELECT product_name, categoryFROM productsWHERE category NOT IN ('Electronics', 'Stationery');
ResultMySQL 8.4
product_namecategory
Office ChairFurniture
Standing DeskFurniture
Desk LampFurniture
Monitor ArmFurniture
4 rows in set
How this query works, step by step
  1. NOT IN keeps rows whose value matches none of the list.
  2. Only Furniture remains: 4 products.

IN with a subquery

The list can come from another query. Customers who have at least one order:

SQL
SELECT customer_id, customer_nameFROM customersWHERE customer_id IN (SELECT customer_id FROM orders);
ResultMySQL 8.4
customer_idcustomer_name
1Ananya Sharma
2Rohit Kumar
3Fatima Khan
4David Fernandes
5Ishita Bose
5 rows in set
How this query works, step by step
  1. The inner query returns the customer_id of every order (with repeats).
  2. The outer query keeps customers whose id is in that list: 5 of the 7 customers.

You will learn more in Subqueries.

The NOT IN and NULL trap

If the list contains a NULL, NOT IN returns no rows at all:

SQL
SELECT first_nameFROM employeesWHERE emp_id NOT IN (SELECT manager_id FROM employees);
ResultMySQL 8.4
Empty set
How this query works, step by step
  1. The subquery returns every manager_id, including the CEO’s NULL.
  2. For an employee such as 103, MySQL checks 103 <> 101 AND 103 <> 100 AND ... AND 103 <> NULL.
  3. 103 <> NULL is UNKNOWN, so the whole AND chain is never TRUE: Empty set.

Exclude the NULLs and the query finds the employees who manage nobody:

SQL
SELECT first_nameFROM employeesWHERE emp_id NOT IN (SELECT manager_id FROM employees WHERE manager_id IS NOT NULL);
ResultMySQL 8.4
first_name
Priya
Karan
Neha
Arjun
Rohan
Sneha
Amit
7 rows in set
Watch out

Prefer NOT EXISTS for “rows with no match in another table”. It handles NULLs the way you expect.

BETWEEN: a range

Products priced from 1,000 to 4,000:

SQL
SELECT product_name, priceFROM productsWHERE price BETWEEN 1000 AND 4000ORDER BY price;
ResultMySQL 8.4
product_nameprice
Desk Lamp1499
USB-C Hub1999
Monitor Arm2799
Mechanical Keyboard3499
4 rows in set
How this query works, step by step
  1. BETWEEN 1000 AND 4000 means price >= 1000 AND price <= 4000.
  2. Both ends are included. A product priced exactly 1,000 or 4,000 would match.
  3. Four products fall in the range.

BETWEEN with dates

Orders placed in February and March 2025:

SQL
SELECT order_id, order_dateFROM ordersWHERE order_date BETWEEN '2025-02-01' AND '2025-03-31';
ResultMySQL 8.4
order_idorder_date
10032025-02-02
10042025-02-14
10052025-03-08
10062025-03-22
4 rows in set
Watch out

For DATETIME columns, BETWEEN '2025-02-01' AND '2025-03-31' stops at midnight at the start of 31 March and misses the rest of that day. Use a half-open range instead: order_time >= '2025-02-01' AND order_time < '2025-04-01'.

NOT BETWEEN

SQL
SELECT first_name, salaryFROM employeesWHERE salary NOT BETWEEN 50000 AND 100000ORDER BY salary;
ResultMySQL 8.4
first_namesalary
Amit25000
Rohan45000
Arjun48000
Vikram105000
Asha125000
Meera250000
6 rows in set
How this query works, step by step
  1. NOT BETWEEN 50000 AND 100000 keeps salaries below 50,000 or above 100,000.
  2. Three people earn less than 50,000 and three earn more than 100,000.

BETWEEN with text

BETWEEN also works on text, comparing alphabetically:

SQL
SELECT customer_nameFROM customersWHERE customer_name BETWEEN 'A' AND 'G'ORDER BY customer_name;
ResultMySQL 8.4
customer_name
Ananya Sharma
David Fernandes
Fatima Khan
3 rows in set

Note that a name like 'Gaurav' would not match, because 'Gaurav' sorts after 'G'. To include every name starting with G, use customer_name < 'H'.

Exercise: Mid-range employees

List employees whose salary is from 45,000 to 75,000, highest first.

Expected resultMySQL 8.4
first_namesalary
Priya72000
Karan72000
Neha54000
Arjun48000
Rohan45000
5 rows in set
Hint 1

BETWEEN 45000 AND 75000 includes both ends.

Exercise: Selected cities

List customers who live in Mumbai, Delhi or Goa.

Expected resultMySQL 8.4
customer_namecity
Ananya SharmaMumbai
Rohit KumarDelhi
David FernandesGoa
3 rows in set
Hint 1

Use IN with three text values.

Quick quiz

1. price BETWEEN 100 AND 200 is the same as…

2. What does WHERE id NOT IN (1, 2, NULL) return?

3. Which is the safest filter for a whole day on a DATETIME column?

IN and BETWEEN FAQ

Is SQL BETWEEN inclusive?

Yes. BETWEEN a AND b includes both a and b. It is equal to >= a AND <= b.

What is the difference between IN and BETWEEN?

IN matches a list of specific values. BETWEEN matches every value in a continuous range.

Why does NOT IN return no rows?

If the list or subquery contains NULL, NOT IN can never be true. Filter the NULLs out (WHERE col IS NOT NULL) or use NOT EXISTS.

How many values can I put in an IN list?

MySQL has no fixed limit, but very long lists are slow to parse. For thousands of values, load them into a table and join instead.

SQL LIKE Operator and Wildcards

SQL Tutorial › Querying Data › LIKE & Wildcards

SQL LIKE Operator and Wildcards

BeginnerLesson 14 of 699 min read8 queries run on MySQL 8.4

= only finds exact matches. LIKE finds patterns: names that start with “A”, emails at a certain domain, product names that contain “Desk”. Two wildcard characters do the work: % for any number of characters and _ for exactly one.

In this lesson
  • Use % and _ to match patterns
  • Write starts-with, ends-with and contains searches
  • Escape a literal % or _
  • Know when to use REGEXP and why leading % is slow

The two wildcards

WildcardMatchesExample patternMatches
%zero or more characters'Desk%'Desk, Desk Lamp, Desktop
_exactly one character'_at'Bat, Cat, Hat (not Flat)

Sample data

customers · 7 rows
customer_idcustomer_nameemailcityjoined_on
1Ananya Sharmaananya@example.comMumbai2024-01-15
2Rohit Kumarrohit.k@example.comDelhi2024-02-03
3Fatima Khanfatima@example.comHyderabad2024-03-21
4David FernandesNULLGoa2024-05-09
5Ishita Boseishita@example.comKolkata2024-07-30
6Manoj Pillaimanoj.p@example.comChennai2025-01-11
7Sara Thomassara.t@example.comBengaluru2025-03-02

Starts with

Customers whose name starts with “R”:

SQL
SELECT customer_nameFROM customersWHERE customer_name LIKE 'R%';
ResultMySQL 8.4
customer_name
Rohit Kumar
1 row in set
How this query works, step by step
  1. The pattern 'R%' means: an R, then anything (or nothing).
  2. Only Rohit Kumar starts with R.
  3. MySQL’s default collation is case insensitive, so 'r%' would match too.

Ends with

Customers with an example.com email address:

SQL
SELECT customer_name, emailFROM customersWHERE email LIKE '%@example.com';
ResultMySQL 8.4
customer_nameemail
Ananya Sharmaananya@example.com
Rohit Kumarrohit.k@example.com
Fatima Khanfatima@example.com
Ishita Boseishita@example.com
Manoj Pillaimanoj.p@example.com
Sara Thomassara.t@example.com
6 rows in set
How this query works, step by step
  1. % at the start allows any text before @example.com.
  2. David has no email (NULL). NULL LIKE '...' is UNKNOWN, so his row is not returned: 6 rows.

Contains

Products whose name contains “Desk” anywhere:

SQL
SELECT product_nameFROM productsWHERE product_name LIKE '%Desk%';
ResultMySQL 8.4
product_name
Standing Desk
Desk Lamp
2 rows in set
How this query works, step by step
  1. % on both sides means “anything before and anything after”.
  2. Standing Desk and Desk Lamp match.

The underscore: exactly one character

Employees whose first name has exactly four letters:

SQL
SELECT first_nameFROM employeesWHERE first_name LIKE '____';
ResultMySQL 8.4
first_name
Asha
Neha
Amit
3 rows in set
How this query works, step by step
  1. Four underscores match exactly four characters, no more and no less.
  2. Asha, Neha and Amit have four-letter names.

Second letter is “a”:

SQL
SELECT first_nameFROM employeesWHERE first_name LIKE '_a%';
ResultMySQL 8.4
first_name
Rahul
Karan
Kavya
3 rows in set

NOT LIKE

SQL
SELECT product_nameFROM productsWHERE product_name NOT LIKE '%e%';
ResultMySQL 8.4
product_name
USB-C Hub
Monitor Arm
2 rows in set
How this query works, step by step
  1. NOT LIKE '%e%' keeps names that contain no “e” anywhere.
  2. Because the match ignores case, a capital “E” would also count.

Escaping % and _

To search for a real percent sign or underscore, put a backslash in front of it:

MySQL
CREATE TABLE coupons (code VARCHAR(20));INSERT INTO coupons VALUES ('SAVE10%'), ('SAVE100'), ('NEW_USER'), ('NEWUSER');SELECT code FROM coupons WHERE code LIKE '%10\%';SELECT code FROM coupons WHERE code LIKE 'NEW\_%';
ResultMySQL 8.4
mysql> CREATE TABLE coupons (code VARCHAR(20))
Query OK, 0 rows affected
mysql> INSERT INTO coupons VALUES ('SAVE10%'), ('SAVE100'), ('NEW_USER'), ('N…
Query OK, 4 rows affected
mysql> SELECT code FROM coupons WHERE code LIKE '%10\%'
code
SAVE10%
1 row in set
mysql> SELECT code FROM coupons WHERE code LIKE 'NEW\_%'
code
NEW_USER
1 row in set
How this query works, step by step
  1. In '%10\%' the last % is escaped, so it means a literal percent sign. Only SAVE10% ends with “10%”.
  2. In 'NEW\_%' the underscore is literal, so NEWUSER (no underscore) is excluded.

LIKE vs REGEXP

For more complex patterns MySQL has REGEXP (also written RLIKE), which uses regular expressions:

MySQL
SELECT customer_nameFROM customersWHERE customer_name REGEXP '^[A-F]';
ResultMySQL 8.4
customer_name
Ananya Sharma
Fatima Khan
David Fernandes
3 rows in set
How this query works, step by step
  1. ^ anchors the pattern at the start of the text.
  2. [A-F] matches one character from A to F, so names starting with A to F are returned.

Performance tip

A pattern with a fixed start, such as 'R%', can use an index on the column. A leading wildcard, '%son', cannot: MySQL must check every row. On big tables, consider a FULLTEXT index for word searches.

Exercise: Packs and pens

List the products whose name ends with “Pack” or contains “Pen”.

Expected resultMySQL 8.4
product_name
Notebook Pack
Gel Pens
2 rows in set
Hint 1

Two LIKE conditions joined by OR.

Exercise: Last names with “a” in second position

List employees whose last_name has the letter “a” as its second letter.

Expected resultMySQL 8.4
first_namelast_name
MeeraKapoor
AshaRao
PriyaNair
ArjunDas
4 rows in set
Hint 1

One underscore, then a, then %.

Quick quiz

1. What does % match in a LIKE pattern?

2. Which pattern finds names with exactly three characters?

3. Does WHERE email LIKE '%' return rows where email is NULL?

4. Which search cannot use a normal index?

LIKE operator FAQ

What are wildcards in SQL?

Wildcards are special characters in a LIKE pattern: % matches any sequence of characters and _ matches exactly one character.

Is LIKE case sensitive in MySQL?

Not with the default case-insensitive collation. For a case-sensitive match, use LIKE BINARY 'R%' or a column with a _cs or _bin collation.

How do I search for a percent sign with LIKE?

Escape it with a backslash: LIKE '%10\%'. You can also choose your own escape character: LIKE '%10!%' ESCAPE '!'.

What is the difference between LIKE and = in SQL?

= compares the whole value exactly. LIKE compares against a pattern with wildcards. Without wildcards, LIKE 'abc' behaves almost like = 'abc'.