MySQL BETWEEN
Many everyday questions are about a range: products priced from 500 to 1,000, employees aged 30 to 40, orders placed last week. In MySQL, the BETWEEN operator answers these questions in one short, readable condition.
This tutorial explains how BETWEEN works with numbers, dates, date-times and text, how NOT BETWEEN works, how NULL values behave, and how to use BETWEEN with indexes and from Java. You will also see the common mistakes and some practice exercises. Every result shown was produced by running the query on MySQL.
1. What is the BETWEEN Operator?
BETWEEN is a comparison operator. It checks whether a value lies inside a range that has a lower limit and an upper limit. It is used mostly in the WHERE clause to filter rows, but it works anywhere a condition is allowed.
Think of a shop's price filter where you choose "from 500 to 1,000". A product that costs exactly 500 or exactly 1,000 still appears in the results. BETWEEN behaves the same way: both limits are included.
2. Syntax and How It Works
SELECT column1, column2, ...
FROM table_name
WHERE expression BETWEEN low_value AND high_value;
MySQL reads this as two comparisons joined with AND:
-- These two conditions mean exactly the same thing
WHERE age BETWEEN 30 AND 40
WHERE age >= 30 AND age <= 40
- Inclusive: rows equal to
low_valueorhigh_valueare returned. - Low value first: write the smaller value before
AND(see section 10). - Any comparable type: numbers,
DATE,DATETIME,TIMEand text all work. - Result: the condition is
1(true),0(false) orNULL(unknown).
3. Sample Tables
All examples use two small tables. Run this script once to create them and follow along:
CREATE DATABASE IF NOT EXISTS company;
USE company;
CREATE TABLE employees (
emp_id INT PRIMARY KEY,
name VARCHAR(50),
dept VARCHAR(30),
age INT,
salary DECIMAL(10,2),
joined_on DATE
);
INSERT INTO employees VALUES
(101, 'Aarav', 'Sales', 24, 32000, '2021-03-15'),
(102, 'Bhavna', 'HR', 31, 45000, '2019-07-01'),
(103, 'Chetan', 'IT', 29, 58000, '2022-01-10'),
(104, 'Divya', 'IT', 35, 72000, '2018-11-23'),
(105, 'Farhan', 'Finance', 42, 81000, '2016-05-30'),
(106, 'Gauri', 'Sales', 27, 39000, '2023-02-14'),
(107, 'Kiran', 'Marketing', 30, 50000, '2020-09-05'),
(108, 'Meera', 'Finance', 38, NULL, '2024-06-17'),
(109, 'Nikhil', 'IT', 50, 95000, '2015-01-02'),
(110, 'Pooja', 'HR', 26, 36000, '2025-04-21');
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer VARCHAR(40),
amount DECIMAL(10,2),
ordered_at DATETIME
);
INSERT INTO orders VALUES
(1, 'Ravi', 1200.00, '2026-03-01 09:15:00'),
(2, 'Sneha', 450.50, '2026-03-01 18:40:00'),
(3, 'Tarun', 3299.00, '2026-03-02 11:05:00'),
(4, 'Usha', 799.00, '2026-03-03 00:00:00'),
(5, 'Varun', 2150.75, '2026-03-03 14:30:00'),
(6, 'Ravi', 999.00, '2026-03-03 23:59:10'),
(7, 'Sneha', 5600.00, '2026-03-04 08:00:00');
The employees table:
Table: employees (10 rows)
| emp_id | name | dept | age | salary | joined_on |
|---|---|---|---|---|---|
| 101 | Aarav | Sales | 24 | 32000.00 | 2021-03-15 |
| 102 | Bhavna | HR | 31 | 45000.00 | 2019-07-01 |
| 103 | Chetan | IT | 29 | 58000.00 | 2022-01-10 |
| 104 | Divya | IT | 35 | 72000.00 | 2018-11-23 |
| 105 | Farhan | Finance | 42 | 81000.00 | 2016-05-30 |
| 106 | Gauri | Sales | 27 | 39000.00 | 2023-02-14 |
| 107 | Kiran | Marketing | 30 | 50000.00 | 2020-09-05 |
| 108 | Meera | Finance | 38 | NULL | 2024-06-17 |
| 109 | Nikhil | IT | 50 | 95000.00 | 2015-01-02 |
| 110 | Pooja | HR | 26 | 36000.00 | 2025-04-21 |
The orders table:
Table: orders (7 rows)
| order_id | customer | amount | ordered_at |
|---|---|---|---|
| 1 | Ravi | 1200.00 | 2026-03-01 09:15:00 |
| 2 | Sneha | 450.50 | 2026-03-01 18:40:00 |
| 3 | Tarun | 3299.00 | 2026-03-02 11:05:00 |
| 4 | Usha | 799.00 | 2026-03-03 00:00:00 |
| 5 | Varun | 2150.75 | 2026-03-03 14:30:00 |
| 6 | Ravi | 999.00 | 2026-03-03 23:59:10 |
| 7 | Sneha | 5600.00 | 2026-03-04 08:00:00 |
4. BETWEEN with Numbers
Example 1: employees aged 30 to 40. Kiran (30) is on the lower limit and is included:
SELECT emp_id, name, age
FROM employees
WHERE age BETWEEN 30 AND 40;
Result: 4 rows
| emp_id | name | age |
|---|---|---|
| 102 | Bhavna | 31 |
| 104 | Divya | 35 |
| 107 | Kiran | 30 |
| 108 | Meera | 38 |
Example 2: decimal values work the same way. Salaries from 40,000 to 75,000, lowest first:
SELECT name, dept, salary
FROM employees
WHERE salary BETWEEN 40000 AND 75000
ORDER BY salary;
Result: 4 rows
| name | dept | salary |
|---|---|---|
| Bhavna | HR | 45000.00 |
| Kiran | Marketing | 50000.00 |
| Chetan | IT | 58000.00 |
| Divya | IT | 72000.00 |
Example 3: a range of IDs, which is handy when you process rows in batches:
SELECT emp_id, name, age
FROM employees
WHERE emp_id BETWEEN 103 AND 106;
Result: 4 rows
| emp_id | name | age |
|---|---|---|
| 103 | Chetan | 29 |
| 104 | Divya | 35 |
| 105 | Farhan | 42 |
| 106 | Gauri | 27 |
Example 4: orders worth 500 to 1,500:
SELECT order_id, amount
FROM orders
WHERE amount BETWEEN 500 AND 1500;
Result: 3 rows
| order_id | amount |
|---|---|
| 1 | 1200.00 |
| 4 | 799.00 |
| 6 | 999.00 |
5. NOT BETWEEN
NOT BETWEEN returns the rows outside the range: values smaller than the lower limit or bigger than the upper limit. The limits themselves are not returned.
-- Same as: WHERE age < 30 OR age > 40
SELECT emp_id, name, age
FROM employees
WHERE age NOT BETWEEN 30 AND 40;
Result: 6 rows
| emp_id | name | age |
|---|---|---|
| 101 | Aarav | 24 |
| 103 | Chetan | 29 |
| 105 | Farhan | 42 |
| 106 | Gauri | 27 |
| 109 | Nikhil | 50 |
| 110 | Pooja | 26 |
Salaries outside 40,000 to 75,000:
SELECT name, salary
FROM employees
WHERE salary NOT BETWEEN 40000 AND 75000;
Result: 5 rows
| name | salary |
|---|---|
| Aarav | 32000.00 |
| Farhan | 81000.00 |
| Gauri | 39000.00 |
| Nikhil | 95000.00 |
| Pooja | 36000.00 |
BETWEEN nor the NOT BETWEEN result, because her salary is NULL. A NULL value is never inside or outside a range.6. BETWEEN with Dates
Write dates as text in 'YYYY-MM-DD' format. MySQL converts them to dates before comparing. Employees who joined from 2020 to 2022:
SELECT name, joined_on
FROM employees
WHERE joined_on BETWEEN '2020-01-01' AND '2022-12-31'
ORDER BY joined_on;
Result: 3 rows
| name | joined_on |
|---|---|
| Kiran | 2020-09-05 |
| Aarav | 2021-03-15 |
| Chetan | 2022-01-10 |
DATE column this is safe: a DATE has no time part, so '2022-12-31' covers the whole last day.7. BETWEEN with DATETIME: the Last-Day Trap
A DATETIME column stores a time as well. When you write only a date, MySQL treats it as midnight at the start of that day. Look what happens when we ask for orders from 1 to 3 March:
SELECT order_id, customer, ordered_at
FROM orders
WHERE ordered_at BETWEEN '2026-03-01' AND '2026-03-03';
Result: 4 rows
| order_id | customer | ordered_at |
|---|---|---|
| 1 | Ravi | 2026-03-01 09:15:00 |
| 2 | Sneha | 2026-03-01 18:40:00 |
| 3 | Tarun | 2026-03-02 11:05:00 |
| 4 | Usha | 2026-03-03 00:00:00 |
Orders 5 and 6 were placed on 3 March, but they are missing. The upper limit '2026-03-03' really means '2026-03-03 00:00:00', so anything later that day is outside the range.
BETWEEN '2026-03-01' AND '2026-03-03' stops at 2026-03-03 00:00:00, so orders #5 and #6 are lost. A half-open range (>= start, < next day) covers the whole last day.The fix: use a half-open range. Start at the first day (included) and stop before the day after the last day:
SELECT order_id, customer, ordered_at
FROM orders
WHERE ordered_at >= '2026-03-01'
AND ordered_at < '2026-03-04';
Result: 6 rows
| order_id | customer | ordered_at |
|---|---|---|
| 1 | Ravi | 2026-03-01 09:15:00 |
| 2 | Sneha | 2026-03-01 18:40:00 |
| 3 | Tarun | 2026-03-02 11:05:00 |
| 4 | Usha | 2026-03-03 00:00:00 |
| 5 | Varun | 2026-03-03 14:30:00 |
| 6 | Ravi | 2026-03-03 23:59:10 |
Two other fixes you may see, and why the half-open range is better:
BETWEEN '2026-03-01 00:00:00' AND '2026-03-03 23:59:59'works for whole seconds, but misses a value such as23:59:59.500if the column stores fractional seconds (DATETIME(3)).DATE(ordered_at) BETWEEN '2026-03-01' AND '2026-03-03'returns the right rows, but wrapping the column in a function stops MySQL from using an index on it (see section 13).
BETWEEN for DATE columns, and >= start AND < next start for DATETIME and TIMESTAMP columns.8. BETWEEN with Text
Text values are compared in dictionary (alphabetical) order, using the column's collation. Let's find names from 'B' to 'K':
SELECT name
FROM employees
WHERE name BETWEEN 'B' AND 'K'
ORDER BY name;
Result: 5 rows
| name |
|---|
| Bhavna |
| Chetan |
| Divya |
| Farhan |
| Gauri |
Kiran starts with K, yet it is not returned. In a dictionary, a longer word comes after its own beginning, just as cart comes after car. So 'Kiran' is greater than 'K' and falls outside the range.
'K', so it sorts after it and falls outside the range.To include every name that starts with K, raise the upper limit, or stop just before the next letter:
-- Option 1: an upper limit that sorts after every 'K...' name
SELECT name FROM employees WHERE name BETWEEN 'B' AND 'Kz' ORDER BY name;
-- Option 2: stop before the next letter
SELECT name FROM employees WHERE name >= 'B' AND name < 'L' ORDER BY name;
Result of either query: 6 rows
| name |
|---|
| Bhavna |
| Chetan |
| Divya |
| Farhan |
| Gauri |
| Kiran |
utf8mb4_0900_ai_ci) the comparison ignores case, so BETWEEN 'b' AND 'k' returns the same five names. A binary or case-sensitive collation would treat upper and lower case differently.9. BETWEEN and NULL Values
If the value being tested, or either limit, is NULL, the result is NULL (unknown), and WHERE keeps only rows whose condition is true. You can see the raw results by selecting the conditions directly:
SELECT 5 BETWEEN 1 AND 10 AS a,
10 BETWEEN 1 AND 10 AS b,
11 BETWEEN 1 AND 10 AS c,
NULL BETWEEN 1 AND 10 AS d;
Result: 1 row
| a | b | c | d |
|---|---|---|---|
| 1 | 1 | 0 | NULL |
To include rows with a missing salary, ask for them explicitly:
SELECT name
FROM employees
WHERE salary BETWEEN 40000 AND 75000
OR salary IS NULL;
Result: 5 rows
| name |
|---|
| Bhavna |
| Chetan |
| Divya |
| Kiran |
| Meera |
10. The Order of the Values Matters
BETWEEN does not swap the limits for you. age BETWEEN 40 AND 30 means age >= 40 AND age <= 30, which no number can satisfy. MySQL raises no error; you simply get nothing back:
SELECT name, age
FROM employees
WHERE age BETWEEN 40 AND 30; -- wrong order
Result: Empty set (0 rows)
| name | age |
|---|---|
| No rows match | |
11. Using BETWEEN with Other Clauses
With AND: IT employees earning 50,000 to 80,000:
SELECT name, dept, age, salary
FROM employees
WHERE dept = 'IT'
AND salary BETWEEN 50000 AND 80000;
Result: 2 rows
| name | dept | age | salary |
|---|---|---|---|
| Chetan | IT | 29 | 58000.00 |
| Divya | IT | 35 | 72000.00 |
With OR: two separate ranges at once:
SELECT name, age
FROM employees
WHERE age BETWEEN 25 AND 29
OR age BETWEEN 45 AND 55
ORDER BY age;
Result: 4 rows
| name | age |
|---|---|
| Pooja | 26 |
| Gauri | 27 |
| Chetan | 29 |
| Nikhil | 50 |
With COUNT: how many employees are aged 28 to 40?
SELECT COUNT(*) AS mid_career
FROM employees
WHERE age BETWEEN 28 AND 40;
Result: 1 row
| mid_career |
|---|
| 5 |
With CASE: put each employee into a salary band and count each band:
SELECT CASE
WHEN salary BETWEEN 0 AND 39999 THEN 'Band A'
WHEN salary BETWEEN 40000 AND 69999 THEN 'Band B'
WHEN salary >= 70000 THEN 'Band C'
ELSE 'Not set'
END AS salary_band,
COUNT(*) AS employees
FROM employees
GROUP BY salary_band
ORDER BY salary_band;
Result: 4 rows
| salary_band | employees |
|---|---|
| Band A | 3 |
| Band B | 3 |
| Band C | 3 |
| Not set | 1 |
0–39999 and 40000–69999 touch without a gap. With decimals, a salary of 39999.50 would fall between them; use salary < 40000 style limits in that case.With HAVING: departments whose average salary is between 40,000 and 70,000:
SELECT dept, ROUND(AVG(salary)) AS avg_salary
FROM employees
GROUP BY dept
HAVING AVG(salary) BETWEEN 40000 AND 70000
ORDER BY dept;
Result: 2 rows
| dept | avg_salary |
|---|---|
| HR | 40500 |
| Marketing | 50000 |
With UPDATE: give a 10% raise to everyone who joined from 2015 to 2018:
UPDATE employees
SET salary = salary * 1.10
WHERE joined_on BETWEEN '2015-01-01' AND '2018-12-31';
Updated rows: 3
| name | joined_on | salary |
|---|---|---|
| Divya | 2018-11-23 | 79200.00 |
| Farhan | 2016-05-30 | 89100.00 |
| Nikhil | 2015-01-02 | 104500.00 |
With DELETE: remove all orders placed on 1 March (a half-open range, because ordered_at is a DATETIME):
DELETE FROM orders
WHERE ordered_at >= '2026-03-01'
AND ordered_at < '2026-03-02'; -- deletes orders 1 and 2
UPDATE or DELETE with a range, run the same WHERE clause in a SELECT first, so you can check exactly which rows will change.12. BETWEEN vs Other Operators
| Condition | Meaning | Best for |
|---|---|---|
x BETWEEN 10 AND 20 | 10 ≤ x ≤ 20, both ends included | Numbers, DATE columns, readable ranges |
x >= 10 AND x <= 20 | Exactly the same as BETWEEN | When you prefer explicit comparisons |
x >= 10 AND x < 20 | Start included, end excluded | DATETIME/TIMESTAMP ranges, decimals, ranges that must not overlap |
x NOT BETWEEN 10 AND 20 | x < 10 OR x > 20 | Finding values outside a normal range |
x IN (10, 15, 20) | x equals one of the listed values | A short list of exact values, not a range |
x LIKE 'K%' | Text starting with K | Text patterns rather than alphabetical ranges |
BETWEEN and the >=/<= pair run at the same speed; MySQL treats them the same way. Choose whichever is easier to read.
13. BETWEEN and Indexes
An index on the column lets MySQL jump straight to the start of the range and read only the matching rows, instead of scanning the whole table. Add an index on joined_on and check the plan with EXPLAIN:
CREATE INDEX idx_joined_on ON employees (joined_on);
EXPLAIN SELECT name
FROM employees
WHERE joined_on BETWEEN '2020-01-01' AND '2022-12-31';
EXPLAIN result (main columns)
| type | possible_keys | key | rows | Extra |
|---|---|---|---|---|
| range | idx_joined_on | idx_joined_on | 3 | Using index condition |
type = range means MySQL used the index to read only the 3 rows in the range. Now wrap the column in a function:
EXPLAIN SELECT name
FROM employees
WHERE YEAR(joined_on) BETWEEN 2020 AND 2022;
EXPLAIN result (main columns)
| type | possible_keys | key | rows | Extra |
|---|---|---|---|---|
| ALL | NULL | NULL | 10 | Using where |
type = ALL is a full table scan: MySQL must calculate YEAR() for every row, so the index cannot help. Keep the column bare and put the calculation on the limits instead, as in the first query.
range and ALL can be milliseconds versus many seconds.14. Using BETWEEN from Java (JDBC)
Range limits usually come from users, so pass them as parameters with a PreparedStatement. Never join user input into the SQL text; that opens the door to SQL injection. This program finds employees with a salary from 40,000 to 80,000 who joined from 2018 to 2022:
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.Date;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
public class EmployeeRangeSearch {
private static final String URL = "jdbc:mysql://localhost:3306/company";
public static void main(String[] args) throws SQLException {
String sql = "SELECT name, salary, joined_on FROM employees "
+ "WHERE salary BETWEEN ? AND ? "
+ "AND joined_on BETWEEN ? AND ? "
+ "ORDER BY salary";
try (Connection con = DriverManager.getConnection(URL, "root", "your-password");
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setBigDecimal(1, new BigDecimal("40000")); // lowest salary
ps.setBigDecimal(2, new BigDecimal("80000")); // highest salary
ps.setDate(3, Date.valueOf(LocalDate.of(2018, 1, 1))); // joined from
ps.setDate(4, Date.valueOf(LocalDate.of(2022, 12, 31))); // joined until
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.printf("%-8s %10s %s%n",
rs.getString("name"),
rs.getBigDecimal("salary"),
rs.getDate("joined_on"));
}
}
}
}
}
Output:
Bhavna 45000.00 2019-07-01
Kiran 50000.00 2020-09-05
Chetan 58000.00 2022-01-10
Divya 72000.00 2018-11-23
To run it, add the MySQL JDBC driver (com.mysql:mysql-connector-j) to your project and change the URL, user and password to match your server.
15. Common Mistakes
- Forgetting the time part.
BETWEEN '2026-03-01' AND '2026-03-03'on aDATETIMEcolumn misses most of 3 March. Use>= '2026-03-01' AND < '2026-03-04'. - Putting the bigger value first.
BETWEEN 40 AND 30returns no rows and gives no error. - Expecting the limits to be excluded.
BETWEENalways includes both ends. Use>and<if you need to exclude them. - Overlapping ranges.
BETWEEN 0 AND 100andBETWEEN 100 AND 200both include 100, so a value of 100 is counted twice. Use half-open ranges for buckets. - Forgetting NULL. Rows with
NULLappear in neitherBETWEENnorNOT BETWEENresults. AddOR column IS NULLwhen you need them. - Text ranges that stop too early.
BETWEEN 'A' AND 'M'leaves out Meera, because'Meera'sorts after'M'. - Wrong date format. Use
'YYYY-MM-DD'. A value like'12/31/2022'is not a valid MySQL date: the query returns no rows and MySQL only shows a warning, not an error. - Functions on the column.
YEAR(col) BETWEEN ...orDATE(col) BETWEEN ...prevents index use. Compare the bare column with calculated limits.
16. Practice Exercises
Try these on the sample tables first, then open the solution to check your answer.
Exercise 1: List employees aged 25 to 30, youngest first.
SELECT name, age
FROM employees
WHERE age BETWEEN 25 AND 30
ORDER BY age;
Result: 4 rows
| name | age |
|---|---|
| Pooja | 26 |
| Gauri | 27 |
| Chetan | 29 |
| Kiran | 30 |
Exercise 2: Find orders whose amount is NOT between 1,000 and 3,000, smallest first.
SELECT order_id, customer, amount
FROM orders
WHERE amount NOT BETWEEN 1000 AND 3000
ORDER BY amount;
Result: 5 rows
| order_id | customer | amount |
|---|---|---|
| 2 | Sneha | 450.50 |
| 4 | Usha | 799.00 |
| 6 | Ravi | 999.00 |
| 3 | Tarun | 3299.00 |
| 7 | Sneha | 5600.00 |
Exercise 3: Which employees joined in the years 2019, 2020 or 2021?
SELECT name, joined_on
FROM employees
WHERE joined_on BETWEEN '2019-01-01' AND '2021-12-31'
ORDER BY joined_on;
Result: 3 rows
| name | joined_on |
|---|---|
| Bhavna | 2019-07-01 |
| Kiran | 2020-09-05 |
| Aarav | 2021-03-15 |
Exercise 4: How many orders were placed on 3 March 2026, and what is their total amount?
-- ordered_at is a DATETIME, so use a half-open range
SELECT COUNT(*) AS orders, SUM(amount) AS total
FROM orders
WHERE ordered_at >= '2026-03-03'
AND ordered_at < '2026-03-04';
Result: 1 row
| orders | total |
|---|---|
| 3 | 3948.75 |
17. Summary
BETWEEN low AND highkeeps values fromlowtohigh, including both ends. It is the same as>= low AND <= high.NOT BETWEENkeeps values belowlowor abovehigh.- Always write the smaller value first; a reversed range returns no rows.
- It works for
DATEcolumns as is. ForDATETIMEandTIMESTAMP, prefer>=startAND <next day. - Text uses dictionary order, so
'Kiran'is after'K'. NULLvalues are never returned byBETWEENorNOT BETWEEN.- Keep the column free of functions so an index can serve the range, and pass the limits as parameters from Java.