MySQLDISTINCT

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

SQL
-- 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.

SQL
-- 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)
Note
You cannot apply DISTINCT to only one column when selecting multiple columns. For that use case, consider GROUP BY or a window function.
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.

SQL
-- 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 pattern
Tip
The MySQL query optimizer usually generates the same execution plan for DISTINCT and GROUP BY when no aggregation is involved. Prefer DISTINCT for simple deduplication (it communicates intent more clearly) and GROUP BY when aggregates are needed.
COUNT(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.

SQL
-- 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.

SQL
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

SQL
-- 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
Warning
When using DISTINCT, you can only ORDER BY columns that appear in the SELECT list. This is a SQL standard requirement enforced by MySQL in strict mode.
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).

SQL
-- 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')
Tip
Run 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

SQL
-- 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+.

SQL
-- 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

SQL
-- 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

SQL
-- 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

SQL
-- 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.

SQL
-- 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

SQL
-- 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

SQL
-- 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.

SQL
-- 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

SQL
-- 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.