Columnar and time series databases
In short: A columnar database stores each column separately so reports over millions of rows read fast, and a time series database stores timestamped measurements such as sensor readings.
Problem
The report wants two columns and reads seven
The table sales from the previous lesson has seven columns: order, day, city, category, product, quantity and amount. The report on revenue by month wants two of them, day and amount.
An ordinary database stores the data by rows: everything about one sale is together on disk. To read two columns, it reads the whole rows and throws away the other five. With a billion sales that is hundreds of gigabytes, of which a little over a quarter is needed.
The idea
Every column on its own
A columnar database (ClickHouse, BigQuery, Snowflake, DuckDB) stores every column in a separate file. The revenue report opens only the files of day and amount. And a column holds the same kind of value next to each other (only dates, only cities), so it compresses much better than a row in which they are mixed.
The simplest compression is a run: instead of "Sofia, Sofia, Sofia…" you write "Sofia × 50". The whole column city, sorted by city, is seven runs:
It works only if the equal values are next to each other. Here is how many runs the column has when the rows are in the order of the orders, and how many when they are sorted by city:
Out of 128 values 39 runs are left if the rows are not sorted, and 7 if they are. That is why in ClickHouse every table is made with ORDER BY. The order of the columns in it is the sort order on disk.
More precisely, if you are curious
LAG(city, 1, '-') gives the city of the previous row (and '-' for the first row), and every row in which the city changes starts a new run. IS NOT instead of <> is because NULL <> NULL is not true, while NULL IS NOT NULL is a clear "no". Real columnar databases also use other kinds of compression: a dictionary (the cities become the numbers 1–7), differences between neighbouring numbers, and a general algorithm on top (LZ4, ZSTD). Compression of 10 times is not rare.
The idea
When columnar, when not
A columnar database is excellent for reports: few columns, many rows, sums and groups. It is bad for the till. One new order means a write into seven separate files, and an UPDATE of one row often means rewriting a whole chunk of the column. That is why columnar databases like to write in big batches (thousands of rows at once) and rarely change. This is the OLTP and OLAP split from the previous lesson, only on disk.
Problem
The dairy fridge
The fridge with the dairy products has a sensor that records the temperature every 15 minutes. Above 8 °C the cheese and the milk go off quickly, and the shop has to know when it happened. Such data is a time series: values ordered by time, which are only added and almost never changed. Two days of readings:
Two days of 96 readings should be 192, but they are 184. Eight are missing, and you will see where.
The idea
Downsampling to hours
Nobody looks at 35,000 points on a chart for a year. The readings are grouped by hour (or day) with a few numbers for each: the average, the lowest and the highest. This is downsampling. The hour is the first 13 characters of the time:
The average for 14:00 is 8.9 °C, and the highest is 9.7 °C. The door stood open.
Try it
When is the alarm
Show the hours in which at least one reading is above 8 °C (columns hour and max_temp, the highest temperature in the hour), in the order of the hour.
Solution
SELECT substr(at, 1, 13) || ':00' AS hour, MAX(temp) AS max_temp
FROM fridge
GROUP BY hour
HAVING MAX(temp) > 8
ORDER BY hour;GROUP BY the hour and HAVING MAX(temp) > 8. With the average (AVG) an hour in which the temperature was 9 °C for 15 minutes and 4 °C the rest of the time would not show. For spoiled products what matters is the highest, not the average.
The idea
A moving average
Sensors sometimes give a single wrong value, and the fridge is opened for seconds by every customer. So the alarm often looks not at one reading but at the average of the last four (the last hour). It moves with every new row: a window function with ROWS BETWEEN 3 PRECEDING AND CURRENT ROW:
The average goes above 8 °C only at 14:45, when the door has been open for an hour. One random high reading would raise it by about 1 °C, but not above 8.
The window function is in the inner query, and the period is outside. WHERE runs before the window: if the condition for the period is in the same query, the average for 13:30 does not see 12:45, 13:00 and 13:15 and is from one reading only.
Try it
The gap
The sensor measures every 15 minutes. Find where it lost the connection: the last reading before the gap and the first one after it (columns last_before and first_after).
Solution
SELECT before AS last_before, at AS first_after
FROM (SELECT at, LAG(at) OVER (ORDER BY at) AS before FROM fridge) AS t
WHERE ROUND((julianday(at) - julianday(before)) * 24 * 60) > 15;LAG(at) gives the previous reading, and julianday turns the time into days. The difference times 24 × 60 is in minutes, and anything above 15 is a gap. ROUND is needed because days are fractional numbers and 15 minutes come out as 15.000000xxx. Without the multiplication the difference is in days (0.01 for 15 minutes) and is never above 15.
The idea
A retention period
The raw readings grow by 35,000 rows a year for one sensor, and the shop has hundreds. The usual thing is a table by hour, kept for a long time, and raw readings kept briefly (here for one day). The hourly table is made as in the data warehouse, with CREATE TABLE … AS SELECT:
The hours 03:00 and 04:00 are missing in it too: there is not a single reading from them. The chart has to show an empty space, not join 02:00 and 05:00 with a straight line as if everything was fine.
Exercise
Cleaning up and a report
1. The hourly table is already made.
Delete the raw readings from before 25 October 2026. The table fridge_hourly stays whole.
Solution
DELETE FROM fridge WHERE at < '2026-10-25';DELETE FROM fridge WHERE at < '2026-10-25'. The text '2026-10-25 00:00' is greater than '2026-10-25', so the midnight reading stays. With <= '2026-10-25 00:00' the first reading of the day you keep would be deleted too. The check also looks at fridge_hourly: the hours stay 46.
2. A report for the day.
For every day: the lowest, the highest and the average temperature (rounded to 0.01) and the number of readings (columns day, min_temp, max_temp, avg_temp, readings), in the order of the day.
Solution
SELECT substr(at, 1, 10) AS day, MIN(temp) AS min_temp, MAX(temp) AS max_temp, ROUND(AVG(temp), 2) AS avg_temp, COUNT(*) AS readings
FROM fridge
GROUP BY day
ORDER BY day;substr(at, 1, 10) is the day. COUNT(*) shows that the 25th has 88 readings, not 96. A report without the count would hide that the sensor was silent for two hours.
The idea
Databases for time series
There are also separate databases for time series: TimescaleDB (an extension of PostgreSQL that splits the table by time on its own and downsamples to hours on its own), InfluxDB and Prometheus (for server metrics). They all do the same as in the lesson: sort by time, compress the columns, keep downsampled tables and delete raw data after a period. The columnar ClickHouse and DuckDB handle them well too.
More precisely, if you are curious
In TimescaleDB an hourly table that updates itself is CREATE MATERIALIZED VIEW … WITH (timescaledb.continuous) AS SELECT time_bucket('1 hour', at) …, and the retention period is SELECT add_retention_policy('fridge', INTERVAL '1 day'). SQLite has nothing like it: the hourly table and the deleting are run on a schedule by the program.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: CREATE TABLE: a new table · Natural keys and denormalization · Indexes · OLTP and OLAP
- Continues in: How to choose a database
- Tasks on this topic: Kinds of databases