NightFox

NightFox

Useful MySQL Query Patterns

After enough years of business apps, you notice that almost every query is one of a handful of patterns: filtered lists, totals, "the latest one for each," "does this exist," upserts, and batch jobs. These are the versions I keep reaching for.

Existence check

SELECT EXISTS (
    SELECT 1
    FROM users
    WHERE email = :email
) AS email_exists;

If you only need to know whether a row exists, use EXISTS. Don't COUNT(*) the whole thing just to check if it's more than zero.

Latest child record per parent

SELECT t.*
FROM trip_status_history AS t
JOIN (
    SELECT trip_id, MAX(id) AS latest_id
    FROM trip_status_history
    GROUP BY trip_id
) AS latest ON latest.latest_id = t.id;

On MySQL 8, a window function is often clearer, especially if there's more than one tiebreaker.

WITH ranked AS (
    SELECT
        h.*,
        ROW_NUMBER() OVER (
            PARTITION BY trip_id
            ORDER BY created_at DESC, id DESC
        ) AS row_num
    FROM trip_status_history AS h
)
SELECT *
FROM ranked
WHERE row_num = 1;

Conditional aggregates

SELECT
    COUNT(*) AS total,
    SUM(status = 'open') AS open_count,
    SUM(status = 'closed') AS closed_count,
    SUM(priority = 'urgent' AND status != 'closed') AS urgent_open_count
FROM tickets;

Upsert

INSERT INTO vehicle_telemetry (
    vehicle_id,
    recorded_at,
    latitude,
    longitude
) VALUES (
    :vehicle_id,
    :recorded_at,
    :latitude,
    :longitude
)
ON DUPLICATE KEY UPDATE
    latitude = VALUES(latitude),
    longitude = VALUES(longitude);

This depends on a unique key that really identifies the record. Be careful, though: if two events are genuinely separate, an upsert will quietly squash them into one.

Keyset pagination

SELECT id, created_at, subject
FROM tickets
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Batch updates

UPDATE notifications
SET archived_at = UTC_TIMESTAMP()
WHERE archived_at IS NULL
  AND created_at < UTC_TIMESTAMP() - INTERVAL 90 DAY
ORDER BY id
LIMIT 1000;

Do big updates in small batches, and watch replication lag, lock waits, and whether the app slows down. One giant UPDATE on a busy table is a great way to ruin everyone's afternoon.

Anti-join: find missing relationships

SELECT c.id, c.name
FROM customers AS c
LEFT JOIN contacts AS ct ON ct.customer_id = c.id
WHERE ct.id IS NULL;

Last Modified: October 12, 2026, 11:46 am