This tutorial models sensor readings with timestamps and intervals.
You will use DATE_TRUNC, EXTRACT,
AGE, TO_CHAR, recursive CTEs to fill date
gaps, and window frames for moving averages—showcasing the
temporal SQL that mskql supports natively.
CREATE TABLE sensors (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
location TEXT NOT NULL
);
CREATE TABLE readings (
id SERIAL PRIMARY KEY,
sensor_id INT NOT NULL REFERENCES sensors(id),
recorded TIMESTAMP NOT NULL,
temp_c INT NOT NULL,
humidity INT
);
INSERT INTO sensors (name, location) VALUES
('TH-01', 'Warehouse A'),
('TH-02', 'Warehouse B'),
('TH-03', 'Office');
INSERT INTO readings (sensor_id, recorded, temp_c, humidity) VALUES
(1, '2025-01-10 08:00:00', 18, 45),
(1, '2025-01-10 12:00:00', 22, 40),
(1, '2025-01-10 18:00:00', 20, 42),
(1, '2025-01-11 08:00:00', 17, 48),
(1, '2025-01-11 12:00:00', 23, 38),
(1, '2025-01-11 18:00:00', 21, 41),
(1, '2025-01-12 08:00:00', 19, 44),
(1, '2025-01-12 12:00:00', 24, 36),
(2, '2025-01-10 08:00:00', 15, 55),
(2, '2025-01-10 12:00:00', 19, 50),
(2, '2025-01-10 18:00:00', 16, 53),
(2, '2025-01-11 08:00:00', 14, 58),
(2, '2025-01-11 12:00:00', 20, 48),
(3, '2025-01-10 09:00:00', 21, 35),
(3, '2025-01-10 14:00:00', 23, 33),
(3, '2025-01-11 09:00:00', 22, 34),
(3, '2025-01-11 14:00:00', 24, 32);
Truncate timestamps to the day and compute daily averages per sensor.
SELECT s.name,
DATE_TRUNC('day', r.recorded)
AS day,
AVG(r.temp_c) AS avg_temp,
AVG(r.humidity) AS avg_hum
FROM readings r
JOIN sensors s
ON r.sensor_id = s.id
WHERE s.name = 'TH-01'
GROUP BY s.name,
DATE_TRUNC('day', r.recorded)
ORDER BY day;
Result
| name | day | avg_temp | avg_hum |
|---|---|---|---|
| TH-01 | 2025-01-10 00:00:00 | 20 | 42 |
| TH-01 | 2025-01-11 00:00:00 | 20 | 42 |
| TH-01 | 2025-01-12 00:00:00 | 21 | 40 |
Pull the hour from each reading to see temperature patterns across the day.
SELECT
EXTRACT(HOUR FROM r.recorded)
AS hour,
AVG(r.temp_c) AS avg_temp,
COUNT(*) AS readings
FROM readings r
JOIN sensors s
ON r.sensor_id = s.id
WHERE s.location = 'Warehouse A'
GROUP BY
EXTRACT(HOUR FROM r.recorded)
ORDER BY hour;
Result
| hour | avg_temp | readings |
|---|---|---|
| 8 | 18 | 3 |
| 12 | 23 | 3 |
| 18 | 20 | 2 |
Use AGE to compute the interval between the latest reading
per sensor and a reference timestamp.
SELECT s.name,
MAX(r.recorded) AS last_read,
AGE('2025-01-13 00:00:00',
MAX(r.recorded))
AS time_since
FROM readings r
JOIN sensors s
ON r.sensor_id = s.id
GROUP BY s.name
ORDER BY s.name;
Result
| name | last_read | time_since |
|---|---|---|
| TH-01 | 2025-01-12 12:00:00 | 0 years 0 mons 0 days 12 hours 0 mins 0 secs |
| TH-02 | 2025-01-11 12:00:00 | 0 years 0 mons 1 days 12 hours 0 mins 0 secs |
| TH-03 | 2025-01-11 14:00:00 | 0 years 0 mons 1 days 10 hours 0 mins 0 secs |
Format timestamps for human-readable display.
SELECT s.name,
TO_CHAR(r.recorded,
'YYYY-MM-DD HH24:MI')
AS time,
r.temp_c
FROM readings r
JOIN sensors s
ON r.sensor_id = s.id
WHERE s.name = 'TH-03'
ORDER BY r.recorded;
Result
| name | time | temp_c |
|---|---|---|
| TH-03 | 2025-01-10 09:00 | 21 |
| TH-03 | 2025-01-10 14:00 | 23 |
| TH-03 | 2025-01-11 09:00 | 22 |
| TH-03 | 2025-01-11 14:00 | 24 |
Compute a 3-reading moving average of temperature for sensor TH-01, using a window frame.
SELECT r.recorded,
r.temp_c,
AVG(r.temp_c) OVER (
ORDER BY r.recorded
ROWS BETWEEN 2 PRECEDING
AND CURRENT ROW
) AS moving_avg
FROM readings r
WHERE r.sensor_id = 1
ORDER BY r.recorded;
Result
| recorded | temp_c | moving_avg |
|---|---|---|
| 2025-01-10 08:00:00 | 18 | 18 |
| 2025-01-10 12:00:00 | 22 | 20 |
| 2025-01-10 18:00:00 | 20 | 20 |
| 2025-01-11 08:00:00 | 17 | 19 |
| 2025-01-11 12:00:00 | 23 | 20 |
| 2025-01-11 18:00:00 | 21 | 20 |
| 2025-01-12 08:00:00 | 19 | 21 |
| 2025-01-12 12:00:00 | 24 | 21 |
Generate a continuous date series and LEFT JOIN readings to it, exposing days with no data.
WITH RECURSIVE dates AS (
SELECT '2025-01-10'::DATE AS dt
UNION ALL
SELECT (dt + 1)::DATE
FROM dates
WHERE dt < '2025-01-14'::DATE
)
SELECT d.dt AS day,
COALESCE(
(SELECT AVG(r.temp_c)
FROM readings r
WHERE r.sensor_id = 2
AND DATE_TRUNC('day',
r.recorded) = d.dt
::TIMESTAMP),
0) AS avg_temp
FROM dates d
ORDER BY d.dt;
Result
| day | avg_temp |
|---|---|
| 2025-01-10 | 16 |
| 2025-01-11 | 17 |
| 2025-01-12 | 0 |
| 2025-01-13 | 0 |
| 2025-01-14 | 0 |
Days with no readings for sensor TH-02 show 0 thanks to
COALESCE. In a real dashboard you might display these as
“no data” or interpolate.
DATE_TRUNC to bucket timestamps by day, hour, or monthEXTRACT to pull individual fields (hour, day, month) from timestampsAGE to compute intervals between timestampsTO_CHAR for formatted timestamp display:: cast operator for type conversions (e.g. '2025-01-10'::DATE)ROWS BETWEEN n PRECEDING AND CURRENT ROW) for moving averages