Do You Really Need TimescaleDB? When Materialized Views Are Enough
I regret to inform you that having a created_at column does not make your data time-series. Allow me to explain the distinction — and what to do about it.
The question most teams are actually asking
Good evening. I have been observing a pattern, and I should like to discuss it with you candidly.
Someone on your team opens a Slack thread: "Our events table has 14 million rows and the dashboard query takes 2.4 seconds. Should we use TimescaleDB?" The thread fills promptly. Someone links the TimescaleDB homepage. Someone else mentions hypertables with the quiet confidence of a person who read about them over breakfast. A third has already begun writing the Terraform for a Timescale Cloud instance. This is the infrastructural equivalent of calling a structural engineer because a shelf is crooked.
The question your team is actually asking is not "should we use TimescaleDB?" It is "how do I make this time-stamped query fast?" Those are different questions with very different answers, and conflating them leads to architectural commitments that are, if you'll forgive me, considerably easier to make than to unmake.
Let me be plain: TimescaleDB is a genuinely impressive piece of engineering. I have no quarrel with it. It solves real problems at real scale, and the team behind it has earned their reputation. But the majority of teams who reach for it do not have the problem it was built to solve. They have a growing table with a created_at column and some aggregation queries that have gotten slow. That is not a time-series problem. That is a query performance problem. And PostgreSQL — your PostgreSQL, the one already running, already paid for, already understood by your team — has had the answer since version 9.3: materialized views.
Allow me to give credit where it is due
Before I make my case, permit me a moment of honest respect. TimescaleDB was built for workloads where data arrives continuously, is indexed primarily by time, and is queried in time-windowed aggregations. The canonical examples are genuine time-series: IoT sensor telemetry (thousands of devices reporting every few seconds), financial market data (tick-by-tick trades and quotes), infrastructure monitoring (metrics from hundreds of servers at sub-minute intervals), and industrial process data (factory floor readings at millisecond granularity).
These workloads share specific characteristics that standard PostgreSQL handles poorly at scale:
- Append-heavy ingestion — millions of rows per hour, never updated after insertion
- Time-range queries — almost every query filters on a time window, and scanning months of data to answer "what happened in the last hour?" is wasteful
- Continuous rollups — hourly, daily, and weekly aggregates need to stay current as new data arrives, without re-scanning the entire table
- Data lifecycle management — raw data needs to expire after days or weeks, while aggregates are retained for months or years
- Storage pressure — at billions of rows, raw storage costs become a primary concern, and columnar compression offers an order-of-magnitude reduction
For these workloads, TimescaleDB's hypertable partitioning, continuous aggregates, compression, and retention policies are not conveniences — they are necessities. Standard PostgreSQL can partition tables, but managing chunk boundaries, compression, and retention policies by hand at this scale is full-time operational work. I would no sooner advise against TimescaleDB for a genuine time-series workload than I would advise against a proper wine cellar for a household that entertains nightly.
Now. Having established that — let us discuss what you actually have.
What most teams actually have
Most teams reaching for TimescaleDB do not have IoT telemetry or financial tick data. They have an application events table. I have seen hundreds of them. They all look something like this:
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 14 million rows, growing by ~50K/day
CREATE INDEX ON events (created_at);
CREATE INDEX ON events (user_id, created_at); Fourteen million rows sounds imposing. It is not. PostgreSQL handles tables of this size the way a seasoned butler handles a dinner party for twelve — with quiet competence and no particular urgency. The problem is not the row count. It is the queries running against it. Specifically, it is usually one or two dashboard queries that aggregate over a time range and have, as these things do, gotten slow:
-- The dashboard query that started the conversation
SELECT
date_trunc('hour', created_at) AS hour,
event_type,
count(*) AS event_count
FROM events
WHERE created_at > now() - INTERVAL '7 days'
GROUP BY hour, event_type
ORDER BY hour DESC;
-- Execution time: 2,400 ms on 14M rows
-- Seq Scan on events (rows=3,500,000) This query scans 3.5 million rows to produce a result set of a few hundred rows. It runs every time someone opens the dashboard. It takes 2.4 seconds. The team is understandably unhappy. And so the Slack thread begins, and someone mentions hypertables, and soon the household is interviewing specialist staff for a task the existing household can handle perfectly well.
This is not a time-series problem. This is a "repeatedly computing the same expensive aggregation" problem. The data has timestamps, yes. It arrives in chronological order, yes. But the workload is a reporting query that runs a few times per minute against a table that grows by 50,000 rows per day. That is not a call for specialized infrastructure. That is a materialized view's entire purpose on this earth.
If I may: the materialized view solution
A materialized view pre-computes the aggregation and stores the result as a physical table. Subsequent reads hit the stored result directly — no scanning, no aggregation, no 2.4-second wait. Observe.
-- Pre-aggregate the expensive query
CREATE MATERIALIZED VIEW events_hourly AS
SELECT
date_trunc('hour', created_at) AS hour,
event_type,
count(*) AS event_count
FROM events
GROUP BY hour, event_type;
-- Index for the dashboard's ORDER BY
CREATE UNIQUE INDEX ON events_hourly (hour DESC, event_type);
-- Query the matview instead
SELECT * FROM events_hourly
WHERE hour > now() - INTERVAL '7 days'
ORDER BY hour DESC;
-- Execution time: 8 ms. Same result. No extension required. From 2,400 ms to 8 ms. I confess to a certain quiet satisfaction when presenting this. No extension. No shared_preload_libraries restart. No licensing review. No hypertable conversion. Three SQL statements. The manor's existing capabilities, properly employed.
The trade-off is freshness: the materialized view shows data as of its last refresh. For a dashboard that tolerates 15 minutes of staleness — and I have yet to meet a dashboard that does not, however vigorously its owners protest otherwise — a scheduled refresh handles this cleanly:
-- Refresh every 15 minutes with pg_cron
SELECT cron.schedule(
'refresh-events-hourly',
'*/15 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY events_hourly'
);
-- Refresh takes ~3 seconds on 14M rows.
-- The dashboard tolerates 15 minutes of staleness.
-- This is the entire operational burden. This is the entire operational surface area. One materialized view. One index. One cron schedule. The refresh takes 3 seconds. The dashboard query takes 8 milliseconds. The team is content. No new staff were hired. No specialist agencies were consulted. The existing household simply began doing what it was always capable of doing.
For teams that need multiple rollup windows, the pattern extends naturally:
-- Daily rollup for the trends page
CREATE MATERIALIZED VIEW events_daily AS
SELECT
date_trunc('day', created_at) AS day,
event_type,
count(*) AS event_count,
count(DISTINCT user_id) AS unique_users
FROM events
GROUP BY day, event_type;
CREATE UNIQUE INDEX ON events_daily (day DESC, event_type);
-- Per-user activity summary for the admin panel
CREATE MATERIALIZED VIEW user_activity_30d AS
SELECT
user_id,
count(*) AS total_events,
count(*) FILTER (WHERE created_at > now() - INTERVAL '7 days') AS events_7d,
max(created_at) AS last_active
FROM events
WHERE created_at > now() - INTERVAL '30 days'
GROUP BY user_id;
CREATE UNIQUE INDEX ON user_activity_30d (user_id); Each materialized view is independent. Each gets its own refresh schedule tuned to its staleness tolerance. Each is a standard PostgreSQL object — visible in pg_matviews, manageable with existing tooling, understood by every PostgreSQL developer on the team. No special training required. No new dependencies to track through major version upgrades. Ordinary tools, competently applied.
And now, the TimescaleDB equivalent
In the interest of fairness — and I am nothing if not fair — here is the same problem solved with TimescaleDB continuous aggregates:
-- The TimescaleDB equivalent of the above
-- Step 1: Convert to hypertable (requires shared_preload_libraries restart)
SELECT create_hypertable('events', by_range('created_at'));
-- Step 2: Create a continuous aggregate
CREATE MATERIALIZED VIEW events_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', created_at) AS hour,
event_type,
count(*) AS event_count
FROM events
GROUP BY hour, event_type
WITH NO DATA;
-- Step 3: Add a refresh policy
SELECT add_continuous_aggregate_policy('events_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour'
); The SQL is not dramatically more involved. The continuous aggregate syntax mirrors CREATE MATERIALIZED VIEW closely, and the refresh policy is, I will grant, cleaner than a pg_cron schedule. If TimescaleDB were already installed in your PostgreSQL instance, this would be a perfectly sensible approach. I would not object.
The weight of that phrase — "already installed" — is where the conversation turns. TimescaleDB must be added to shared_preload_libraries, which means a PostgreSQL restart. Your table must be converted to a hypertable, which changes its physical storage layout in ways that are not trivially reversible. The continuous aggregate can only group by time_bucket() on that hypertable — it is a specialist tool, not a general-purpose one. And the advanced features (the continuous aggregate itself, plus compression and retention) are under the Timescale License, which is free for self-hosting but restricts database-as-a-service offerings.
None of these are dealbreakers. All of them are costs. And in a well-run household, one does not hire specialist staff without first confirming that the existing staff cannot manage the task. The advice to add more services, I have found, arrives most enthusiastically from those who sell them.
When materialized views are enough (which is most of the time)
Materialized views are sufficient — and I would say preferable — when the following conditions hold. I suspect you will recognize your own situation in at least four of the five:
- Your table has millions of rows, not billions. A full
REFRESH MATERIALIZED VIEW CONCURRENTLYon a 14-million-row table with a moderately complex aggregation takes 3-5 seconds. On 100 million rows it takes 30-60 seconds. On a billion rows it becomes impractical. The crossover point — where full-table refresh becomes unacceptably slow — is your signal to consider incremental alternatives. Until that point arrives, and it may never arrive, the full refresh is perfectly adequate. - Your staleness tolerance is measured in minutes, not seconds. Dashboards, daily reports, weekly analytics, admin panels with "last updated 5 minutes ago" — these are all matview-friendly. If you need sub-second freshness, a standard matview cannot provide it. But I would gently observe that most dashboards are opened, glanced at for thirty seconds, and closed. They do not require the data to have arrived within the last heartbeat.
- Your aggregations are not exclusively time-bucketed. Materialized views can aggregate on anything: per-user summaries, per-region rollups, per-product metrics, arbitrary WHERE clause snapshots. Continuous aggregates require a
time_bucket()grouping on a hypertable time column. If your dashboard has both "events per hour" and "top users this month," the matview handles both; the continuous aggregate handles only the first. Versatility is an underappreciated virtue. - You do not need automatic data expiration. If your events table grows without bound and you do not have a retention policy, you will eventually need one. But "eventually" might be a year from now, and
DELETE FROM events WHERE created_at < ...scheduled viapg_cronworks until the table is large enough for the delete to be disruptive. One does not install a sprinkler system because the garden might someday catch fire. - You value operational simplicity. A materialized view is a core PostgreSQL object. It requires no extension, no shared library, no special upgrade procedure. It works on every managed PostgreSQL provider — RDS, Cloud SQL, Azure, Supabase, Neon, Crunchy Bridge, all of them. TimescaleDB's cloud availability is limited, and switching providers later means migrating away from hypertables. Simplicity is not the absence of capability. It is the discipline to use existing capability before adding new complexity.
When you genuinely need TimescaleDB
I should be forthcoming about the cases where my position reverses entirely. A butler who overstates his case is no butler at all. TimescaleDB is the right tool — and my honest, unreserved recommendation — when your workload has characteristics that materialized views cannot address:
- Billions of rows with continuous ingestion. When your table receives millions of rows per hour and contains months or years of data, a full-table matview refresh is no longer viable. TimescaleDB's continuous aggregates refresh only the affected time buckets — milliseconds instead of minutes. This is not a marginal improvement. It is a categorically different capability.
- Compression is a requirement, not a convenience. At a billion rows of sensor data, storage costs dominate. TimescaleDB's columnar compression achieves 90-95% reduction on typical time-series data, and compressed chunks remain queryable with standard SQL. There is no PostgreSQL-native equivalent. None. I would not insult your intelligence by pretending otherwise.
-- TimescaleDB compression: older chunks become read-only columnar storage
ALTER TABLE events SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'event_type',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('events', INTERVAL '7 days');
-- 14M rows at ~200 bytes each = ~2.6 GB uncompressed
-- After compression: ~260 MB (typical 90% ratio for event data)
-- This is genuinely useful. Matviews do not compress source data. - Data retention must be automatic and instant. Dropping a TimescaleDB chunk is a
DROP TABLEon a partition — instant, regardless of chunk size, with no index bloat or vacuum overhead. Deleting rows from a regular table at scale is slow, bloats the table, and requires aVACUUMafterward. The difference at scale is not subtle.
-- TimescaleDB retention: automatically drop chunks older than 90 days
SELECT add_retention_policy('events', INTERVAL '90 days');
-- Without TimescaleDB, the equivalent is a scheduled DELETE:
DELETE FROM events WHERE created_at < now() - INTERVAL '90 days';
-- On 14M rows, this DELETE takes 45 seconds, bloats the table,
-- and requires a VACUUM afterward. TimescaleDB's chunk-based DROP
-- is instant and produces no bloat. - You need real-time aggregates. TimescaleDB's continuous aggregates with
real_time_aggregationcombine materialized data for older time buckets with live data for the most recent bucket. The result is always current. Standard materialized views are always a snapshot — fresh as of the last refresh, no more. For a monitoring dashboard where "two minutes stale" means you missed the incident, this matters. - Your entire workload is time-series. If the application is a monitoring platform, an IoT data pipeline, or a financial analytics system, the table is not an afterthought — time-series is the product. The full suite of TimescaleDB features (hypertables, continuous aggregates, compression, retention,
time_bucket(),time_bucket_gapfill()) pays for its operational overhead many times over. In this household, the specialist staff have earned their positions. Keep them.
I have prepared a comparison, if you'll indulge me
For those who prefer their counsel in tabular form:
| Factor | Materialized views | TimescaleDB |
|---|---|---|
| Ingestion rate | Thousands to low millions of rows/day | Millions to billions of rows/day |
| Query patterns | Dashboards, reports, periodic analytics | Continuous time-series analytics, real-time rollups |
| Refresh tolerance | Minutes to hours of staleness is acceptable | Need near-real-time aggregates with automatic refresh |
| Data retention | Manual DELETE + VACUUM (or pg_partman) | Automatic chunk-based DROP, instant, no bloat |
| Compression | Not applicable (source table stays full size) | 90%+ compression on older data, transparent to queries |
| Operational overhead | pg_cron schedule, one REFRESH command | Extension install, shared_preload_libraries restart, chunk tuning |
| Licensing | Core PostgreSQL, no restrictions | TSL for advanced features, restricts DBaaS offerings |
| Cloud availability | Every PostgreSQL provider | Limited (Azure, Neon partial, not on RDS/Cloud SQL) |
The pattern in this table is, I think, rather clear: materialized views win on simplicity, availability, and operational cost. TimescaleDB wins on scale, automation, and storage efficiency. The question is which set of trade-offs matches your actual workload — not the workload you imagine having in two years, not the workload described in the blog post that prompted the Slack thread, but the one running in your production database this evening.
The honest counterpoint (because honesty is not optional)
It would be convenient to declare materialized views the universal answer and take my leave. It would also be dishonest, and I should be embarrassed to serve a guest half the truth.
TimescaleDB's continuous aggregates are better than materialized views at what they do. Incremental refresh is fundamentally superior to full-table refresh. Real-time aggregation — blending pre-computed and live data — is something materialized views simply cannot offer. And the operational automation (compression policies, retention policies, chunk management) eliminates an entire category of maintenance work that materialized views leave on your plate. These are facts, not concessions.
The company behind TimescaleDB — now rebranded as Tiger Data — has been expanding beyond pure time-series. Recent versions (2.22 and 2.23) added 90x faster DISTINCT queries, zero-config hypertables, UUIDv7 partitioning, and configurable columnstore indexes. The project is actively developed, widely deployed, and has a clear trajectory toward making PostgreSQL competitive with purpose-built analytical databases. I follow their work with genuine interest.
My argument is not that TimescaleDB is unworthy. I find that position indefensible, and I would not attempt to defend it. My argument is narrower and, I believe, more useful: installing a specialized extension to solve a problem that three standard SQL statements can handle is a form of over-engineering, however sophisticated that extension may be. The operational cost is real: another extension to maintain through PostgreSQL major version upgrades, another licensing dependency to track, another constraint on which managed providers you can use. These costs compound quietly, the way household expenses do, until one day you realize the staff budget exceeds the mortgage. They are worth paying when the problem demands the tool. They are simply not worth paying when CREATE MATERIALIZED VIEW and pg_cron resolve the matter.
A word about pg_ivm, while we're on the subject
A sharp guest might reasonably ask: if the main advantage of continuous aggregates is incremental refresh, and the main limitation of materialized views is full-table refresh, what about pg_ivm — the PostgreSQL extension for incremental materialized view maintenance?
pg_ivm uses triggers to update materialized views incrementally as the base table changes. It works on any query, not just time-bucketed aggregations. It stays within the PostgreSQL extension ecosystem without the operational weight of TimescaleDB. I regard it as a useful middle path — the household promoting an existing member of staff rather than hiring externally.
The trade-offs are different. pg_ivm adds write-path overhead (every INSERT into the base table triggers an update of the view). TimescaleDB's continuous aggregates batch the refresh into periodic background jobs — no write-path penalty. For high-ingestion workloads (the kind where TimescaleDB shines), trigger-based maintenance can become a bottleneck. For moderate-ingestion workloads (the kind where materialized views are already sufficient), pg_ivm offers a lighter-weight path to incremental refresh than installing TimescaleDB.
It is a middle ground worth knowing about, even if partisans on neither side are inclined to mention it.
Where Gold Lapel fits
I would be a poor butler indeed if I failed to mention that trained staff are available for precisely this situation. Gold Lapel is a PostgreSQL proxy that creates and manages materialized views automatically. It observes your query traffic, identifies expensive aggregations that would benefit from materialization, and creates the views without any changes to your application code.
For teams whose conclusion from this evening's discussion is "materialized views are enough for us," Gold Lapel removes the remaining manual work: deciding which queries to materialize, writing the CREATE MATERIALIZED VIEW statements, choosing refresh intervals, and adjusting schedules as data volumes change. The matview approach becomes not just sufficient but effortless. The household runs itself.
Gold Lapel does not replace TimescaleDB. For genuine time-series workloads — the billions-of-rows, continuous-ingestion, compression-required kind — TimescaleDB remains the right tool, and I have said as much plainly. The two can coexist in the same PostgreSQL instance without friction. But for the majority of teams whose "time-series problem" is actually a "slow aggregation query problem," Gold Lapel makes the simpler solution simpler still.