Put a CASE expression inside the aggregate: SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) counts the paid rows and runs on every database. In PostgreSQL and SQLite 3.30+ the standard shorthand COUNT(*) FILTER (WHERE status = 'paid') gives the same number. Both let you put several counts in one query, which a WHERE clause can't.
When you need one number, a WHERE clause is enough: SELECT COUNT(*) FROM orders WHERE status = 'paid'. The question gets harder when you want the total, the paid orders and the refunded orders side by side, or the same counts for every customer. A WHERE would throw away the rows the other counts need, so the condition has to move inside the aggregate instead. The sections below show the portable CASE form, the standard FILTER clause, counts per group, the SUM(condition) shortcut, how COUNT treats NULL, and percentages. The examples run on SQLite; the table in section 7 gives the PostgreSQL, MySQL and SQL Server forms. Hit Run on any of them, then edit the query and run it again.
1SUM(CASE …) or COUNT(CASE …): works everywhereRecommended
A CASE expression turns each row into a value you can aggregate (see IF/THEN in a SELECT for how it works). There are two ways to count with it. SUM(CASE WHEN … THEN 1 ELSE 0 END) adds a 1 for every matching row. COUNT(CASE WHEN … THEN 1 END) relies on CASE returning NULL when no branch matches, and COUNT skipping NULLs. Either one is a good default: standard SQL that PostgreSQL, MySQL, SQL Server, SQLite and Oracle all accept, so the query keeps working if it moves to another database.
Output
The first query reads the table once and returns 6 orders, 4 paid, 1 refunded and 3 of 30 or more. The second shows the one mistake to avoid: with ELSE 0 inside COUNT, every row produces a non-NULL value, so it counts all 6 instead of 4. Use ELSE 0 with SUM and no ELSE with COUNT. The last query has no rows to aggregate: SUM returns NULL there while COUNT returns 0, so wrap the SUM form in COALESCE(…, 0) when a filter can leave nothing behind. With GROUP BY every group has at least one row, so the difference doesn't show up there.
2COUNT(*) FILTER (WHERE …): the standard shorthand
The SQL standard has a clause made for this: write the aggregate, then FILTER (WHERE condition), and only the rows that pass the condition reach it. It reads closer to what you mean than a CASE, and it works with any aggregate, so SUM(amount) FILTER (WHERE …) adds up only the matching amounts. PostgreSQL has it since 9.4 and SQLite since 3.30. MySQL and SQL Server don't support it, which is why the CASE form is the safer default.
Output
The result is 6 orders, 4 paid, 3 with a coupon and 135 as the sum of the paid amounts (40 + 15 + 60 + 20). The paid count matches the CASE version in section 1 exactly; FILTER is a shorter way to say the same thing. Like COUNT(CASE …), COUNT(*) FILTER returns 0 when nothing matches. If your database is PostgreSQL or a current SQLite and the query will stay there, use whichever reads better to you.
3Conditional counts per group with GROUP BY
Add a GROUP BY and the same expressions count inside each group, so one query gives every customer's total, paid and other orders as columns of one row. To keep only the groups that meet a condition on such a count, put the expression in HAVING, which filters after grouping (WHERE runs before it and can't see aggregates).
Output
Each customer has 2 orders: ana has both paid, ben and cy one paid and one other. The HAVING query returns only ana, the one customer with two paid orders. One thing to watch in the other column: a row whose status is NULL fails both = 'paid' and <> 'paid', so it would land in neither count. If the column allows NULL, count the rest as SUM(CASE WHEN status = 'paid' THEN 0 ELSE 1 END), which puts those rows in other.
4SUM(condition): the shortcut in SQLite and MySQL
In SQLite and MySQL a comparison such as status = 'paid' is a number: 1 when it holds, 0 when it doesn't. Summing it therefore counts the matches, with no CASE at all. It is short and common in MySQL code, but it doesn't port: PostgreSQL has a real boolean type and rejects it with function sum(boolean) does not exist, and SQL Server doesn't allow a comparison as a value at all.
Output
The first query shows the raw values: order 1 is paid and over 25 (1, 1), order 2 is paid but only 15 (1, 0), order 3 is neither. Summed over the table that gives 4 paid, 3 over 25 and 3 with a coupon, the same numbers as the CASE form. In PostgreSQL, cast the boolean first, SUM((status = 'paid')::int), or use FILTER. If the column itself can be NULL, the comparison is NULL for that row and SUM skips it, which is what you want for a count.
5COUNT(*), COUNT(column) and COUNT(DISTINCT column)
What you put inside COUNT is itself a condition. COUNT(*) counts rows. COUNT(coupon) counts rows where coupon is not NULL, so it is a built-in "has a value" count. COUNT(DISTINCT coupon) counts the different non-NULL values. Combine DISTINCT with a CASE to count distinct values among the rows that match a condition.
Output
Of the 6 rows, 3 have a coupon, but only 2 different coupons (SAVE10 and FREESHIP): DISTINCT collapses the repeats and NULL is never counted. There are 3 customers, and 2 of them (ana and ben) used SAVE10. The customer and status pairs come to 5, because ana's two paid orders are one pair. MySQL also accepts COUNT(DISTINCT customer, status), but SQLite rejects it with wrong number of arguments to function COUNT(), and so do PostgreSQL and SQL Server, so the subquery is the form that works everywhere.
6The percentage of rows that match
A percentage is a conditional count divided by COUNT(*). Start the arithmetic with 100.0, not 100: both counts are integers, and integer division in SQLite, PostgreSQL and SQL Server drops the fraction. ROUND(x, 1) keeps one decimal. For each group's share of all rows, divide by SUM(COUNT(*)) OVER (), a window function that adds up the group counts.
Output
Four of six orders are paid, so pct_paid is 66.7, and three of six have a coupon, 50.0. The grouped query gives each status its share: 66.7 for paid and 16.7 each for pending and refunded. The last query shows what integer math does: dividing first gives 4 / 6 = 0, so the result is 0; multiplying by 100 first gives 66, with the fraction cut off. MySQL's / always returns a decimal, so it doesn't have this problem, but 100.0 is harmless there too. In SQL Server, ROUND keeps the full decimal scale, so cast the result to DECIMAL(5,1) to show one decimal place.
7In PostgreSQL, MySQL and SQL Server
The CASE forms are the same in every database. What differs is the shorthand each one adds: FILTER in PostgreSQL, SUM(condition) and IF() in MySQL, IIF() in SQL Server. Each form below was run on PostgreSQL 18, MySQL 8.4 and SQL Server's T-SQL (via Babelfish). Oracle also takes the CASE forms.
| Syntax | Database | Notes |
|---|---|---|
| SUM(CASE WHEN … THEN 1 ELSE 0 END) | All of them | Portable default. Returns NULL, not 0, when there are no rows; wrap in COALESCE |
| COUNT(CASE WHEN … THEN 1 END) | All of them | Returns 0 on no rows. Leave out ELSE, or it counts every row |
| COUNT(*) FILTER (WHERE …) | PostgreSQL 9.4+, SQLite 3.30+ | SQL standard. MySQL and SQL Server reject it with a syntax error |
| SUM((status = 'paid')::int) | PostgreSQL | Cast first: SUM(boolean) fails with "function sum(boolean) does not exist" |
| SUM(status = 'paid') | MySQL, SQLite | A comparison is 1 or 0 there. PostgreSQL and SQL Server reject it |
| COUNT(IF(status = 'paid', 1, NULL)) | MySQL | MySQL's IF() function; the CASE form does the same and ports |
| SUM(IIF(status = 'paid', 1, 0)) | SQL Server 2012+ | IIF is a two-branch CASE. SQL Server has no FILTER and no SUM(condition) |
| COUNT(DISTINCT col) | All of them | Different non-NULL values. One column, except in MySQL |
| COUNT(DISTINCT a, b) | MySQL | PostgreSQL, SQL Server and SQLite reject it; count a SELECT DISTINCT subquery |
| CAST(100.0 * … / COUNT(*) AS DECIMAL(5,1)) | SQL Server | ROUND(…, 1) keeps the full scale there (66.700000000000); the cast shows 66.7 |
Frequently asked questions
How do I count rows that meet a condition in SQL?
For a single count, filter with WHERE: SELECT COUNT(*) FROM orders WHERE status = 'paid'. To count several conditions in one query, move each condition inside the aggregate: SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) works on every database, and COUNT(*) FILTER (WHERE status = 'paid') is the shorter standard form in PostgreSQL and SQLite 3.30+.
How do I get multiple counts with different conditions in one query?
Put one conditional aggregate per column in the same SELECT: SELECT COUNT(*) AS total, SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid, COUNT(CASE WHEN status = 'refunded' THEN 1 END) AS refunded FROM orders. The table is read once and each column counts its own condition; on the sample data that gives 6, 4 and 1. Add GROUP BY to get the same columns for each group.
Why does COUNT(CASE WHEN … THEN 1 ELSE 0 END) count every row?
COUNT counts every non-NULL value, and 0 is not NULL. With ELSE 0 each row produces a value, so the result is the row count (6 instead of 4 in section 1). Drop the ELSE so non-matching rows give NULL, or switch to SUM, which adds the 1s and 0s.
Does SQL Server or MySQL support COUNT(*) FILTER (WHERE …)?
No. MySQL 8.4 fails with error 1064, You have an error in your SQL syntax, and SQL Server (run here through Babelfish) reports a syntax error near WHERE. Use COUNT(CASE WHEN … THEN 1 END) or SUM(CASE WHEN … THEN 1 ELSE 0 END) there; in SQL Server, SUM(IIF(condition, 1, 0)) is a shorter equivalent.
What is the difference between COUNT(*), COUNT(column) and COUNT(DISTINCT column)?
COUNT(*) counts rows, COUNT(column) counts rows where the column is not NULL, and COUNT(DISTINCT column) counts the different non-NULL values. On six orders where three have a coupon and two coupon codes repeat, they return 6, 3 and 2.
How do I calculate the percentage of rows that match a condition?
Divide the conditional count by COUNT(*) and start with 100.0 so the division is not integer division: ROUND(100.0 * COUNT(CASE WHEN status = 'paid' THEN 1 END) / COUNT(*), 1) returns 66.7 for 4 of 6 rows. Starting with 100 instead truncates to 66, and dividing first gives 0.
Run it yourself
Open any of these in the SQLite playground to change the data, run the queries and share them.
SQLite playground