Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
SQL

Writing SQL to Find Duplicates and Deduplicate Records: GROUP BY, HAVING, and Subquery Strategies

Duplicate records silently corrupt every report and join that touches them — often without raising an error. Learn how to detect exact and partial duplicates with GROUP BY and HAVING, then safely remove them using ROW_NUMBER() subquery patterns you can trust with production data.

⚡ Practitioner21 min readOct 19, 2026Updated Oct 19, 2026
Writing SQL to Find Duplicates and Deduplicate Records: GROUP BY, HAVING, and Subquery Strategies
On this page
  • Prerequisites
  • Setting Up a Realistic Scenario
  • Step 1: Find Duplicate Email Addresses with GROUP BY and HAVING
  • Step 2: Retrieve the Full Duplicate Rows
  • Step 3: Multi-Column Duplicate Detection
  • Step 4: Counting the Full Scope of the Problem
  • Step 5: Using ROW_NUMBER() to Tag Which Rows to Delete
  • Step 6: Safe Deletion with a Subquery
  • Step 7: Deduplication Without Deletion — Creating a Clean View
  • Step 8: Handling "Fuzzy" Duplicates
  • Step 9: Verifying Your Deduplication Worked
  • Real-World Project: Auditing and Cleaning a CRM Import
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Writing SQL to Find Duplicates and Deduplicate Records: GROUP BY, HAVING, and Subquery Strategies

    Duplicates are one of the most common data quality problems you'll encounter in production databases. A CRM gets records imported twice from a spreadsheet. An ETL job runs twice because of a retry after a timeout. A form submission fires twice because a user clicked the button at the same moment the network recovered. However they got there, duplicate records silently corrupt every aggregate, every report, and every join that touches them — often without raising a single error.

    By the end of this lesson, you'll be able to do two distinct things: find duplicates precisely, regardless of whether they're exact copies or "logical" duplicates sharing only certain fields, and then remove them safely, keeping exactly the record you want. These are different problems that require different tools, and conflating them is where most people go wrong.

    This lesson uses realistic scenarios across two domains — a customer database and an order log — because duplicate problems tend to look different depending on whether you're working with reference data or transactional data. You'll walk away with patterns you can drop into a real codebase with minimal modification.

    What you'll learn:

    • How to detect exact and partial duplicates using GROUP BY and HAVING
    • How to diagnose the scope of your duplicate problem before touching any data
    • How to use subqueries and ROW_NUMBER() to identify which specific records to remove
    • Safe deletion patterns that protect you from accidentally wiping legitimate data
    • How to verify that your deduplication actually worked

    Prerequisites

    You should be comfortable with SELECT, WHERE, and basic GROUP BY before starting here. If you need a refresher, see SQL Basics: Master SELECT, FROM, WHERE Clauses and Build Your First Queries. This lesson also uses subqueries — you don't need to be an expert, but you should understand the basic concept. Understanding SQL Subqueries: Filtering and Looking Up Data with Nested SELECT Statements covers the foundation if you need it.


    Setting Up a Realistic Scenario

    Let's work with a customers table that a sales team uses to track leads and accounts. An import script ran twice last week and nobody noticed until a rep called the same prospect twice in one afternoon.

    CREATE TABLE customers (
        customer_id   INT PRIMARY KEY,
        email         VARCHAR(255),
        first_name    VARCHAR(100),
        last_name     VARCHAR(100),
        company       VARCHAR(200),
        created_at    TIMESTAMP,
        source        VARCHAR(50)
    );
    

    Here's a representative slice of what's in there:

    customer_id email first_name last_name company created_at source
    1001 sara.chen@acme.com Sara Chen Acme Corp 2024-01-15 09:00:00 webform
    1002 j.patel@globex.io Jay Patel Globex 2024-01-15 09:01:00 import
    1003 sara.chen@acme.com Sara Chen Acme Corp 2024-01-15 09:03:00 import
    1004 m.torres@pinnacle.co Maria Torres Pinnacle 2024-01-16 10:15:00 import
    1005 m.torres@pinnacle.co Maria Torres Pinnacle 2024-01-16 10:15:00 import
    1006 ben.wu@startup.io Ben Wu StartupCo 2024-01-17 14:22:00 webform

    Rows 1001/1003 and 1004/1005 are duplicates. But notice they're slightly different kinds: Sara Chen has two different customer_ids and slightly different timestamps (she might have submitted the form and been imported). Maria Torres has two records that are nearly identical, same timestamp, clearly a double-write from a retry.

    This matters when you decide which record to keep.


    Step 1: Find Duplicate Email Addresses with GROUP BY and HAVING

    The first question is always: how bad is it? Before you touch anything, audit the scope.

    SELECT
        email,
        COUNT(*) AS record_count
    FROM customers
    GROUP BY email
    HAVING COUNT(*) > 1
    ORDER BY record_count DESC;
    

    Output:

    email record_count
    m.torres@pinnacle.co 2
    sara.chen@acme.com 2

    This is the foundational duplicate-detection pattern. GROUP BY email collapses all rows with the same email into a single group, COUNT(*) tells you how many rows fell into that group, and HAVING COUNT(*) > 1 filters to only the groups with more than one — the duplicates.

    The HAVING clause is what makes this work. WHERE filters individual rows before aggregation. HAVING filters groups after aggregation. You cannot write WHERE COUNT(*) > 1 — that's a syntax error because COUNT(*) doesn't exist at the WHERE evaluation stage. If you want to go deeper on this distinction, Filtering Groups After Aggregation: Writing HAVING Clauses That Answer Real Business Questions covers the mechanics thoroughly.

    Key insight

    This query tells you which emails have duplicates, not which rows are duplicates. That sounds like a minor distinction but it drives everything that comes next. Your next job is to retrieve the actual rows behind those email addresses.


    Step 2: Retrieve the Full Duplicate Rows

    Now that you know which emails are duplicated, pull all the rows associated with them. The cleanest way is a subquery in an IN clause:

    SELECT
        customer_id,
        email,
        first_name,
        last_name,
        created_at,
        source
    FROM customers
    WHERE email IN (
        SELECT email
        FROM customers
        GROUP BY email
        HAVING COUNT(*) > 1
    )
    ORDER BY email, created_at;
    

    Output:

    customer_id email first_name last_name created_at source
    1001 sara.chen@acme.com Sara Chen 2024-01-15 09:00:00 webform
    1003 sara.chen@acme.com Sara Chen 2024-01-15 09:03:00 import
    1004 m.torres@pinnacle.co Maria Torres 2024-01-16 10:15:00 import
    1005 m.torres@pinnacle.co Maria Torres 2024-01-16 10:15:00 import

    Now you can see the actual rows and make decisions. For Sara Chen, the webform record came first — that's likely the canonical one. For Maria Torres, the records are identical (same timestamp) — you'd keep whichever has the lower customer_id (the earlier-assigned one) by convention.

    Tip

    Always run this read-only retrieval query first and review the output before writing any DELETE or UPDATE. Duplicate scenarios often reveal data quality surprises you didn't expect — like rows that are almost duplicates but have different phone numbers, suggesting two people at the same company.


    Step 3: Multi-Column Duplicate Detection

    Email is a clean deduplication key for customer data, but many real problems require matching on combinations of columns. Consider an orders table where the same order might be logged twice due to a message-queue retry:

    CREATE TABLE orders (
        order_id       INT PRIMARY KEY,
        customer_id    INT,
        product_sku    VARCHAR(50),
        quantity       INT,
        order_total    DECIMAL(10,2),
        placed_at      TIMESTAMP
    );
    

    Here you can't just match on customer_id — the same customer places many orders. You need to match on the combination of customer_id, product_sku, and placed_at (within a narrow time window) to identify a retry duplicate.

    SELECT
        customer_id,
        product_sku,
        placed_at,
        COUNT(*)          AS record_count,
        MIN(order_id)     AS earliest_order_id,
        MAX(order_id)     AS latest_order_id
    FROM orders
    GROUP BY
        customer_id,
        product_sku,
        placed_at
    HAVING COUNT(*) > 1
    ORDER BY record_count DESC;
    

    By including MIN(order_id) and MAX(order_id) in the SELECT, you're already surfacing the information you'll need for the deletion step — the earliest (canonical) record and the duplicate.

    Warning

    Be careful about what you include in GROUP BY when detecting partial duplicates. If you group by too many columns, near-duplicates that differ in one minor field won't be caught. If you group by too few, you'll incorrectly flag legitimate rows as duplicates. Define "duplicate" in business terms first, then translate that definition into your column list.


    Step 4: Counting the Full Scope of the Problem

    Before any remediation, produce a summary that tells you the total scale:

    SELECT
        COUNT(*)                                    AS total_rows,
        COUNT(DISTINCT email)                       AS unique_emails,
        COUNT(*) - COUNT(DISTINCT email)            AS excess_rows,
        ROUND(
            100.0 * (COUNT(*) - COUNT(DISTINCT email)) / COUNT(*),
            2
        )                                           AS duplicate_pct
    FROM customers;
    

    Output (hypothetical with a larger dataset):

    total_rows unique_emails excess_rows duplicate_pct
    24183 23941 242 1.00

    This is useful for two reasons: it gives you a before snapshot to validate against after deduplication, and it tells you whether you're dealing with a small clean-up or a systemic problem that needs a process fix upstream. One percent is common after a bad import. Ten percent means something structural is broken.


    Step 5: Using ROW_NUMBER() to Tag Which Rows to Delete

    The GROUP BY / HAVING approach is excellent for finding duplicates, but it doesn't directly give you a list of row IDs to delete. For that, you need a window function: ROW_NUMBER().

    SELECT
        customer_id,
        email,
        first_name,
        created_at,
        source,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY created_at ASC, customer_id ASC
        ) AS row_num
    FROM customers;
    

    Output (subset):

    customer_id email first_name created_at source row_num
    1001 sara.chen@acme.com Sara 2024-01-15 09:00:00 webform 1
    1003 sara.chen@acme.com Sara 2024-01-15 09:03:00 import 2
    1002 j.patel@globex.io Jay 2024-01-15 09:01:00 import 1
    1004 m.torres@pinnacle.co Maria 2024-01-16 10:15:00 import 1
    1005 m.torres@pinnacle.co Maria 2024-01-16 10:15:00 import 2
    1006 ben.wu@startup.io Ben 2024-01-17 14:22:00 webform 1

    PARTITION BY email restarts the row counter for each unique email group. ORDER BY created_at ASC means the earliest record gets row_num = 1 — the one you want to keep. Every row with row_num > 1 is a candidate for deletion.

    Key insight

    The ORDER BY inside OVER() is your "which one do I keep" logic. Ordering by created_at ASC keeps the oldest record. Change it to DESC to keep the newest. You can also order by a source priority (using a CASE expression) if webform submissions should take precedence over import rows regardless of timing. Locking in this logic explicitly prevents ambiguity.

    Now wrap it in a subquery to extract only the duplicate rows:

    SELECT customer_id
    FROM (
        SELECT
            customer_id,
            ROW_NUMBER() OVER (
                PARTITION BY email
                ORDER BY created_at ASC, customer_id ASC
            ) AS row_num
        FROM customers
    ) ranked
    WHERE row_num > 1;
    

    This gives you a clean list of customer_id values to delete — and nothing else.


    Step 6: Safe Deletion with a Subquery

    Now you're ready to actually remove the duplicates. The safest pattern is a DELETE with a subquery that references the ROW_NUMBER() logic you already validated:

    -- Preview first: confirm these are the rows you want to remove
    SELECT *
    FROM customers
    WHERE customer_id IN (
        SELECT customer_id
        FROM (
            SELECT
                customer_id,
                ROW_NUMBER() OVER (
                    PARTITION BY email
                    ORDER BY created_at ASC, customer_id ASC
                ) AS row_num
            FROM customers
        ) ranked
        WHERE row_num > 1
    );
    

    Run this SELECT first. Make sure the output looks exactly right. Then swap out SELECT * for the deletion:

    DELETE FROM customers
    WHERE customer_id IN (
        SELECT customer_id
        FROM (
            SELECT
                customer_id,
                ROW_NUMBER() OVER (
                    PARTITION BY email
                    ORDER BY created_at ASC, customer_id ASC
                ) AS row_num
            FROM customers
        ) ranked
        WHERE row_num > 1
    );
    

    Warning

    In MySQL specifically, you cannot directly reference the table you're deleting from in a subquery in the same statement without wrapping it in an intermediate derived table — which is exactly what the double-nested structure above does. In PostgreSQL and SQL Server, you can use a CTE with DELETE directly (shown below), which is cleaner. Know your database's rules before running this.

    For PostgreSQL, a CTE-based delete is much more readable:

    WITH duplicates AS (
        SELECT
            customer_id,
            ROW_NUMBER() OVER (
                PARTITION BY email
                ORDER BY created_at ASC, customer_id ASC
            ) AS row_num
        FROM customers
    )
    DELETE FROM customers
    WHERE customer_id IN (
        SELECT customer_id
        FROM duplicates
        WHERE row_num > 1
    );
    

    If you want to go deeper on DELETE safety patterns, INSERT, UPDATE, DELETE: Master SQL Data Modification Safely covers transaction wrapping and rollback strategies that are worth pairing with any bulk deletion operation.


    Step 7: Deduplication Without Deletion — Creating a Clean View

    Sometimes you can't or shouldn't delete from the source table. Maybe it's a raw ingestion table that's append-only by design. Maybe you don't have write access. In these cases, you deduplicate at query time using a derived table or CTE that filters to row_num = 1.

    -- Reusable "clean" customer view
    CREATE VIEW customers_deduped AS
    SELECT
        customer_id,
        email,
        first_name,
        last_name,
        company,
        created_at,
        source
    FROM (
        SELECT
            *,
            ROW_NUMBER() OVER (
                PARTITION BY email
                ORDER BY created_at ASC, customer_id ASC
            ) AS row_num
        FROM customers
    ) ranked
    WHERE row_num = 1;
    

    Any query that joins against or aggregates from customers_deduped will see only one row per email, automatically. This is especially useful in data warehouse environments where raw tables are immutable and transformation logic lives in views or CTEs.

    Tip

    If you're creating this view in a production system, put it through the same review process as application code. Views that silently deduplicate data should be clearly named (the _deduped suffix is a good convention) and documented, so the next person who uses them knows that a row-selection rule is being applied.


    Step 8: Handling "Fuzzy" Duplicates

    Not all duplicates are clean matches. Sometimes the same person is in the system twice because they spelled their name differently, used a different email alias, or filled in their company name inconsistently. These fuzzy duplicates require different detection techniques.

    One practical SQL-level approach uses string normalization before grouping:

    SELECT
        LOWER(TRIM(email))         AS normalized_email,
        LOWER(TRIM(first_name))    AS normalized_first,
        LOWER(TRIM(last_name))     AS normalized_last,
        COUNT(*)                   AS record_count
    FROM customers
    GROUP BY
        LOWER(TRIM(email)),
        LOWER(TRIM(first_name)),
        LOWER(TRIM(last_name))
    HAVING COUNT(*) > 1;
    

    This catches cases where one record has "Sara Chen" and another has " sara chen " (with leading/trailing spaces). It won't catch sara.chen@acme.com vs s.chen@acme.com, but it handles a large class of real-world data entry inconsistencies.

    For email-specific deduplication, you might normalize the domain:

    SELECT
        LOWER(TRIM(email))  AS canonical_email,
        COUNT(*)            AS record_count
    FROM customers
    GROUP BY LOWER(TRIM(email))
    HAVING COUNT(*) > 1;
    

    True fuzzy matching (Levenshtein distance, phonetic matching) generally requires application-layer processing or database extensions. SQL is good at exact and normalized matching; for the rest, flag candidates and hand them to a human or a Python script. The Master SQL String and Date Functions: Essential Data Transformation Skills lesson covers TRIM, LOWER, and related normalization functions in depth.


    Step 9: Verifying Your Deduplication Worked

    After any deduplication operation, verify. This is not optional.

    -- Check 1: No duplicates remain
    SELECT
        email,
        COUNT(*) AS record_count
    FROM customers
    GROUP BY email
    HAVING COUNT(*) > 1;
    
    -- Should return 0 rows.
    
    -- Check 2: Total row count matches expectations
    SELECT COUNT(*) AS total_rows FROM customers;
    
    -- Check 3: Spot-check a specific record you know was a duplicate
    SELECT * FROM customers WHERE email = 'sara.chen@acme.com';
    -- Should return exactly 1 row, with customer_id = 1001
    

    If Check 1 returns rows, your deletion didn't catch everything — likely because your grouping key wasn't inclusive enough. If Check 2 shows you deleted far more than expected, you may have had a logic error in your window function's PARTITION BY.

    Note

    Run your verification queries against a transaction-wrapped operation whenever your database supports it. Use BEGIN; DELETE ...; -- run checks -- ROLLBACK; while testing, then COMMIT only when you're satisfied. This gives you a safety net against irreversible mistakes.


    Real-World Project: Auditing and Cleaning a CRM Import

    Let's put all of this together in a realistic sequence you'd actually run as part of a data cleanup task.

    Scenario: Your company just acquired a smaller firm and their CRM data was imported into your customers table last night. You need to identify duplicates, understand what kind they are, deduplicate safely, and report back to the data engineering team.

    -- Step 1: Baseline audit
    SELECT
        COUNT(*)                                AS total_rows,
        COUNT(DISTINCT email)                   AS unique_emails,
        COUNT(*) - COUNT(DISTINCT email)        AS estimated_duplicates
    FROM customers;
    
    -- Step 2: Identify all duplicate email groups with counts
    SELECT
        email,
        COUNT(*)              AS record_count,
        MIN(created_at)       AS first_seen,
        MAX(created_at)       AS last_seen,
        COUNT(DISTINCT source) AS source_variety
    FROM customers
    GROUP BY email
    HAVING COUNT(*) > 1
    ORDER BY record_count DESC;
    
    -- Step 3: Pull the full rows for manual review (limit for safety)
    SELECT
        customer_id,
        email,
        first_name,
        last_name,
        company,
        created_at,
        source
    FROM customers
    WHERE email IN (
        SELECT email
        FROM customers
        GROUP BY email
        HAVING COUNT(*) > 1
    )
    ORDER BY email, created_at
    LIMIT 100;
    
    -- Step 4: Tag rows with their rank within each duplicate group
    -- (Preview only — do not delete yet)
    SELECT
        customer_id,
        email,
        created_at,
        source,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY
                -- Prefer webform over import; then prefer oldest record
                CASE source WHEN 'webform' THEN 1 ELSE 2 END ASC,
                created_at ASC,
                customer_id ASC
        ) AS keep_rank
    FROM customers
    WHERE email IN (
        SELECT email
        FROM customers
        GROUP BY email
        HAVING COUNT(*) > 1
    )
    ORDER BY email, keep_rank;
    
    -- Step 5: Transaction-wrapped deletion
    BEGIN;
    
    DELETE FROM customers
    WHERE customer_id IN (
        SELECT customer_id
        FROM (
            SELECT
                customer_id,
                ROW_NUMBER() OVER (
                    PARTITION BY email
                    ORDER BY
                        CASE source WHEN 'webform' THEN 1 ELSE 2 END ASC,
                        created_at ASC,
                        customer_id ASC
                ) AS keep_rank
            FROM customers
        ) ranked
        WHERE keep_rank > 1
    );
    
    -- Step 6: Verify inside the transaction before committing
    SELECT
        COUNT(*)               AS rows_after,
        COUNT(DISTINCT email)  AS unique_emails_after
    FROM customers;
    
    SELECT email, COUNT(*)
    FROM customers
    GROUP BY email
    HAVING COUNT(*) > 1;
    
    -- If everything looks right:
    COMMIT;
    -- If something looks wrong:
    -- ROLLBACK;
    

    Notice the CASE source WHEN 'webform' THEN 1 ELSE 2 END ASC in the ORDER BY inside OVER(). This implements a business rule: webform submissions represent people who opted in directly and should be preserved over records that came in via bulk import, regardless of which timestamp is earlier. This kind of explicit logic in your deduplication query prevents a whole category of ambiguous decisions from being made silently by whatever record happened to get a lower ID.


    Hands-On Exercise

    Dataset: A fictional event_registrations table:

    CREATE TABLE event_registrations (
        registration_id  INT PRIMARY KEY,
        event_id         INT,
        attendee_email   VARCHAR(255),
        attendee_name    VARCHAR(200),
        registered_at    TIMESTAMP,
        ticket_type      VARCHAR(50)   -- 'vip', 'general', 'staff'
    );
    

    Your tasks:

    1. Write a query that identifies all attendee_email / event_id combinations where the same person registered for the same event more than once.

    2. Extend that query to show the total number of "extra" registrations across the entire table.

    3. Write a ROW_NUMBER() query that ranks the registrations within each email + event_id group. Use this rule: keep vip tickets over general over staff; for ties, keep the earliest registered_at.

    4. Write (but do not execute) the DELETE statement that would remove the duplicate registrations, keeping only the canonical row per attendee per event.

    5. Write the verification query you would run immediately after the deletion.

    Challenge: What happens to your deduplication logic if attendee_email can be NULL? How would you modify your queries to handle registrations where no email was captured?

    Tip

    For the NULL challenge in task 5, think about how PARTITION BY handles NULL values (all NULLs go into the same partition together) and whether that's the behavior you want. The NULL Handling in SQL: IS NULL, COALESCE, and NULLIF article covers the edge cases in detail.


    Common Mistakes & Troubleshooting

    Mistake 1: Deleting from the outer table while referencing it in a subquery (MySQL)

    MySQL doesn't allow you to modify a table and query it in the same statement without a derived table layer. The double-nest pattern (SELECT ... FROM (SELECT ...) alias WHERE ...) is the fix. If you get an error like You can't specify target table 'customers' for update in FROM clause, add that extra layer of nesting.

    Mistake 2: Forgetting to include a tiebreaker in ROW_NUMBER()

    If two rows are identical in every column your ORDER BY uses, the database can assign row_num = 1 to either row non-deterministically across different executions. Always include customer_id ASC (or whatever your primary key is) as a final tiebreaker. This makes your deduplication deterministic and repeatable.

    Mistake 3: Using DISTINCT instead of ROW_NUMBER() for deduplication

    SELECT DISTINCT * removes rows that are identical in every column, which is rarely what you want. More commonly, you need rows that are distinct on a subset of columns (e.g., one row per email), while keeping all columns from one of the matched rows. ROW_NUMBER() is the right tool for this. If you're uncertain when to use DISTINCT versus aggregation approaches, Counting and Grouping with DISTINCT vs GROUP BY: When to Use Each and Why It Matters walks through the trade-offs.

    Mistake 4: Deduplicating without checking foreign keys

    If customer_id is referenced by other tables (orders, invoices, support tickets), deleting a customer row will either fail (with a foreign key constraint error) or cascade-delete a bunch of related records. Before deleting, check whether any of the duplicate IDs you're removing are referenced elsewhere. If so, update those references to point to the canonical record first.

    -- Check for FK references before deleting
    SELECT COUNT(*)
    FROM orders
    WHERE customer_id IN (
        -- paste your list of duplicate customer_ids here
        1003, 1005
    );
    

    If this returns rows, run an UPDATE to remap them before your DELETE:

    UPDATE orders
    SET customer_id = 1001
    WHERE customer_id = 1003;
    

    Mistake 5: Not scoping HAVING correctly

    A query like this is a common error:

    -- Broken: tries to filter on alias 'cnt' in HAVING
    SELECT email, COUNT(*) AS cnt
    FROM customers
    GROUP BY email
    HAVING cnt > 1;  -- works in MySQL, fails in PostgreSQL and SQL Server
    

    The safe, portable version always repeats the aggregate expression:

    HAVING COUNT(*) > 1
    

    Some databases allow referencing column aliases in HAVING, but it's a non-standard extension. Writing the expression directly is the correct, portable approach. Master SQL Aggregate Functions: Advanced GROUP BY, HAVING, and Performance Optimization covers these portability gotchas in more depth.

    Mistake 6: Running the DELETE without a prior SELECT preview

    This one doesn't need much explanation — it just needs emphasis. Every DELETE based on a subquery should be preceded by a SELECT * using the exact same WHERE clause. If the row count and content of that SELECT look right, you can proceed. If they don't, you haven't lost anything.


    Summary & Next Steps

    You now have a complete toolkit for duplicate detection and deduplication:

    • GROUP BY + HAVING COUNT(*) > 1: the core pattern for identifying which values have multiple rows
    • Subquery in IN: for pulling the actual rows behind the duplicated values
    • ROW_NUMBER() with PARTITION BY: for tagging which row to keep and which to remove, with explicit business-rule priority baked into the ORDER BY
    • Transaction-wrapped DELETE: for making the deletion reversible until you've verified it
    • View-based deduplication: for situations where you can't or shouldn't modify the source data

    The most important habit this lesson should leave you with is the audit-first approach: measure the scope of the problem before touching data, define your "which row to keep" logic explicitly in code rather than relying on undefined behavior, and verify the result against your before-snapshot.

    From here, a few natural next steps depending on where your work takes you:

    • If you're working with complex multi-table systems where duplicate detection spans joins, Multi-Table Reporting with JOIN and GROUP BY: Aggregating Across Relationships in a Single Query will show you how to aggregate and detect patterns across related tables.
    • If you want to prevent duplicates from entering the table in the first place, look into unique constraints and schema design — SQL Data Types and Schema Design Mastery: Performance, Constraints, and Evolution Strategies covers how to enforce uniqueness at the database level so you don't need to clean it up manually.
    • If your deduplication logic is getting complex enough to benefit from CTEs for readability and maintainability, Advanced Subqueries and CTEs: Mastering Complex SQL Query Architecture is the right next read.

    Data quality problems like duplicates aren't glamorous, but catching and fixing them before they propagate into reports, ML training sets, or customer-facing systems is genuinely high-leverage work. These are exactly the queries that make you the person everyone trusts with production data.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    SQL Fundamentals

    Previous

    Combining GROUP BY with Multiple JOINs: Aggregating Across Three or More Related Tables

    Related Insights

    SQLFoundation

    Combining GROUP BY with Multiple JOINs: Aggregating Across Three or More Related Tables

    17 min
    SQLExpert

    Translating Outer Query Results into Inner Query Filters: Writing Correlated Subqueries That Reference Parent Scope

    27 min
    SQLPractitioner

    Writing SQL to Identify Top-N Records Per Group: Filtering with Subqueries and Row Ranking Without Window Functions

    23 min

    On this page

    • Prerequisites
    • Setting Up a Realistic Scenario
    • Step 1: Find Duplicate Email Addresses with GROUP BY and HAVING
    • Step 2: Retrieve the Full Duplicate Rows
    • Step 3: Multi-Column Duplicate Detection
    • Step 4: Counting the Full Scope of the Problem
    • Step 5: Using ROW_NUMBER() to Tag Which Rows to Delete
    • Step 6: Safe Deletion with a Subquery
    • Step 7: Deduplication Without Deletion — Creating a Clean View
    • Step 8: Handling "Fuzzy" Duplicates
    • Step 9: Verifying Your Deduplication Worked
    • Real-World Project: Auditing and Cleaning a CRM Import
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps