How do I perform calculations in a SELECT statement?

A SELECT statement is not limited to returning columns exactly as they are stored. You can compute new values on the fly — the total price of an order line, a discount, a tax amount, the age of a record, or the concatenation of a first and last name. These calculations happen inside the database and are returned as extra columns in your result set.

In this tutorial you will learn how to add computed columns to a SELECT statement using arithmetic operators, expressions on multiple columns, built-in functions, and aliases. You will also see how NULL affects the math, and which small differences to watch for between database systems.

Prerequisites

To follow along, you need:

  • A working SQL database (PostgreSQL, MySQL, MariaDB, SQL Server, Oracle Database, or SQLite).
  • A client that can execute SQL statements.
  • Basic familiarity with the SELECT and WHERE clauses.

If you already followed the previous tutorials in this series, you can reuse the same sample tables.

Sample Database

We continue with the online bookstore schema used across the series so the examples stay consistent.

CREATE TABLE authors
(
    author_id   INTEGER PRIMARY KEY,
    author_name VARCHAR(100) NOT NULL,
    country     VARCHAR(50)
);

CREATE TABLE books
(
    book_id      INTEGER PRIMARY KEY,
    title        VARCHAR(200)  NOT NULL,
    author_id    INTEGER       NOT NULL,
    category     VARCHAR(50),
    price        DECIMAL(8, 2) NOT NULL,
    stock        INTEGER       NOT NULL,
    published_on DATE,
    CONSTRAINT fk_books_author
        FOREIGN KEY (author_id) REFERENCES authors (author_id)
);

INSERT INTO authors (author_id, author_name, country)
VALUES (1, 'Jane Austen', 'United Kingdom'),
       (2, 'Haruki Murakami', 'Japan'),
       (3, 'Chimamanda Ngozi Adichie', 'Nigeria'),
       (4, 'Gabriel Garcia Marquez', 'Colombia');

INSERT INTO books (book_id, title, author_id, category, price, stock,
                   published_on)
VALUES (1, 'Pride and Prejudice', 1, 'Classic', 12.50, 20, '1813-01-28'),
       (2, 'Emma', 1, 'Classic', 10.00, 0, '1815-12-23'),
       (3, 'Norwegian Wood', 2, 'Fiction', 15.75, 12, '1987-09-04'),
       (4, 'Kafka on the Shore', 2, 'Fiction', 18.20, 5, '2002-09-12'),
       (5, 'Half of a Yellow Sun', 3, 'Historical', 16.00, 8, '2006-08-11'),
       (6, 'Americanah', 3, 'Contemporary', 14.50, 3, '2013-05-14'),
       (7, 'One Hundred Years of Solitude', 4, 'Classic', 22.00, 25,
        '1967-05-30'),
       (8, 'Love in the Time of Cholera', 4, 'Classic', 19.99, 0, '1985-09-05'),
       (9, 'Unknown Title', 2, NULL, 13.00, 4, NULL);

Row 9 intentionally has NULL in category and published_on. We will use it to see how missing values behave in calculations.

Basic Syntax

Any expression that returns a value can appear in the SELECT list, not just a column name. You can combine literal values, columns, arithmetic operators, and function calls, and you can give the result a name with an alias.

SELECT
    column_name,
    expression        AS alias_name,
    function(column)  AS alias_name
FROM table_name
WHERE condition;
  • expression — anything the database can evaluate, such as price * stock or UPPER(title).
  • AS alias_name — a readable label for the computed column. The AS keyword is optional in most databases but recommended for clarity.
  • The calculation is executed per row, using values from that row.

Arithmetic Operators

SQL supports the standard arithmetic operators:

Operator Meaning Example
+ Addition price + 1.00
- Subtraction stock - 1
* Multiplication price * stock
/ Division price / 2
% Modulo (remainder), most engines stock % 2

The modulo operator % is supported by PostgreSQL, MySQL, MariaDB, SQL Server, and SQLite. Oracle Database uses the MOD(x, y) function instead. MOD(x, y) is portable and works on every engine listed above.

Practical Example

The bookstore manager wants a report of the total value of each book’s stock — that is, price × stock for every row.

SELECT
    book_id,
    title,
    price,
    stock,
    price * stock AS inventory_value
FROM books
ORDER BY inventory_value DESC;

Reading the query in logical order:

  1. FROM books — start with every row in books.
  2. SELECT ... — for each row, compute price * stock and label it inventory_value.
  3. ORDER BY inventory_value DESC — sort so that the most valuable inventory appears first.

Expected Result

book_id title price stock inventory_value
7 One Hundred Years of Solitude 22.00 25 550.00
1 Pride and Prejudice 12.50 20 250.00
3 Norwegian Wood 15.75 12 189.00
5 Half of a Yellow Sun 16.00 8 128.00
4 Kafka on the Shore 18.20 5 91.00
9 Unknown Title 13.00 4 52.00
6 Americanah 14.50 3 43.50
2 Emma 10.00 0 0.00
8 Love in the Time of Cholera 19.99 0 0.00

The calculation is performed row by row; nothing is aggregated across rows yet. That is a topic for a later tutorial on GROUP BY.

Additional Examples

1. Applying a Discount

Show each book with a 10% discount applied to its price.

SELECT
    title,
    price                 AS original_price,
    price * 0.90          AS discounted_price
FROM books
ORDER BY title;

The literal 0.90 is a numeric value, and the multiplication returns a numeric result. If you prefer to express the discount as a subtraction, price - (price * 0.10) produces the same value.

2. Rounding a Computed Value

Computed columns often have more decimal places than you want to display. Use ROUND(expression, digits) to control precision.

SELECT
    title,
    price,
    ROUND(price * 0.90, 2) AS discounted_price
FROM books
ORDER BY discounted_price DESC;

ROUND is available in every major database, though the exact rounding rules (half-up vs. banker’s rounding) can differ slightly. Always check the documentation if the last digit matters for financial reporting.

3. Building a Derived Column from Multiple Columns

The following query builds a compact line item description by concatenating text and formatting the price.

-- PostgreSQL, Oracle Database, SQLite
SELECT
    title || ' - $' || price AS line_item
FROM books
ORDER BY title;

|| is the ANSI string-concatenation operator. It is supported by PostgreSQL, Oracle Database, and SQLite. It is also supported by MySQL and MariaDB when PIPES_AS_CONCAT SQL mode is enabled, but it is not the default.

Portable alternative that works across engines:

-- Portable across PostgreSQL, MySQL, MariaDB, SQL Server, Oracle Database, SQLite
SELECT
    CONCAT(title, ' - $', price) AS line_item
FROM books
ORDER BY title;

SQL Server uses + for string concatenation, but CONCAT is preferred because it handles NULL values without turning the whole result into NULL.

4. Integer Division and Numeric Types

Division behaves differently for integer and decimal operands. In PostgreSQL, Oracle Database, and SQL Server, dividing two integers truncates the fractional part:

SELECT 7 / 2 AS integer_division;

Result: 3 on PostgreSQL, SQL Server, and Oracle Database. MySQL, MariaDB, and SQLite return 3.5 because they promote the result to a floating-point value.

To make the intent explicit and portable, cast one operand to a numeric type:

SELECT
    stock,
    CAST(stock AS DECIMAL(10, 2)) / 2 AS half_stock
FROM books
WHERE stock > 0;

5. Using Built-in Functions

Databases provide a rich set of functions. A few commonly used ones in a SELECT list:

SELECT
    UPPER(title)         AS title_upper,
    LOWER(category)      AS category_lower,
    LENGTH(title)        AS title_length,
    ABS(stock - 10)      AS distance_from_ten,
    ROUND(price, 0)      AS price_rounded
FROM books
ORDER BY title;
  • UPPER / LOWER change case.
  • LENGTH returns the number of characters (in most databases; SQL Server uses LEN).
  • ABS returns the absolute value.
  • ROUND rounds a numeric value.

Each database ships its own set of scalar functions; consult your documentation for the full list.

6. Date Arithmetic

You can also compute values from date columns. The exact syntax depends on the database.

-- PostgreSQL: current date minus stored date returns an INTERVAL
SELECT
    title,
    published_on,
    CURRENT_DATE - published_on AS days_in_print
FROM books
WHERE published_on IS NOT NULL
ORDER BY days_in_print DESC;
-- MySQL / MariaDB
SELECT
    title,
    published_on,
    DATEDIFF(CURRENT_DATE, published_on) AS days_in_print
FROM books
WHERE published_on IS NOT NULL
ORDER BY days_in_print DESC;
-- SQL Server
SELECT
    title,
    published_on,
    DATEDIFF(DAY, published_on, CAST(GETDATE() AS DATE)) AS days_in_print
FROM books
WHERE published_on IS NOT NULL
ORDER BY days_in_print DESC;

Date functions vary substantially between databases. When portability matters, isolate them behind a helper view or in the application layer.

7. Using a Calculated Column in ORDER BY

Most databases accept an alias defined in the SELECT list inside the ORDER BY clause, because ORDER BY is logically evaluated after SELECT.

SELECT
    title,
    price,
    stock,
    price * stock AS inventory_value
FROM books
ORDER BY inventory_value DESC;

You can also repeat the expression instead of the alias — that always works, at the cost of some duplication.

8. Calculations in WHERE

You can filter by a computed value, but the expression must appear in the WHERE clause, not the alias, because WHERE is evaluated before SELECT.

Correct:

SELECT
    title,
    price * stock AS inventory_value
FROM books
WHERE price * stock > 100
ORDER BY inventory_value DESC;

Incorrect on most databases:

SELECT
    title,
    price * stock AS inventory_value
FROM books
WHERE inventory_value > 100;   -- error: alias not visible in WHERE

9. Calculations in Application Code

When your application reads a computed column, treat it the same as any other value. Use a PreparedStatement and parameters for any user-supplied inputs.

String sql = """
        SELECT
            book_id,
            title,
            price,
            stock,
            price * stock AS inventory_value
        FROM books
        WHERE category = ?
        ORDER BY inventory_value DESC
        """;

try (PreparedStatement statement = connection.prepareStatement(sql)) {
    statement.setString(1, category);

    try (ResultSet resultSet = statement.executeQuery()) {
        while (resultSet.next()) {
            long bookId = resultSet.getLong("book_id");
            String title = resultSet.getString("title");
            BigDecimal price = resultSet.getBigDecimal("price");
            int stock = resultSet.getInt("stock");
            BigDecimal inventoryValue = resultSet.getBigDecimal("inventory_value");
            // process the row...
        }
    }
}

Use BigDecimal for monetary values to avoid the precision issues of binary floating point.

Common Mistakes

Forgetting That NULL Propagates Through Arithmetic

Any arithmetic expression that involves NULL returns NULL. If a nullable column participates in a calculation, expect NULL outputs.

Incorrect assumption:

SELECT
    title,
    price + NULL AS adjusted_price
FROM books;

Every row returns NULL for adjusted_price. To provide a default value, use COALESCE:

SELECT
    title,
    price + COALESCE(discount, 0) AS adjusted_price
FROM books;

COALESCE(expr1, expr2, ...) returns the first non-NULL argument and is supported by every major database.

Dividing by Zero

Dividing by zero raises an error in most databases (PostgreSQL, MySQL in strict mode, Oracle Database, SQL Server). Guard against it with a CASE expression or NULLIF.

SELECT
    title,
    price,
    stock,
    CASE WHEN stock = 0 THEN NULL
         ELSE price / stock
    END AS price_per_unit
FROM books;

Or, more compactly:

SELECT
    title,
    price / NULLIF(stock, 0) AS price_per_unit
FROM books;

NULLIF(a, b) returns NULL when a = b, and returns a otherwise. The result of the division becomes NULL instead of an error.

Referring to an Alias in WHERE

As shown earlier, aliases defined in the SELECT list are usually not visible in WHERE. Repeat the expression or wrap the query in a subquery / common table expression if the calculation is complex.

Assuming Integer Division Behaves Like Decimal Division

7 / 2 may return 3 on some databases and 3.5 on others. When the fractional part matters, cast at least one operand to a decimal or floating-point type.

Losing Precision with Floating-Point Types

FLOAT and DOUBLE PRECISION are approximate types. Use DECIMAL / NUMERIC for money, invoice totals, tax rates, and anything else where exact arithmetic is required.

Database Compatibility

Basic arithmetic (+, -, *, /), most standard scalar functions (ROUND, ABS, UPPER, LOWER, COALESCE, NULLIF, CAST), and column aliases are part of ANSI SQL and are supported by:

  • PostgreSQL
  • MySQL
  • MariaDB
  • SQL Server
  • Oracle Database
  • SQLite

Notable differences to be aware of:

  • String concatenation. || in PostgreSQL, Oracle Database, and SQLite. + in SQL Server. CONCAT(...) works everywhere.
  • Modulo. % works in PostgreSQL, MySQL, MariaDB, SQL Server, SQLite. Oracle Database uses MOD(x, y). MOD(x, y) is portable.
  • Integer division. PostgreSQL, Oracle Database, and SQL Server truncate on integer operands. MySQL, MariaDB, and SQLite return a floating-point result. Cast explicitly for portability.
  • String length. LENGTH on PostgreSQL, MySQL, MariaDB, SQLite, and Oracle Database. LEN on SQL Server. Oracle Database also has LENGTHB for byte length.
  • Date arithmetic. Every engine uses different function names (DATEDIFF, DATE_ADD, AGE, INTERVAL, etc.). Consult the documentation for your database and version.
  • Rounding rules. Different engines may implement half-up, half-even, or truncation for ROUND. Verify with your own test cases before using it for financial output.

When in doubt, consult the documentation for your database and version.

Best Practices

  • Always give computed columns an alias. Without one, the column name in the result set is engine-defined and unstable.
  • Use DECIMAL / NUMERIC for money. Binary floating-point types introduce rounding errors that are unacceptable in financial calculations.
  • Guard against division by zero with NULLIF or CASE.
  • Handle NULL explicitly with COALESCE when a nullable column participates in arithmetic.
  • Prefer portable functions (CONCAT, COALESCE, CAST, MOD) over engine-specific operators when the query may run on multiple databases.
  • Repeat expressions instead of relying on aliases in WHERE. Alternatively, use a common table expression or subquery so the calculation appears only once.
  • Use parameterized queries when constants in the calculation come from application input.
  • Avoid computing values in the application when the database can do it. The database can often use indexes and streaming, and less data is sent over the network.

Conclusion

You learned how to perform calculations in a SQL SELECT statement using arithmetic operators, string and date expressions, built-in functions, and aliases. You saw how NULL and division by zero require care, and how the same calculation can behave differently across database engines. Always use DECIMAL for money, guard against NULL and zero, and give every computed column a clear alias. Next, learn how to summarize rows with aggregate functions such as COUNT, SUM, AVG, MIN, and MAX.

How do I rename columns and tables using SQL aliases?

When you write SQL queries, the column names in your result often come straight from the table definition. Names such as unit_price or order_date are useful in the database, but they may look technical in a report, or they may collide when you join two tables that both contain a column called id. SQL solves this with aliases: temporary names that you assign to columns or tables for the duration of a single query.

In this tutorial, you will learn what an alias is, how to create one with the AS keyword, how aliases make joins easier to read, and which pitfalls to avoid. By the end, you will be able to produce cleaner result sets and shorter, more maintainable queries.

Prerequisites

To follow along, you need:

  • A running database such as PostgreSQL, MySQL, MariaDB, SQL Server, Oracle Database, or SQLite.
  • A SQL client or command-line tool to execute statements.
  • Basic familiarity with SELECT and FROM.

If you have not created a practice database yet, any empty schema will do. The setup script below creates everything else.

Sample Database

The examples use a small online bookstore with two tables: authors and books. Each book is written by one author, so books.author_id refers to authors.author_id.

CREATE TABLE authors (
    author_id   INTEGER PRIMARY KEY,
    first_name  VARCHAR(50) NOT NULL,
    last_name   VARCHAR(50) NOT NULL,
    country     VARCHAR(50)
);

CREATE TABLE books (
    book_id      INTEGER PRIMARY KEY,
    title        VARCHAR(200) NOT NULL,
    author_id    INTEGER NOT NULL,
    unit_price   DECIMAL(8, 2) NOT NULL,
    published_on DATE,
    CONSTRAINT fk_books_author
        FOREIGN KEY (author_id) REFERENCES authors (author_id)
);

INSERT INTO authors (author_id, first_name, last_name, country) VALUES
    (1, 'Joshua',  'Bloch',     'USA'),
    (2, 'Martin',  'Fowler',    'UK'),
    (3, 'Robert',  'Martin',    'USA');

INSERT INTO books (book_id, title, author_id, unit_price, published_on) VALUES
    (101, 'Effective Java',        1, 45.00, DATE '2018-01-06'),
    (102, 'Refactoring',           2, 50.00, DATE '2018-11-20'),
    (103, 'Clean Code',            3, 40.00, DATE '2008-08-01'),
    (104, 'Clean Architecture',    3, 42.00, DATE '2017-09-10');

Note: DATE '2018-01-06' is ANSI-standard syntax. If your database does not accept it, use a plain string literal such as '2018-01-06'.

Basic Syntax

There are two kinds of aliases: column aliases and table aliases.

Column alias:

SELECT column_name AS alias_name
FROM table_name;

Table alias:

SELECT alias_name.column_name
FROM table_name AS alias_name;

The AS keyword is optional in most databases; first_name AS given_name and first_name given_name mean the same thing. Writing AS explicitly is recommended because it is easier to read and harder to confuse with a missing comma.

Practical Example: Renaming Columns

Suppose you want a report with a friendlier heading than first_name and last_name.

SELECT
    first_name AS given_name,
    last_name  AS family_name,
    country    AS home_country
FROM authors;

What this query does:

  1. FROM authors selects rows from the authors table.
  2. SELECT picks three columns and renames them for the result set.
  3. The database returns rows using the new headings.

Expected Result

given_name family_name home_country
Joshua Bloch USA
Martin Fowler UK
Robert Martin USA

The underlying column names in the authors table are unchanged. An alias exists only for the current query.

Practical Example: Aliasing Computed Columns

Aliases are especially useful for expressions, because computed columns otherwise get awkward, database-generated names.

SELECT
    title,
    unit_price,
    unit_price * 0.9 AS discounted_price
FROM books;

The expression unit_price * 0.9 has no natural name. The alias discounted_price gives the result column a clear meaning.

Expected Result

title unit_price discounted_price
Effective Java 45.00 40.50
Refactoring 50.00 45.00
Clean Code 40.00 36.00
Clean Architecture 42.00 37.80

Practical Example: Table Aliases in Joins

Table aliases become essential when a query references the same column name in more than one table, or when the table names are long.

SELECT
    b.title,
    b.unit_price,
    a.first_name AS author_first_name,
    a.last_name  AS author_last_name
FROM books   AS b
INNER JOIN authors AS a
    ON a.author_id = b.author_id
ORDER BY
    b.title;

What this query does:

  1. FROM books AS b gives the books table the short alias b.
  2. INNER JOIN authors AS a joins authors using the alias a.
  3. ON a.author_id = b.author_id matches each book with its author.
  4. The SELECT list uses qualified names such as b.title so the database knows which table each column belongs to.
  5. ORDER BY b.title sorts the final result by book title.

Expected Result

title unit_price author_first_name author_last_name
Clean Architecture 42.00 Robert Martin
Clean Code 40.00 Robert Martin
Effective Java 45.00 Joshua Bloch
Refactoring 50.00 Martin Fowler

Without aliases, you would repeat books. and authors. everywhere, making the query harder to scan.

Practical Example: Aliases with Spaces or Mixed Case

If you want the alias to contain spaces or preserve capitalization, you must quote it. Standard SQL uses double quotes; SQL Server and Sybase also accept square brackets; MySQL and MariaDB additionally accept backticks.

SELECT
    first_name AS "Given Name",
    last_name  AS "Family Name"
FROM authors;

Use this feature sparingly. Quoted aliases with spaces are useful for reports but inconvenient for application code that must reference the column by name.

Common Mistakes

Using a Column Alias in the WHERE Clause

Column aliases are assigned in the SELECT list, which is processed after WHERE. Referring to an alias in WHERE therefore fails in most databases.

Incorrect:

SELECT
    unit_price * 0.9 AS discounted_price
FROM books
WHERE discounted_price < 40;

Correct:

SELECT
    unit_price * 0.9 AS discounted_price
FROM books
WHERE unit_price * 0.9 < 40;

You can, however, reference a column alias in ORDER BY, because sorting happens after the SELECT list is evaluated.

Forgetting to Qualify Columns After Adding a Table Alias

Once you assign a table alias, some databases require you to use the alias instead of the original table name for the rest of the query.

Incorrect:

SELECT books.title
FROM books AS b;

Correct:

SELECT b.title
FROM books AS b;

Forgetting the Comma Between Columns

A missing comma turns the next column name into an alias.

SELECT
    first_name
    last_name
FROM authors;

This query returns a single column, first_name, aliased as last_name. Always double-check commas.

Database Compatibility

Feature PostgreSQL MySQL MariaDB SQL Server Oracle Database SQLite
AS keyword for column aliases Yes Yes Yes Yes Yes Yes
AS keyword for table aliases Yes Yes Yes Yes No (must omit AS) Yes
Double-quoted aliases Yes Yes (in ANSI mode) Yes (in ANSI mode) Yes Yes Yes
Square-bracket aliases No No No Yes No Yes
Backtick aliases No Yes Yes No No Yes

Oracle Database is the notable exception: it does not accept AS in front of a table alias. Write FROM books b, not FROM books AS b.

Best Practices

  • Prefer descriptive aliases such as order_count over cryptic ones such as oc.
  • Keep table aliases short but meaningful. c for customers and o for orders are common conventions.
  • Always write AS for column aliases; it improves readability.
  • Qualify every column with its table alias in multi-table queries, even when the column name is unambiguous today. Future schema changes may introduce a conflict.
  • Avoid aliases that shadow existing column or table names.
  • Do not depend on aliases to hide poor column names. If a column is consistently misnamed, consider renaming it in the schema instead.

Conclusion

You learned how to use SQL aliases to rename columns and tables inside a query. Column aliases give result sets readable headings and label computed expressions; table aliases keep join queries short and unambiguous. Remember that aliases exist only for the duration of the query and that column aliases cannot be used in WHERE.

How do I filter values using IN, BETWEEN, and NOT in SQL?

When you filter rows in SQL, you often need to match a value against a list of options, check whether it falls within a range, or exclude rows that satisfy a condition. Chaining many AND/OR comparisons for these cases quickly becomes hard to read. SQL provides three operators — IN, BETWEEN, and NOT — that make these filters expressive and concise.

In this tutorial you will learn how to use IN to match a value against a list, BETWEEN to match a value within an inclusive range, and NOT to negate any condition. You will also see how each one interacts with NULL and where readers commonly get tripped up.

Prerequisites

To follow along you need:

  • A working SQL database (PostgreSQL, MySQL, MariaDB, SQL Server, Oracle Database, or SQLite).
  • A client that can execute SQL statements.
  • Basic familiarity with SELECT, WHERE, and comparison/logical operators.

If you followed the previous tutorials in this series, you can reuse the same sample tables.

Sample Database

We continue with the online bookstore schema used earlier in the series.

CREATE TABLE authors (
    author_id   INTEGER      PRIMARY KEY,
    author_name VARCHAR(100) NOT NULL,
    country     VARCHAR(50)
);

CREATE TABLE books (
    book_id      INTEGER       PRIMARY KEY,
    title        VARCHAR(200)  NOT NULL,
    author_id    INTEGER       NOT NULL,
    category     VARCHAR(50),
    price        DECIMAL(8, 2) NOT NULL,
    stock        INTEGER       NOT NULL,
    published_on DATE,
    CONSTRAINT fk_books_author
        FOREIGN KEY (author_id) REFERENCES authors (author_id)
);

INSERT INTO authors (author_id, author_name, country) VALUES
    (1, 'Jane Austen',              'United Kingdom'),
    (2, 'Haruki Murakami',          'Japan'),
    (3, 'Chimamanda Ngozi Adichie', 'Nigeria'),
    (4, 'Gabriel Garcia Marquez',   'Colombia');

INSERT INTO books (book_id, title, author_id, category, price, stock, published_on) VALUES
    (1, 'Pride and Prejudice',           1, 'Classic',      12.50, 20, '1813-01-28'),
    (2, 'Emma',                          1, 'Classic',      10.00,  0, '1815-12-23'),
    (3, 'Norwegian Wood',                2, 'Fiction',      15.75, 12, '1987-09-04'),
    (4, 'Kafka on the Shore',            2, 'Fiction',      18.20,  5, '2002-09-12'),
    (5, 'Half of a Yellow Sun',          3, 'Historical',   16.00,  8, '2006-08-11'),
    (6, 'Americanah',                    3, 'Contemporary', 14.50,  3, '2013-05-14'),
    (7, 'One Hundred Years of Solitude', 4, 'Classic',      22.00, 25, '1967-05-30'),
    (8, 'Love in the Time of Cholera',   4, 'Classic',      19.99,  0, '1985-09-05'),
    (9, 'Unknown Title',                 2, NULL,           13.00,  4, NULL);

Row 9 intentionally uses NULL for category and published_on. We will use it to explore how IN, BETWEEN, and NOT handle missing values.

Basic Syntax

All three operators are used inside a boolean expression, most often in a WHERE clause.

SELECT column_list
FROM table_name
WHERE column_name IN (value1, value2, ...);

SELECT column_list
FROM table_name
WHERE column_name BETWEEN low_value AND high_value;

SELECT column_list
FROM table_name
WHERE NOT condition;
  • IN returns true when the column value equals any value in the list.
  • BETWEEN low AND high returns true when the column value is greater than or equal to low and less than or equal to high — both endpoints are included.
  • NOT inverts the boolean result of the expression that follows it.

Each of them can be combined with NOT: NOT IN, NOT BETWEEN, and IS NOT NULL.

Filtering with IN

IN is a readable shortcut for a chain of equality checks joined by OR.

Practical Example

“Show every book whose category is Classic, Fiction, or Historical.”

SELECT
    title,
    category
FROM books
WHERE category IN ('Classic', 'Fiction', 'Historical')
ORDER BY category, title;

Reading the query in logical order:

  1. FROM books — start with every row in the books table.
  2. WHERE category IN (...) — keep only rows whose category matches one of the listed values.
  3. SELECT title, category — return these two columns.
  4. ORDER BY category, title — sort the final result.

Expected Result

title category
Emma Classic
Love in the Time of Cholera Classic
One Hundred Years of Solitude Classic
Pride and Prejudice Classic
Kafka on the Shore Fiction
Norwegian Wood Fiction
Half of a Yellow Sun Historical

The row with category IS NULL is excluded, because NULL does not equal any listed value.

Equivalent Form with OR

The query above is equivalent to:

SELECT
    title,
    category
FROM books
WHERE category = 'Classic'
   OR category = 'Fiction'
   OR category = 'Historical';

IN communicates intent more clearly and is easier to maintain when the list changes.

Filtering with BETWEEN

BETWEEN matches values inside an inclusive range. It works with numbers, dates, and strings that share a comparable type.

Practical Example: Numeric Range

“Show every book priced between 12.00 and 16.00, inclusive.”

SELECT
    title,
    price
FROM books
WHERE price BETWEEN 12.00 AND 16.00
ORDER BY price;

Expected Result

title price
Pride and Prejudice 12.50
Unknown Title 13.00
Americanah 14.50
Norwegian Wood 15.75
Half of a Yellow Sun 16.00

Both 12.00 and 16.00 would be included if they matched a row exactly. BETWEEN low AND high is equivalent to column >= low AND column <= high.

Practical Example: Date Range

BETWEEN also works with dates. Use the ANSI-standard DATE '...' literal for portability.

SELECT
    title,
    published_on
FROM books
WHERE published_on BETWEEN DATE '1900-01-01' AND DATE '1999-12-31'
ORDER BY published_on;

This returns twentieth-century books. The row with published_on IS NULL is excluded because comparisons against NULL yield unknown.

Watch the Order of Endpoints

BETWEEN requires the lower value first. If you reverse them, most databases silently return zero rows because no value can be both >= 16.00 and <= 12.00.

-- Returns no rows on standard-compliant databases.
SELECT title, price
FROM books
WHERE price BETWEEN 16.00 AND 12.00;

Negating with NOT

NOT inverts a boolean expression. It is most commonly used together with IN, BETWEEN, and LIKE, but it can negate any condition.

NOT IN

“Show every book whose category is neither Classic nor Fiction.”

SELECT
    title,
    category
FROM books
WHERE category NOT IN ('Classic', 'Fiction')
ORDER BY title;

Expected Result

title category
Americanah Contemporary
Half of a Yellow Sun Historical

Notice that Unknown Title (with category IS NULL) is not in the result. This is a classic pitfall — see the Common Mistakes section.

NOT BETWEEN

“Show every book priced outside the 12.00–16.00 range.”

SELECT
    title,
    price
FROM books
WHERE price NOT BETWEEN 12.00 AND 16.00
ORDER BY price;

Expected Result

title price
Emma 10.00
Kafka on the Shore 18.20
Love in the Time of Cholera 19.99
One Hundred Years of Solitude 22.00

NOT BETWEEN low AND high is equivalent to column < low OR column > high.

NOT with Other Conditions

You can apply NOT to any boolean expression, including a parenthesized combination:

SELECT
    title,
    category,
    stock
FROM books
WHERE NOT (category = 'Classic' AND stock = 0)
ORDER BY title;

“Show every book that is not an out-of-stock classic.”

Combining IN, BETWEEN, and NOT

These operators combine naturally with AND and OR to describe complex requirements.

“Show Classic or Fiction books, in stock, priced between 12.00 and 20.00.”

SELECT
    title,
    category,
    price,
    stock
FROM books
WHERE category IN ('Classic', 'Fiction')
  AND price BETWEEN 12.00 AND 20.00
  AND stock > 0
ORDER BY price;

Expected Result

title category price stock
Pride and Prejudice Classic 12.50 20
Norwegian Wood Fiction 15.75 12
Kafka on the Shore Fiction 18.20 5
Love in the Time of Cholera Classic 19.99 0

Wait — Love in the Time of Cholera has stock = 0 and should be excluded. Reading the actual result, only the first three rows appear:

title category price stock
Pride and Prejudice Classic 12.50 20
Norwegian Wood Fiction 15.75 12
Kafka on the Shore Fiction 18.20 5

Each clause plays a clear role:

  1. category IN ('Classic', 'Fiction') — restrict to two categories.
  2. price BETWEEN 12.00 AND 20.00 — apply the price range.
  3. stock > 0 — keep only books currently available.

Using These Operators in Application Code

When the values come from user input or a variable, always use parameterized queries.

String sql = """
        SELECT book_id, title, category, price
        FROM books
        WHERE category IN (?, ?, ?)
          AND price BETWEEN ? AND ?
        """;

try (PreparedStatement statement = connection.prepareStatement(sql)) {
    statement.setString(1, "Classic");
    statement.setString(2, "Fiction");
    statement.setString(3, "Historical");
    statement.setBigDecimal(4, new BigDecimal("12.00"));
    statement.setBigDecimal(5, new BigDecimal("20.00"));

    try (ResultSet resultSet = statement.executeQuery()) {
        while (resultSet.next()) {
            long bookId = resultSet.getLong("book_id");
            String title = resultSet.getString("title");
            String category = resultSet.getString("category");
            BigDecimal price = resultSet.getBigDecimal("price");
            // process the row...
        }
    }
}

Note that most JDBC drivers do not accept a Java collection directly for IN (...). You typically expand placeholders to match the list size or use database-specific features such as PostgreSQL’s = ANY(?) with an array parameter.

Common Mistakes

NOT IN with a List That Contains NULL

This is the most surprising pitfall in this tutorial. If any value in the NOT IN list is NULL, the whole condition evaluates to unknown for every row, so no rows are returned.

Incorrect:

SELECT title, category
FROM books
WHERE category NOT IN ('Classic', 'Fiction', NULL);

This query returns zero rows on every standard-compliant database. It is equivalent to:

WHERE category <> 'Classic'
  AND category <> 'Fiction'
  AND category <> NULL   -- always unknown

Because category <> NULL is always unknown, the entire AND chain never evaluates to true.

Correct — filter out NULL in the source or the list before applying NOT IN:

SELECT title, category
FROM books
WHERE category IS NOT NULL
  AND category NOT IN ('Classic', 'Fiction');

Forgetting That NOT IN Excludes NULL Rows Too

Even without NULL in the list, NOT IN excludes rows where the column itself is NULL, because NULL <> 'Classic' is unknown.

If you want those rows included, add an explicit check:

SELECT title, category
FROM books
WHERE category NOT IN ('Classic', 'Fiction')
   OR category IS NULL;

Reversing the BETWEEN Endpoints

BETWEEN 16.00 AND 12.00 is not the same as BETWEEN 12.00 AND 16.00. The lower value must come first, otherwise the range is empty.

Assuming BETWEEN Is Exclusive

BETWEEN is inclusive on both ends. If you need an exclusive upper bound (common with dates), write the comparison explicitly:

SELECT title, published_on
FROM books
WHERE published_on >= DATE '2000-01-01'
  AND published_on <  DATE '2010-01-01';

This is safer than BETWEEN DATE '2000-01-01' AND DATE '2009-12-31', which can behave differently when the column stores a timestamp with a time component.

Comparing to NULL with = or <>

NOT does not rescue NULL comparisons. Both column = NULL and NOT (column = NULL) evaluate to unknown. Always use IS NULL / IS NOT NULL for missing-value checks.

Database Compatibility

IN, BETWEEN, NOT, NOT IN, and NOT BETWEEN are part of ANSI SQL and work in:

  • PostgreSQL
  • MySQL
  • MariaDB
  • SQL Server
  • Oracle Database
  • SQLite

Notable considerations:

  • List length limits for IN. Oracle Database limits the IN list to 1000 expressions. Other systems allow more but may perform poorly with very long lists. For large sets, consider a subquery or a temporary table instead.
  • IN with a subquery. All major systems support WHERE column IN (SELECT ...). Be aware of NULL in the subquery result — the same NOT IN pitfall applies.
  • Date literals in BETWEEN. The DATE '2000-01-01' form is portable to PostgreSQL, MySQL, MariaDB, SQLite, and Oracle Database. SQL Server accepts the plain string '2000-01-01'.
  • Arrays and ANY/ALL. PostgreSQL supports = ANY(array) as a convenient alternative to IN when passing a single array parameter from application code.

When in doubt, consult the documentation for your database and version.

Best Practices

  • Prefer IN over long OR chains. It reads better and is easier to maintain.
  • Prefer BETWEEN for inclusive numeric ranges. For date/timestamp ranges, prefer explicit >= and < comparisons to avoid boundary bugs.
  • Handle NULL explicitly with NOT IN. Filter with IS NOT NULL first, or ensure your list never contains NULL.
  • Keep IN lists short. Very long lists reduce readability and may hit database-specific limits. Consider a subquery, a join, or a temporary table for large sets.
  • Parenthesize when combining with AND and OR. Even though IN, BETWEEN, and NOT bind clearly on their own, mixing them with OR can still cause precedence bugs.
  • Use parameters, not string concatenation. This prevents SQL injection and lets the database reuse the execution plan.
  • Verify destructive filters with SELECT first. Especially when using NOT IN or NOT BETWEEN, run a SELECT to confirm the target rows before any UPDATE or DELETE.

Warning: Running UPDATE or DELETE with NOT IN on a column that contains NULL values can produce unexpected results. Practice these statements only in a disposable learning database or after taking a verified backup.

Conclusion

You learned how to use IN to match a value against a list, BETWEEN to match an inclusive range, and NOT to negate any of these conditions. You also saw how each operator interacts with NULL — most importantly, why NOT IN combined with a NULL in the list returns no rows at all.

How do I search for patterns using LIKE and wildcards in SQL?

When you filter data with WHERE, exact comparisons such as = are not always enough. You often need to find rows where a text column starts with, ends with, or contains a certain fragment. For example, you may want every customer whose email ends with @gmail.com, or every book whose title begins with “The”.

SQL provides the LIKE operator together with wildcard characters to solve this problem. In this tutorial, you will learn how LIKE works, what the % and _ wildcards mean, how to escape special characters, and how different databases handle case sensitivity.

Prerequisites

To follow along, you need:

  • A working database (PostgreSQL, MySQL, MariaDB, SQL Server, Oracle Database, or SQLite).
  • Basic knowledge of the SELECT and WHERE clauses.
  • A client such as psql, MySQL Workbench, DBeaver, or the IntelliJ IDEA Database tool window.

If you are new to filtering rows, review the earlier tutorial How Do I Filter SQL Query Results Using WHERE? first.

Sample Database

We will continue with the online bookstore domain used earlier in this series. The examples use a single customers table so that pattern matching remains the focus.

CREATE TABLE customers (
    customer_id   INTEGER PRIMARY KEY,
    customer_name VARCHAR(100) NOT NULL,
    email         VARCHAR(150),
    city          VARCHAR(100),
    phone_number  VARCHAR(20)
);

INSERT INTO customers (customer_id, customer_name, email, city, phone_number) VALUES
    (1, 'Alice Johnson',   '[email protected]',       'London',    '+44-20-7946-0001'),
    (2, 'Bob Smith',       '[email protected]',   'Liverpool', '+44-20-7946-0002'),
    (3, 'Charlie Brown',   '[email protected]',     'Leeds',     '+44-20-7946-0003'),
    (4, 'Diana Prince',    '[email protected]',     'Manchester','+44-20-7946-0004'),
    (5, 'Ethan Hunt',      '[email protected]',     'Bristol',   NULL),
    (6, 'Fiona O''Neill',  '[email protected]',   'Dublin',    '+353-1-555-0006'),
    (7, 'George Miller',   NULL,                    'London',    '+44-20-7946-0007');

Basic Syntax

The general form of a pattern-matching query is:

SELECT column_list
FROM table_name
WHERE column_name LIKE 'pattern';

LIKE compares a text value to a pattern that may contain wildcard characters:

Wildcard Meaning
% Matches zero or more characters of any kind.
_ Matches exactly one character of any kind.

You can also use NOT LIKE to invert the match.

Practical Example

Suppose we want every customer whose email is hosted on Gmail:

SELECT
    customer_id,
    customer_name,
    email
FROM customers
WHERE email LIKE '%@gmail.com';

How to read this query:

  1. FROM customers supplies the rows.
  2. WHERE email LIKE '%@gmail.com' keeps only rows whose email ends with @gmail.com. The % matches any characters (including none) that appear before @gmail.com.
  3. The SELECT list returns three columns for each matching row.

Expected Result

customer_id customer_name email
1 Alice Johnson [email protected]
3 Charlie Brown [email protected]
5 Ethan Hunt [email protected]

Note that George Miller does not appear because his email is NULL. LIKE never matches NULL.

Additional Examples

Starts With

Find every customer whose name starts with the letter A:

SELECT customer_id, customer_name
FROM customers
WHERE customer_name LIKE 'A%';

Expected result:

customer_id customer_name
1 Alice Johnson

Ends With

Find every customer living in a city ending with pool:

SELECT customer_id, customer_name, city
FROM customers
WHERE city LIKE '%pool';

Expected result:

customer_id customer_name city
2 Bob Smith Liverpool

Contains

Find every customer whose name contains on:

SELECT customer_id, customer_name
FROM customers
WHERE customer_name LIKE '%on%';

Expected result:

customer_id customer_name
1 Alice Johnson
4 Diana Prince
6 Fiona O’Neill

Fixed-Length Match with _

The _ wildcard matches exactly one character. To find every UK phone number where the area code is three digits after +44- and starts with 20-79, and the next two digits can be anything:

SELECT customer_name, phone_number
FROM customers
WHERE phone_number LIKE '+44-20-79__-____';

Expected result:

customer_name phone_number
Alice Johnson +44-20-7946-0001
Bob Smith +44-20-7946-0002
Charlie Brown +44-20-7946-0003
Diana Prince +44-20-7946-0004
Ethan Hunt is skipped because his phone_number is NULL.

Negation with NOT LIKE

Find customers whose email is not a Gmail address (and is known):

SELECT customer_id, customer_name, email
FROM customers
WHERE email NOT LIKE '%@gmail.com'
  AND email IS NOT NULL;

Expected result:

customer_id customer_name email
2 Bob Smith [email protected]
4 Diana Prince [email protected]
6 Fiona O’Neill [email protected]

The extra IS NOT NULL is required because NOT LIKE still returns UNKNOWN for NULL values, and rows with UNKNOWN conditions are excluded from the result.

Escaping Wildcard Characters

What if the text you are searching for actually contains a % or _ character? Use the ESCAPE clause to define an escape character.

For example, to find emails that literally contain an underscore:

SELECT customer_id, email
FROM customers
WHERE email LIKE '%\_%' ESCAPE '\';

Expected result:

customer_id email
5 [email protected]

Any character can serve as the escape character; \ is a common choice.

Common Mistakes

Using = Instead of LIKE

Incorrect:

SELECT *
FROM customers
WHERE email = '%@gmail.com';

This looks for a literal string %@gmail.com and returns no rows. Wildcards work only with LIKE, not with =.

Correct:

SELECT *
FROM customers
WHERE email LIKE '%@gmail.com';

Forgetting That LIKE Ignores NULL

A condition such as email LIKE '%' does not match rows where email IS NULL. If you need those rows too, add an explicit IS NULL check:

SELECT *
FROM customers
WHERE email LIKE '%'
   OR email IS NULL;

Assuming LIKE Is Always Case-Insensitive

Case sensitivity depends on the database and column collation. LIKE 'a%' may or may not match Alice. See the compatibility notes below.

Leading Wildcards and Performance

A pattern such as LIKE '%gmail.com' prevents most databases from using a standard B-tree index on the column, because the search does not start from the beginning of the string. On small learning datasets this is fine, but for large tables it can be slow. Consider full-text search or specialized indexes when this becomes a problem, and always inspect the execution plan with EXPLAIN before drawing conclusions.

Database Compatibility

The LIKE operator and the % and _ wildcards are part of the SQL standard and are supported by every major database. However, some behaviors differ.

PostgreSQL

  • LIKE is case-sensitive.
  • Use ILIKE for a case-insensitive match:
SELECT *
FROM customers
WHERE customer_name ILIKE 'a%';

MySQL / MariaDB

  • LIKE is case-insensitive by default because most character collations end in _ci (case-insensitive).
  • Use LIKE BINARY to force case-sensitive comparison:
SELECT *
FROM customers
WHERE customer_name LIKE BINARY 'a%';

SQL Server

  • Case sensitivity depends on the column or database collation.
  • Supports additional bracket wildcards such as [abc], [^abc], and character ranges like [a-c]:
SELECT *
FROM customers
WHERE customer_name LIKE '[A-C]%';

These bracket expressions are not portable to other databases.

Oracle Database

  • LIKE is case-sensitive.
  • Combine with UPPER or LOWER for case-insensitive matches:
SELECT *
FROM customers
WHERE UPPER(customer_name) LIKE 'A%';

SQLite

  • LIKE is case-insensitive for ASCII characters by default.
  • Use GLOB for case-sensitive, Unix-style wildcard matching (* and ?).

When you need advanced pattern matching that goes beyond LIKE, most databases also support regular expressions through operators or functions such as ~ (PostgreSQL), REGEXP (MySQL, MariaDB, SQLite), and REGEXP_LIKE (Oracle). Consult your database documentation before relying on them.

Best Practices

  • Use LIKE only when you actually need pattern matching. For exact matches, use = because it is simpler and can use indexes efficiently.
  • Anchor patterns when possible. LIKE 'gmail%' is usually faster than LIKE '%gmail%' because the leading part is fixed.
  • Always add IS NULL handling when your logic must include or exclude unknown values.
  • Escape user input properly. When using LIKE from application code, use parameterized queries and escape the % and _ characters inside the input:
String sql = """
        SELECT customer_id, customer_name, email
        FROM customers
        WHERE email LIKE ? ESCAPE '\\'
        """;

try (PreparedStatement statement = connection.prepareStatement(sql)) {
    String safeInput = userInput
            .replace("\\", "\\\\")
            .replace("%",  "\\%")
            .replace("_",  "\\_");

    statement.setString(1, "%" + safeInput + "%");

    try (ResultSet resultSet = statement.executeQuery()) {
        while (resultSet.next()) {
            long   customerId   = resultSet.getLong("customer_id");
            String customerName = resultSet.getString("customer_name");
            String email        = resultSet.getString("email");
        }
    }
}

This prevents both SQL injection and accidental wildcard behavior caused by raw user input.

  • Document the intended case sensitivity of a query, especially when the same code runs against multiple database systems.

Conclusion

You learned how to search for text patterns in SQL using the LIKE operator with the % and _ wildcards, how to use NOT LIKE, how to escape special characters with ESCAPE, and how case sensitivity differs across database systems. Remember that LIKE never matches NULL, and that patterns starting with % may slow down queries on large tables.

How do I use comparison and logical operators in SQL?

When you write SQL, every meaningful query eventually needs a condition — a boolean expression that decides which rows are returned, updated, or deleted. Conditions are built from two small but essential building blocks: comparison operators (=, <>, <, >, <=, >=) and logical operators (AND, OR, NOT).

In this tutorial you will learn what each operator does, how they combine, how operator precedence affects the result, and how NULL interacts with them through SQL’s three-valued logic. By the end, you will be able to build precise conditions with confidence.

Prerequisites

To follow along you need:

  • A working SQL database (PostgreSQL, MySQL, MariaDB, SQL Server, Oracle Database, or SQLite).
  • A client that can execute SQL statements.
  • Basic familiarity with the SELECT and WHERE clauses.

If you already followed the previous tutorial on WHERE, you can reuse the same sample tables.

Sample Database

We continue with the online bookstore schema so the examples stay consistent across the series.

CREATE TABLE authors
(
    author_id   INTEGER PRIMARY KEY,
    author_name VARCHAR(100) NOT NULL,
    country     VARCHAR(50)
);

CREATE TABLE books
(
    book_id      INTEGER PRIMARY KEY,
    title        VARCHAR(200)  NOT NULL,
    author_id    INTEGER       NOT NULL,
    category     VARCHAR(50),
    price        DECIMAL(8, 2) NOT NULL,
    stock        INTEGER       NOT NULL,
    published_on DATE,
    CONSTRAINT fk_books_author
        FOREIGN KEY (author_id) REFERENCES authors (author_id)
);

INSERT INTO authors (author_id, author_name, country)
VALUES (1, 'Jane Austen', 'United Kingdom'),
       (2, 'Haruki Murakami', 'Japan'),
       (3, 'Chimamanda Ngozi Adichie', 'Nigeria'),
       (4, 'Gabriel Garcia Marquez', 'Colombia');

INSERT INTO books (book_id, title, author_id, category, price, stock,
                   published_on)
VALUES (1, 'Pride and Prejudice', 1, 'Classic', 12.50, 20, '1813-01-28'),
       (2, 'Emma', 1, 'Classic', 10.00, 0, '1815-12-23'),
       (3, 'Norwegian Wood', 2, 'Fiction', 15.75, 12, '1987-09-04'),
       (4, 'Kafka on the Shore', 2, 'Fiction', 18.20, 5, '2002-09-12'),
       (5, 'Half of a Yellow Sun', 3, 'Historical', 16.00, 8, '2006-08-11'),
       (6, 'Americanah', 3, 'Contemporary', 14.50, 3, '2013-05-14'),
       (7, 'One Hundred Years of Solitude', 4, 'Classic', 22.00, 25,
        '1967-05-30'),
       (8, 'Love in the Time of Cholera', 4, 'Classic', 19.99, 0, '1985-09-05'),
       (9, 'Unknown Title', 2, NULL, 13.00, 4, NULL);

Row 9 intentionally has NULL in category — we will use it to show how logical operators behave when a value is missing.

Comparison Operators

A comparison operator returns one of three values: true, false, or unknown (when either side is NULL).

Operator Meaning Example
= Equal to price = 12.50
<> or != Not equal to category <> 'Classic'
< Less than stock < 5
> Greater than price > 15.00
<= Less than or equal to stock <= 10
>= Greater than or equal to published_on >= DATE '2000-01-01'

<> is the ANSI-standard “not equal” operator. != is accepted by most databases but is not part of the standard.

Example: Equality and Inequality

Find every classic book:

SELECT title, category
FROM books
WHERE category = 'Classic';

Find every book that is not a classic:

SELECT title, category
FROM books
WHERE category <> 'Classic';

Notice that this second query does not return the row where category IS NULL. Comparing NULL with <> produces unknown, and WHERE keeps only rows where the condition is true. We will return to this behavior in the common mistakes section.

Example: Ordered Comparisons

Ordered operators (<, >, <=, >=) work on numbers, dates, and strings (using the column’s collation for strings).

SELECT title, price
FROM books
WHERE price >= 15.00;

Expected result:

title price
Norwegian Wood 15.75
Kafka on the Shore 18.20
Half of a Yellow Sun 16.00
One Hundred Years of Solitude 22.00
Love in the Time of Cholera 19.99

Logical Operators

Logical operators combine boolean expressions into more precise conditions.

Operator Meaning
AND True when both sides are true.
OR True when at least one side is true.
NOT Inverts a boolean: true becomes false, false becomes true, NULL stays NULL.

Example: AND

Return classic books that are currently in stock:

SELECT title, category, stock
FROM books
WHERE category = 'Classic'
  AND stock > 0;

Expected result:

title category stock
Pride and Prejudice Classic 20
One Hundred Years of Solitude Classic 25

Emma and Love in the Time of Cholera are excluded because their stock is 0.

Example: OR

Return every book that is either a Classic or a Fiction title:

SELECT title, category
FROM books
WHERE category = 'Classic'
   OR category = 'Fiction';

Example: NOT

Return every book that is not cheap (using NOT to negate a condition):

SELECT title, price
FROM books
WHERE NOT price < 15.00;

This is equivalent to WHERE price >= 15.00, but NOT is useful when the inner expression is more complex — for example, NOT (category = 'Classic' AND stock = 0).

Operator Precedence

When you combine operators in one condition, SQL evaluates them in this order (highest to lowest):

  1. Comparison operators (=, <>, <, >, <=, >=)
  2. NOT
  3. AND
  4. OR

That means AND binds tighter than OR. The following two conditions are not equivalent:

-- Interpreted as: category = 'Classic'
--                 OR (category = 'Fiction' AND price < 20.00)
WHERE category = 'Classic'
OR category = 'Fiction'
AND price < 20.00;
-- Every classic OR fiction book cheaper than 20.00
WHERE (category = 'Classic' OR category = 'Fiction')
AND price < 20.00;

When you mix AND and OR, always use parentheses. They make intent explicit and prevent subtle bugs.

Three-Valued Logic and NULL

SQL logic has three possible outcomes: true, false, and unknown. Any comparison involving NULL produces unknown.

The truth tables below explain how AND, OR, and NOT handle unknown values.

AND

Left Right Result
true true true
true false false
true unknown unknown
false anything false
unknown unknown unknown

OR

Left Right Result
true anything true
false false false
false unknown unknown
unknown unknown unknown

NOT

Operand Result
true false
false true
unknown unknown

WHERE keeps a row only when its condition evaluates to true. Rows with an unknown result are silently excluded — this is often surprising to newcomers.

Example: NULL and Inequality

You might expect the following query to return the row Unknown Title (which has category = NULL):

SELECT title, category
FROM books
WHERE category <> 'Classic';

It does not. NULL <> 'Classic' evaluates to unknown, so the row is filtered out. To include rows with missing categories, add an explicit check:

SELECT title, category
FROM books
WHERE category <> 'Classic'
   OR category IS NULL;

Use IS NULL and IS NOT NULL to test for missing values — never = NULL or <> NULL.

Combining Comparison and Logical Operators

Here is a realistic query that uses several operators together. “Show contemporary or historical books that are in stock and cost at most 16.00.”

SELECT title,
       category,
       price,
       stock
FROM books
WHERE (category = 'Contemporary' OR category = 'Historical')
  AND stock > 0
  AND price <= 16.00
ORDER BY price;

Expected result:

title category price stock
Americanah Contemporary 14.50 3
Half of a Yellow Sun Historical 16.00 8

Reading the condition clause by clause:

  1. (category = 'Contemporary' OR category = 'Historical') — restrict to two categories.
  2. AND stock > 0 — only books currently available.
  3. AND price <= 16.00 — apply the budget constraint.

The parentheses around the OR are required; without them, AND would bind first and produce a very different result.

Common Mistakes

Comparing to NULL with = or <>

Incorrect:

SELECT *
FROM books
WHERE category = NULL;

Correct:

SELECT *
FROM books
WHERE category IS NULL;

category = NULL always evaluates to unknown, so the query returns zero rows on every standard-compliant database.

Forgetting Parentheses Around OR

Incorrect (probably not what was intended):

SELECT title, category, price
FROM books
WHERE category = 'Classic'
   OR category = 'Fiction'
    AND price < 20.00;

Correct:

SELECT title, category, price
FROM books
WHERE (category = 'Classic' OR category = 'Fiction')
  AND price < 20.00;

Assuming NOT (a = b) Is the Same as a <> b When NULL Is Possible

Both NOT (category = 'Classic') and category <> 'Classic' evaluate to unknown when category is NULL, so both exclude those rows. If you want to include rows with missing values, add OR category IS NULL explicitly.

Mixing AND/OR in UPDATE and DELETE

Before running a destructive statement, verify the filter with SELECT:

SELECT *
FROM books
WHERE category = 'Classic'
  AND stock = 0;

Only after confirming the target rows should you run:

DELETE FROM books
WHERE category = 'Classic'
  AND stock = 0;

Warning: Running UPDATE or DELETE with a wrong combination of AND/OR can silently modify far more rows than expected. Practice these statements only in a disposable learning database or after taking a verified backup.

Database Compatibility

The comparison operators (=, <>, <, >, <=, >=) and the logical operators (AND, OR, NOT) are part of ANSI SQL and are supported by:

  • PostgreSQL
  • MySQL
  • MariaDB
  • SQL Server
  • Oracle Database
  • SQLite

Minor differences to be aware of:

  • != versus <>. Both are widely supported, but only <> is standard. Prefer <> for portability.
  • String comparison case sensitivity. = and <> on text depend on the column collation. PostgreSQL is case-sensitive by default; MySQL and SQL Server are usually case-insensitive.
  • Boolean expressions. PostgreSQL has a native BOOLEAN type, so WHERE is_active works directly. In SQL Server and Oracle Database you typically compare against 1/0 or 'Y'/'N' flags.
  • Three-valued logic. All major systems follow it, but some client tools display unknown as an empty cell rather than NULL.

When in doubt, consult the documentation for your database and version.

Best Practices

  • Use <> instead of != for portable code.
  • Always parenthesize when mixing AND and OR. Readers should never have to remember precedence rules.
  • Handle NULL explicitly with IS NULL / IS NOT NULL when a column is nullable and those rows matter.
  • Prefer set-based operators when they read better. For fixed lists, IN (...) is clearer than a chain of ORs. For inclusive ranges, BETWEEN low AND high is clearer than two comparisons.
  • Verify destructive filters with SELECT first. This is especially important when the condition mixes AND and OR.
  • Use parameters, not string concatenation, when comparison values come from application input. This prevents SQL injection and improves plan reuse.

Conclusion

You learned how SQL comparison operators (=, <>, <, >, <=, >=) and logical operators (AND, OR, NOT) work together to form the conditions that drive every WHERE clause. You also saw how operator precedence and three-valued logic affect the outcome, and how to protect yourself with parentheses and explicit NULL checks.