{"id":"code-dev-sql-dedupe-delete-dry-run","audience":"developers","category":"coding","track":"coding","technique":"context","title":"Write a safe duplicate-cleanup DELETE you can dry-run","free":false,"task":"Write a PostgreSQL DELETE that removes duplicate contacts for tenant_id 42 only. Emails that differ only in case count as the same, and for each email you keep the OLDEST row, the one with the lowest id, because invoices link to it. Wrap it as BEGIN; ... ROLLBACK; so the reviewer can dry-run it, with RETURNING id so they see what would go. Exactly 1 code block of at most 14 lines, under 25 words of text.","materialLabel":"The table and sample rows (attached to your prompt automatically)","material":"-- PostgreSQL 16, multi-tenant CRM (Brightloom)\nCREATE TABLE contacts (\n  id          BIGSERIAL PRIMARY KEY,\n  tenant_id   INT NOT NULL,\n  email       TEXT NOT NULL,\n  full_name   TEXT,\n  updated_at  TIMESTAMPTZ NOT NULL\n);\n\n-- sample rows, tenant 42\n-- id   | tenant | email               | updated_at\n-- 1001 | 42     | ana@kestrel.test    | 2026-09-01 10:00\n-- 1002 | 42     | Ana@Kestrel.test    | 2026-10-15 08:30\n-- 1003 | 42     | ben@kestrel.test    | 2026-10-20 12:00\n-- 1004 | 42     | ben@kestrel.test    | 2026-10-20 12:00\n-- 1005 | 42     | cara@kestrel.test   | 2026-08-02 09:15\n\n-- sample rows, tenant 7 (must NOT be touched, they're on a\n-- different plan and have their own cleanup next month)\n-- 2001 | 7      | ana@kestrel.test    | 2026-07-01 11:00\n-- 2002 | 7      | ana@kestrel.test    | 2026-07-03 11:00\n\n-- Ticket CRM-940 from support (Jo): \"Tenant 42 imported their list\n-- twice and now sees duplicate contacts. They asked us to clean it up.\"\n-- Table has ~2.1M rows across all tenants.","rules":[{"type":"codeBlocks","count":1},{"type":"maxCodeLines","max":14},{"type":"maxProseWords","max":25}],"mistakes":[{"title":"Touching every tenant","why":"Without the tenant filter, the AI's DELETE also wipes tenant 7's rows.","fix":"Add: \"Only tenant_id = 42.\"","checks":["r0"]},{"title":"Case-sensitive matching","why":"Ana@ and ana@ look different to a plain comparison, so a duplicate stays.","fix":"Add: \"Emails that differ only in case are duplicates: use lower(email).\"","checks":["r0"]},{"title":"Keeping the wrong row","why":"The AI usually keeps the newest row, but invoices point at the oldest one.","fix":"Add: \"Keep the row with the lowest id per email.\"","checks":["r1"]},{"title":"Running it for real","why":"A DELETE with COMMIT can't be reviewed first.","fix":"Add: \"Wrap in BEGIN; ... ROLLBACK; with RETURNING id.\"","checks":["r2"]},{"title":"A query plus alternatives","why":"The AI offers a CTE version, a USING version and a lecture.","fix":"Add: \"One statement block in one code block, no alternatives.\"","checks":["codeBlocks","maxCodeLines","maxProseWords"]}],"promptHints":[{"label":"Limits to one tenant","patterns":["tenant","\\b42\\b"]},{"label":"Handles case","patterns":["case","lower"]},{"label":"Gives the keep rule","patterns":["oldest","lowest id","keep the (first|original)"]},{"label":"Asks for a dry run","patterns":["rollback","dry.?run","begin"]},{"label":"Asks to see what's deleted","patterns":["returning"]}],"hint":"A DELETE is only safe if it hits exactly the rows you mean and can be checked first. Tell the AI the scope, the duplicate rule, which row to keep and how to dry-run it.","rubricLabels":["Only deletes rows where tenant_id = 42, and treats emails as duplicates case-insensitively (e.g. lower(email) in the PARTITION BY or join)","Keeps the row with the lowest id per email (e.g. ROW_NUMBER() OVER (PARTITION BY lower(email) ORDER BY id ASC) and deleting rn > 1), not the newest updated_at","Starts with BEGIN;, the DELETE has RETURNING id, and it ends with ROLLBACK; (not COMMIT)"]}