sql

11m read · 2292 words

Clause execution order

  1. FROM, JOIN, ON - Take in all source tables. Join them to create a single large table.
  2. WHERE - Filter the large combined table to remove unnecessary rows.
  3. GROUP BY
  4. HAVING
  5. Window functions, SELECT, distinct() - Select required columns from the table. Apply window functions if required.
  6. ORDER BY
  7. LIMIT
  8. Return the data as result.

Nulls

Key types

Date handling

CAST - most portable function (part of SQL spec itself)

-- sqlserver
SELECT TRY_CONVERT(date, '26-09-2026', 105);
SELECT YEAR(date_column)
SELECT MONTH(date_col)
SELECT DATE(date_col)

-- postgres
SELECT TO_DATE('26-09-2026', 'DD-MM-YYYY');
SELECT EXTRACT(YEAR FROM date_column)
SELECT EXTRACT(MONTH FROM date_col)
SELECT EXTRACT(DAY FROM date_col)

-- spark
SELECT to_date('26-09-2026', 'dd-MM-yyyy');
SELECT YEAR(date_column)

TRY_CONVERT formatters - Sqlserver - Date and time styles

String operations

TRIM(str_col)

Wildcard characters

WHERE name LIKE 'John%'

Case sensitivity of LIKE changes with dbms.

Gaps and islands

Problem Typical technique
Consecutive values/dates ROW_NUMBER() + grouping (make sure to dedupe)
Detect gaps LAG()
Build groups from boundaries LAG() + cumulative SUM()
Merge overlapping ranges Running MAX() + cumulative SUM()

Merge overlapping ranges | Running MAX() + cumulative SUM() |

Sample questions dump

Sessionization

Session in 30 minute breaks

For raw event logs stored in a sql database, count user sessions. A new session starts when the gap between an event and the immediately preceding event is greater than 30 minutes.

Columns: userid, eventtime

Approach:

  1. Sort events by user_id, event_time.
  2. Use LAG() to get the previous event time for each user.
  3. Start a new session when the gap from the previous event is > 30 minutes.
  4. Cumulatively sum the session-start flags to assign a session_id.
  5. Count sessions.
WITH events_with_prev AS (
    SELECT
        user_id,
        event_time,
        LAG(event_time) OVER (
            PARTITION BY user_id
            ORDER BY event_time
        ) AS prev_event_time
    FROM events
),

session_starts AS (
    SELECT
        *,
        CASE
            WHEN prev_event_time IS NULL
              OR DATEDIFF(MINUTE, prev_event_time, event_time) > 30
            THEN 1
            ELSE 0
        END AS is_new_session
    FROM events_with_prev
),

sessionized AS (
    SELECT
        *,
        SUM(is_new_session) OVER (
            PARTITION BY user_id
            ORDER BY event_time
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS session_id
    FROM session_starts
)

SELECT
    user_id,
    COUNT(DISTINCT session_id) AS session_count
FROM sessionized
GROUP BY user_id;

session between a login and logout event

simply count logins

SELECT
    user_id,
    COUNT(*) AS session_count
FROM events
WHERE event = 'login'
GROUP BY user_id;

If want to consider logout too, and there are activities between login and logout:

WITH flagged AS (
    SELECT
        user_id,
        event_time,
        event,

        CASE
            WHEN event = 'login' THEN 1
            ELSE 0
        END AS is_login

    FROM events
),

sessionized AS (
    SELECT
        *,
        SUM(is_login) OVER (
            PARTITION BY user_id
            ORDER BY event_time
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS session_id

    FROM flagged
)

SELECT
    user_id,
    session_id,
    MIN(CASE WHEN event = 'login' THEN event_time END) AS login_time,
    MAX(CASE WHEN event = 'logout' THEN event_time END) AS logout_time
FROM sessionized
GROUP BY
    user_id,
    session_id;

Longest consecutive streak

Approach:

WITH distinct_days AS (
    SELECT DISTINCT
        user_id,
        activity_date
    FROM user_activity
),

numbered AS (
    SELECT
        user_id,
        activity_date,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY activity_date
        ) AS rn
    FROM distinct_days
),

islands AS (
    SELECT
        user_id,
        activity_date,
        DATEADD(DAY, -rn, activity_date) AS island_id
    FROM numbered
),

streaks AS (
    SELECT
        user_id,
        MIN(activity_date) AS streak_start,
        MAX(activity_date) AS streak_end,
        COUNT(*) AS streak_days
    FROM islands
    GROUP BY
        user_id,
        island_id
),

ranked AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY streak_days DESC
        ) AS rank
    FROM streaks
)

SELECT
    user_id,
    streak_start,
    streak_end,
    streak_days
FROM ranked
WHERE rank = 1;

Gaps

Find missing dates

Find dates on which a user had no activity.

Approach:

WITH distinct_days AS (
    SELECT DISTINCT
        user_id,
        activity_date
    FROM user_activity
),

gaps AS (
    SELECT
        user_id,
        activity_date,
        LAG(activity_date) OVER (
            PARTITION BY user_id
            ORDER BY activity_date
        ) AS previous_date
    FROM distinct_days
),

missing_dates AS (
    SELECT
        user_id,
        DATEADD(DAY, 1, previous_date) AS missing_date,
        activity_date AS gap_end
    FROM gaps
    WHERE DATEDIFF(DAY, previous_date, activity_date) > 1

    UNION ALL

    SELECT
        user_id,
        DATEADD(DAY, 1, missing_date),
        gap_end
    FROM missing_dates
    WHERE missing_date < DATEADD(DAY, -1, gap_end)
)

SELECT
    user_id,
    missing_date
FROM missing_dates
ORDER BY
    user_id,
    missing_date;

Islands

stock price above 100

Find periods where a stock's price was above $100 for consecutive trading days.

WITH flagged AS (
    SELECT
        stock_symbol,
        trading_date,
        close_price,

        CASE
            WHEN close_price > 100 THEN 1
            ELSE 0
        END AS is_above_100
    FROM stock_prices
),

with_previous AS (
    SELECT
        *,
        LAG(is_above_100) OVER (
            PARTITION BY stock_symbol
            ORDER BY trading_date
        ) AS previous_is_above_100
    FROM flagged
),

island_starts AS (
    SELECT
        *,
        CASE
            WHEN is_above_100 = 1
             AND (previous_is_above_100 = 0
                  OR previous_is_above_100 IS NULL)
            THEN 1
            ELSE 0
        END AS is_new_island
    FROM with_previous
),

islands AS (
    SELECT
        *,
        SUM(is_new_island) OVER (
            PARTITION BY stock_symbol
            ORDER BY trading_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS island_id
    FROM island_starts
)

SELECT
    stock_symbol,
    MIN(trading_date) AS period_start,
    MAX(trading_date) AS period_end,
    COUNT(*) AS consecutive_trading_days
FROM islands
WHERE is_above_100 = 1
GROUP BY
    stock_symbol,
    island_id
ORDER BY
    stock_symbol,
    period_start;

customer with consecutive day purchases

Find customers who made purchases on 3 or more consecutive days.

Approach:

WITH distinct_days AS (
    SELECT DISTINCT
        customer_id,
        CAST(purchase_time AS DATE) AS purchase_date
    FROM purchases
),

numbered AS (
    SELECT
        customer_id,
        purchase_date,

        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY purchase_date
        ) AS rn

    FROM distinct_days
),

islands AS (
    SELECT
        customer_id,
        purchase_date,

        DATEADD(
            DAY,
            -rn,
            purchase_date
        ) AS island_id

    FROM numbered
),

streaks AS (
    SELECT
        customer_id,
        MIN(purchase_date) AS streak_start,
        MAX(purchase_date) AS streak_end,
        COUNT(*) AS streak_days

    FROM islands

    GROUP BY
        customer_id,
        island_id
)

SELECT
    customer_id,
    streak_start,
    streak_end,
    streak_days
FROM streaks
WHERE streak_days >= 3
ORDER BY
    customer_id,
    streak_start;

Employee consecutive employment periods

Given employment records, find continuous periods during which an employee worked for a company.

For example, an employee might have multiple employment records where one period immediately follows another.

Approach:

WITH previous_periods AS (
    SELECT
        employee_id,
        company_id,
        start_date,
        end_date,

        LAG(end_date) OVER (
            PARTITION BY employee_id
            ORDER BY start_date
        ) AS previous_end_date

    FROM employment
),

period_starts AS (
    SELECT
        *,
        CASE
            WHEN previous_end_date IS NULL
              OR start_date > DATEADD(DAY, 1, previous_end_date)
            THEN 1
            ELSE 0
        END AS is_new_period

    FROM previous_periods
),

islands AS (
    SELECT
        *,
        SUM(is_new_period) OVER (
            PARTITION BY employee_id
            ORDER BY start_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS period_id

    FROM period_starts
)

SELECT
    employee_id,
    MIN(start_date) AS employment_start,
    MAX(end_date) AS employment_end
FROM islands
GROUP BY
    employee_id,
    period_id
ORDER BY
    employee_id,
    employment_start;

Merge overlapping date ranges

Merge overlapping reservations.

Cols:

Approach:

WITH previous_ranges AS (
    SELECT
        room_id,
        reservation_id,
        start_date,
        end_date,

        MAX(end_date) OVER (
            PARTITION BY room_id
            ORDER BY start_date, end_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
        ) AS previous_max_end

    FROM reservations
),

island_starts AS (
    SELECT
        *,
        CASE
            WHEN previous_max_end IS NULL
              OR start_date > previous_max_end
            THEN 1
            ELSE 0
        END AS is_new_island

    FROM previous_ranges
),

islands AS (
    SELECT
        *,
        SUM(is_new_island) OVER (
            PARTITION BY room_id
            ORDER BY start_date, end_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS island_id

    FROM island_starts
)

SELECT
    room_id,
    MIN(start_date) AS merged_start,
    MAX(end_date) AS merged_end
FROM islands
GROUP BY
    room_id,
    island_id
ORDER BY
    room_id,
    merged_start;

Given server status events, identify continuous periods of downtime.

10:00 UP
10:05 DOWN
10:10 DOWN
10:15 DOWN
10:20 UP

can use either :

  1. LAG() to detect state transitions
  2. LEAD() to pair a state with its recovery event.

LAG() to detect state transitions

WITH previous_status AS (
    SELECT
        server_id,
        event_time,
        status,

        LAG(status) OVER (
            PARTITION BY server_id
            ORDER BY event_time
        ) AS previous_status

    FROM server_status
),

downtime_starts AS (
    SELECT
        *,
        CASE
            WHEN status = 'DOWN'
             AND (previous_status <> 'DOWN'
                  OR previous_status IS NULL)
            THEN 1
            ELSE 0
        END AS is_downtime_start

    FROM previous_status
),

downtime_groups AS (
    SELECT
        *,
        SUM(is_downtime_start) OVER (
            PARTITION BY server_id
            ORDER BY event_time
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS downtime_id

    FROM downtime_starts
)

SELECT
    server_id,
    downtime_id,
    MIN(event_time) AS downtime_start,
    MAX(event_time) AS last_down_event
FROM downtime_groups
WHERE status = 'DOWN'
GROUP BY
    server_id,
    downtime_id
ORDER BY
    server_id,
    downtime_start;

LEAD() to pair a state with its recovery event.

WITH events AS (
    SELECT
        server_id,
        event_time,
        status,

        LEAD(event_time) OVER (
            PARTITION BY server_id
            ORDER BY event_time
        ) AS next_event_time,

        LEAD(status) OVER (
            PARTITION BY server_id
            ORDER BY event_time
        ) AS next_status

    FROM server_status
)

SELECT
    server_id,
    event_time AS downtime_start,
    next_event_time AS recovery_time
FROM events
WHERE status = 'DOWN'
  AND next_status = 'UP';
Colophon 2292 words · 11m read
Written as a markdown note in Obsidian. Built into this page by a Python script on 2026-10-02.

Pages