Recursive CTE — Shipment Event Timeline Reconstruction
Recursive CTE — Shipment Event Timeline Reconstruction
Delivery event logs form a sequence that must be traversed in order. This lesson uses recursive CTEs to reconstruct the full journey of a shipment and identifies where delays occurred.
1. Event Timeline for a Single Shipment
SELECT
de.status,
de.location,
de.occurred_at,
'' AS notes,
LAG(de.occurred_at) OVER (
PARTITION BY de.shipment_id ORDER BY de.occurred_at
) AS prev_event_time,
de.occurred_at - LAG(de.occurred_at) OVER (
PARTITION BY de.shipment_id ORDER BY de.occurred_at
) AS time_since_prev_event
FROM lg_delivery_events de
WHERE de.shipment_id = 1
ORDER BY de.occurred_at;
2. Longest Gap Between Events (Delay Detection)
Find where in the journey a shipment spent the most time:
WITH event_gaps AS (
SELECT
shipment_id,
status,
location,
occurred_at,
LAG(occurred_at) OVER (
PARTITION BY shipment_id ORDER BY occurred_at
) AS prev_time,
EXTRACT(EPOCH FROM (
occurred_at - LAG(occurred_at) OVER (
PARTITION BY shipment_id ORDER BY occurred_at
)
)) / 3600.0 AS gap_hours
FROM lg_delivery_events
)
SELECT
s.order_ref,
w_o.code || ' → ' || w_d.code AS route,
s.carrier,
eg.status AS event_after_gap,
eg.location,
ROUND(eg.gap_hours, 1) AS gap_hours,
eg.occurred_at::DATE
FROM event_gaps eg
JOIN lg_shipments s ON s.id = eg.shipment_id
JOIN lg_warehouses w_o ON w_o.id = s.warehouse_id
JOIN lg_warehouses w_d ON w_d.id = s.warehouse_id
WHERE eg.gap_hours IS NOT NULL
ORDER BY eg.gap_hours DESC
LIMIT 20;
What This Returns
tracking_number | route | carrier | event_after_gap | gap_hours
----------------+---------------+---------+-----------------+-----------
TRK000003 | US-CH → UK-LN | DHL | in_transit | 115.0
TRK000009 | NL-AM → SG-01 | Maersk | in_transit | 115.0
...
3. Recursive CTE — Build a Shipment Chain
A recursive CTE traverses connected events. Here we build the full path of each shipment:
WITH RECURSIVE journey AS (
-- Base: first event for each shipment
SELECT
de.shipment_id,
de.id AS event_id,
de.status,
de.location,
de.occurred_at,
1 AS step,
de.location::TEXT AS path
FROM lg_delivery_events de
WHERE de.id IN (
SELECT MIN(id) FROM lg_delivery_events GROUP BY shipment_id
)
UNION ALL
-- Recursive: next event for same shipment
SELECT
de.shipment_id,
de.id,
de.status,
de.location,
de.occurred_at,
j.step + 1,
j.path || ' → ' || de.location
FROM lg_delivery_events de
JOIN journey j ON j.shipment_id = de.shipment_id
AND de.occurred_at > j.occurred_at
AND de.id = (
SELECT MIN(id)
FROM lg_delivery_events
WHERE shipment_id = de.shipment_id AND occurred_at > j.occurred_at
)
WHERE j.step < 10
)
SELECT
shipment_id,
MAX(step) AS total_events,
MAX(path) AS full_path
FROM journey
GROUP BY shipment_id
ORDER BY total_events DESC
LIMIT 10;
4. Average Events per Shipment by Status
SELECT
CASE WHEN s.delivered_at IS NULL THEN 'in-transit' ELSE 'delivered' END,
COUNT(DISTINCT s.id) AS shipments,
COUNT(de.id) AS total_events,
ROUND(COUNT(de.id)::NUMERIC / NULLIF(COUNT(DISTINCT s.id), 0), 1) AS avg_events_per_shipment,
COUNT(de.id) FILTER (WHERE de.status = 'exception') AS exception_events,
CASE WHEN s.delivered_at IS NULL THEN 'in-transit' ELSE 'delivered' END AS state
FROM lg_shipments s
LEFT JOIN lg_delivery_events de ON de.shipment_id = s.id
GROUP BY state
ORDER BY avg_events_per_shipment DESC;
Key Takeaway
The LAG(occurred_at) OVER (PARTITION BY shipment_id ORDER BY occurred_at) pattern reconstructs event sequences without a recursive CTE for simple gap calculations. Use a full recursive CTE when you need the accumulated path or when traversal depth is variable. The step < 10 guard in the recursive CTE prevents infinite loops — always include a depth limit when the termination condition is data-dependent.