DISTINCT in MySQL
The DISTINCT keyword in a SELECT statement eliminates duplicate rows from the result set.
It applies to the combination of all selected columns — two rows are considered duplicates only
if every selected column value matches. Understanding when (and when not) to use DISTINCT is
key to writing correct and efficient queries.
SELECT DISTINCT Basics
-- Without DISTINCT: may return duplicate values SELECT department_id FROM employees; -- 1, 1, 2, 2, 2, 3 -- With DISTINCT: one row per unique value SELECT DISTINCT department_id FROM employees; -- 1, 2, 3 -- Common uses SELECT DISTINCT country FROM customers; SELECT DISTINCT status FROM orders; SELECT DISTINCT category_id FROM products WHERE active = 1;
DISTINCT on Multiple Columns
DISTINCT applies to all columns in the SELECT list, not just the first one. Two rows are considered duplicates only if every column value is identical.
-- Unique (city, country) combinations SELECT DISTINCT city, country FROM customers; -- 'New York', 'US' and 'New York', 'CA' are NOT duplicates -- Example data: -- New York | US -- New York | CA <-- different from row 1 (different country) -- Los Angeles | US -- London | GB -- New York | US <-- duplicate of row 1, eliminated -- Result: 4 rows (the second 'New York, US' is removed)
DISTINCT vs GROUP BY
For simple deduplication, SELECT DISTINCT and GROUP BY without aggregates produce
the same result. They differ in intent and capability:
- Use DISTINCT when you simply want unique rows.
- Use GROUP BY when you also need aggregated values per group.
-- These two queries produce identical results:
SELECT DISTINCT department_id FROM employees ORDER BY department_id;
SELECT department_id FROM employees GROUP BY department_id ORDER BY department_id;
-- GROUP BY wins when you need aggregates alongside unique values:
SELECT department_id,
COUNT(*) AS headcount,
AVG(salary) AS avg_salary,
MAX(salary) AS max_salary
FROM employees
GROUP BY department_id;
-- DISTINCT cannot produce aggregates -- use GROUP BY for this patternCOUNT(DISTINCT col)
COUNT(DISTINCT col) counts the number of distinct non-NULL values of a column — one of the
most commonly used aggregate functions in reporting queries.
-- How many distinct products were ever ordered? SELECT COUNT(DISTINCT product_id) AS unique_products_ordered FROM order_items; -- Monthly unique active customers (common KPI) SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(DISTINCT customer_id) AS unique_customers FROM orders GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY month; -- Multiple distinct counts in one query SELECT COUNT(DISTINCT customer_id) AS unique_customers, COUNT(DISTINCT product_id) AS unique_products, COUNT(*) AS total_order_lines FROM order_items; -- Approximate distinct count on very large tables (MySQL 8.0+) -- HyperLogLog-style: approx_count_distinct is not built-in, -- but COUNT(DISTINCT) on indexed columns is fast via loose index scan
DISTINCT with NULL Values
MySQL treats all NULL values as equal for the purposes of DISTINCT — multiple NULLs in a column are collapsed into a single NULL in the result set.
CREATE TABLE test_null (val INT); INSERT INTO test_null VALUES (1), (2), (NULL), (NULL), (1); SELECT DISTINCT val FROM test_null; -- Result: -- 1 -- 2 -- NULL <-- only one NULL, even though two were inserted -- COUNT(DISTINCT) ignores NULLs entirely SELECT COUNT(DISTINCT val) FROM test_null; -- Returns 2 (only non-NULL distinct values: 1 and 2)
DISTINCT with ORDER BY
-- ORDER BY columns must appear in the SELECT list when using DISTINCT -- This works: SELECT DISTINCT department_id, last_name FROM employees ORDER BY department_id, last_name; -- This FAILS in strict mode (ordering by a column not in SELECT): SELECT DISTINCT department_id FROM employees ORDER BY last_name; -- ERROR: 'last_name' not in DISTINCT select list
Performance: DISTINCT Forces a Sort or Hash Operation
DISTINCT must deduplicate the result set. The optimizer may use:
- A temporary table to collect rows and eliminate duplicates via a hash.
- Sorting (filesort) to bring identical rows together for removal.
- A covering index scan if the selected columns are all covered by an index — the ideal case (no extra memory or sort operation needed).
-- Check the execution plan for DISTINCT EXPLAIN SELECT DISTINCT department_id FROM employees; -- Good outcome: 'Using index' means a covering index eliminates the sort -- Bad outcome: 'Using temporary; Using filesort' means extra memory and disk work -- Create a covering index to improve DISTINCT performance ALTER TABLE employees ADD INDEX idx_dept (department_id); -- Now DISTINCT on department_id uses an index-only scan (no filesort) -- Verify the improvement EXPLAIN SELECT DISTINCT department_id FROM employees; -- Extra: 'Using index' (no more 'Using temporary')
EXPLAIN on your DISTINCT queries. If you see Using temporary; Using filesort, add a covering index on the DISTINCT column(s) to eliminate the sort overhead.When GROUP BY Is Better Than DISTINCT
-- Scenario: unique customers who spent more than $500 TOTAL across all orders -- DISTINCT does NOT work for aggregate filtering: SELECT DISTINCT customer_id FROM orders WHERE total > 500; -- This gives customers with ANY single order > $500, not total spending -- GROUP BY + HAVING is the correct approach: SELECT customer_id FROM orders GROUP BY customer_id HAVING SUM(total) > 500; -- Unique product categories that have at least 10 products SELECT category_id FROM products GROUP BY category_id HAVING COUNT(*) >= 10; -- Cannot be done with DISTINCT alone; GROUP BY HAVING is required
ROW_NUMBER() as a DISTINCT Alternative
When you need to deduplicate but also want to keep additional columns from the same row
(not just the distinct column itself), DISTINCT breaks down. A window function with
ROW_NUMBER() solves this elegantly in MySQL 8.0+.
-- Keep only the most recent order per customer
-- DISTINCT customer_id would lose the order details
SELECT customer_id, order_id, total, created_at
FROM (
SELECT
customer_id,
id AS order_id,
total,
created_at,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders
) ranked
WHERE rn = 1;
-- Most recently viewed product page per user session
SELECT session_id, page_slug, viewed_at
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY viewed_at DESC) AS rn
FROM page_views
) r
WHERE rn = 1;DISTINCT in Subqueries
-- Find categories that have at least one active product SELECT * FROM categories WHERE id IN ( SELECT DISTINCT category_id FROM products WHERE active = 1 ); -- The DISTINCT in the subquery can help performance by reducing rows passed upward -- though the optimizer may remove it automatically in EXISTS-style rewrites -- EXISTS is often faster for this pattern: SELECT * FROM categories c WHERE EXISTS ( SELECT 1 FROM products p WHERE p.category_id = c.id AND p.active = 1 );
DISTINCT Inside Aggregate Functions
-- GROUP_CONCAT with DISTINCT: unique values in a comma-separated list SELECT department_id, GROUP_CONCAT(DISTINCT job_title ORDER BY job_title SEPARATOR ', ') AS unique_roles FROM employees GROUP BY department_id; -- SUM(DISTINCT col): sum only unique values (uncommon but valid) SELECT SUM(DISTINCT price) AS sum_of_unique_prices FROM products; -- AVG(DISTINCT col): average of unique values SELECT AVG(DISTINCT salary) AS avg_unique_salary FROM employees;
Practical Reporting: Monthly Unique Active Users
-- Monthly Unique Active Users (MAU) — a standard product metric SELECT DATE_FORMAT(event_date, '%Y-%m') AS month, COUNT(DISTINCT user_id) AS mau FROM user_activity_events WHERE event_type = 'session_start' AND event_date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) GROUP BY DATE_FORMAT(event_date, '%Y-%m') ORDER BY month; -- Weekly unique users who performed a purchase SELECT YEARWEEK(created_at, 3) AS iso_week, COUNT(DISTINCT customer_id) AS weekly_buyers FROM orders WHERE status = 'completed' GROUP BY YEARWEEK(created_at, 3) ORDER BY iso_week DESC LIMIT 12;
DISTINCT vs Deduplication Alternatives
Method | Best For | Notes |
|---|---|---|
SELECT DISTINCT | Simple row deduplication across all selected columns | Cannot combine with per-group aggregates |
GROUP BY (no aggregates) | Same as DISTINCT, more explicit intent | Often generates the same execution plan |
GROUP BY + HAVING | Deduplication with aggregate filtering | Most flexible approach |
ROW_NUMBER() window function | Keep one specific row per group (most recent, highest value) | MySQL 8.0+ only |
EXISTS subquery | Check existence, filter parents by child data | Better than IN for large subquery lists |
COUNT(DISTINCT col) | Count unique non-NULL values within a group | Standard aggregate function |
Finding Duplicate Rows (Inverse of DISTINCT)
A closely related task is finding which rows ARE duplicates — useful for data-quality audits before adding a UNIQUE constraint. The standard approach uses GROUP BY with HAVING COUNT > 1.
-- Find duplicate emails in the users table SELECT email, COUNT(*) AS occurrences FROM users GROUP BY email HAVING COUNT(*) > 1 ORDER BY occurrences DESC; -- Find the actual duplicate rows (keep the first, show the extras) SELECT u.* FROM users u JOIN ( SELECT email, MIN(id) AS keep_id FROM users GROUP BY email HAVING COUNT(*) > 1 ) dups ON u.email = dups.email WHERE u.id != dups.keep_id; -- Delete duplicates, keeping the row with the lowest id DELETE u FROM users u JOIN ( SELECT email, MIN(id) AS keep_id FROM users GROUP BY email HAVING COUNT(*) > 1 ) dups ON u.email = dups.email WHERE u.id != dups.keep_id;
DISTINCT and Query Optimisation Tips
-- Tip 1: use a loose index scan for DISTINCT on indexed columns -- MySQL can satisfy DISTINCT on a single indexed column by walking -- only the first entry per distinct value in the B-tree ALTER TABLE orders ADD INDEX idx_status (status); EXPLAIN SELECT DISTINCT status FROM orders; -- Extra: 'Using index' (loose index scan -- no sort, no temp table) -- Tip 2: prefer DISTINCT on narrow columns to reduce sort overhead -- DISTINCT on a TEXT column requires sorting all text values -- DISTINCT on an INT is much cheaper -- Tip 3: push filters into WHERE before DISTINCT to reduce the dedup work -- BAD: deduplicate 1M rows then filter SELECT DISTINCT customer_id FROM orders WHERE status = 'completed' -- filter before dedup is fine here -- BAD pattern: applying DISTINCT to an already-unique column -- (wastes CPU with no benefit) SELECT DISTINCT id FROM orders; -- id is the PK, already unique -- Tip 4: verify the optimizer plan EXPLAIN SELECT DISTINCT department_id, job_title FROM employees; -- If 'Using temporary; Using filesort' -> add composite index on (department_id, job_title)
Real-World Example: Unique Product Views per Day
-- Track how many distinct users viewed each product each day CREATE TABLE product_views ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, product_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, viewed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_product_date (product_id, viewed_at) ); -- Daily unique viewers per product (last 7 days) SELECT product_id, DATE(viewed_at) AS view_date, COUNT(DISTINCT user_id) AS unique_viewers FROM product_views WHERE viewed_at >= CURDATE() - INTERVAL 7 DAY GROUP BY product_id, DATE(viewed_at) ORDER BY product_id, view_date; -- Top 10 products by unique viewers today SELECT product_id, COUNT(DISTINCT user_id) AS unique_viewers_today FROM product_views WHERE viewed_at >= CURDATE() GROUP BY product_id ORDER BY unique_viewers_today DESC LIMIT 10;
Unexpected Duplicates: Diagnosing Missing JOIN Conditions
DISTINCT is sometimes added to "fix" a query that returns duplicate rows. But unexpected duplicates are usually a symptom of a missing or incorrect JOIN condition — adding DISTINCT hides the bug rather than fixing it. Always investigate the root cause first.
-- Query returns duplicate customers unexpectedly
SELECT DISTINCT c.id, c.name
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.created_at >= '2024-01-01';
-- DISTINCT is suppressing duplicates caused by multiple orders per customer
-- Root cause: JOIN produces one row per order, not per customer
-- Fix 1: use EXISTS to check if any matching order exists
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
AND o.created_at >= '2024-01-01'
);
-- Fix 2: aggregate at the order level if you need order data
SELECT c.id, c.name, COUNT(o.id) AS order_count
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.created_at >= '2024-01-01'
GROUP BY c.id, c.name;DISTINCT with Aggregate Window Functions
-- DISTINCT and window functions require careful composition
-- Cannot use DISTINCT inside a window function (unsupported syntax):
-- SELECT COUNT(DISTINCT user_id) OVER (PARTITION BY product_id) -- ERROR
-- Workaround: use subquery/CTE with conditional aggregation
WITH daily_views AS (
SELECT
product_id,
DATE(viewed_at) AS view_date,
COUNT(DISTINCT user_id) AS unique_viewers
FROM product_views
GROUP BY product_id, DATE(viewed_at)
)
SELECT
product_id,
view_date,
unique_viewers,
SUM(unique_viewers) OVER (PARTITION BY product_id ORDER BY view_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rolling_7day_viewers
FROM daily_views
ORDER BY product_id, view_date;Best Practices
Use DISTINCT when you genuinely need unique rows — but first ask whether unexpected duplicates indicate a missing join condition or data model issue.
Use GROUP BY when you need distinct values alongside aggregate functions.
Add a covering index on the DISTINCT column(s) to enable index-only scans and avoid sort or temp-table overhead.
Use COUNT(DISTINCT col) to count unique values; it automatically ignores NULLs.
Remember DISTINCT deduplicates across ALL selected columns, not just the first one.
Prefer ROW_NUMBER() when you need to keep one specific row per group (e.g. most recent, highest value).
Check EXPLAIN for "Using temporary; Using filesort" with DISTINCT on large tables — it signals a missing index.
Do not apply DISTINCT to a primary key column — PKs are already unique, so DISTINCT adds no value and costs a sort.