Master database partitioning for time-series data to slash query latency. Learn practical hot-cold tiering strategies to optimize storage and performance.
Last quarter, our sensor-logging service hit a wall. We were ingesting roughly 50,000 events per minute, and our primary PostgreSQL instance—which had been humming along perfectly for months—suddenly started choking on simple aggregate queries. The dashboard, which used to load in under 200ms, was timing out after 30 seconds.
We were drowning in a massive table of historical telemetry. When you're dealing with time-series data, your biggest enemy isn't just the volume of writes; it's the sheer weight of old data forcing the database to scan through millions of irrelevant rows.
If you’ve ever stared at an EXPLAIN ANALYZE output only to see a Seq Scan on a 50GB table, you know the pain. Database partitioning is the most effective way to break that monolith into manageable chunks. By splitting a large table into smaller, logically defined pieces, you allow the query planner to skip entire sections of data that don't match your WHERE clause.
Before we jump into the architecture, remember that partitioning isn't a silver bullet. If you're struggling with index bloat or missing indexes, start there first. I often suggest checking out WordPress Performance: Implementing Database Partitioning for Scale to understand the fundamentals of how table structure impacts retrieval speed.
The goal of time-series data management is to keep the "hot" data (the last 24–48 hours) in high-performance storage and move the "cold" data (everything older) to cheaper, slower storage or archive it entirely.
We initially tried just adding more indexes. That was a mistake. Our write latency spiked because every insert had to update five different B-tree indexes. We then pivoted to a declarative partitioning strategy in PostgreSQL 14.
We used range partitioning on a created_at timestamp column. Here is how we structured our main telemetry table:
SQLCREATE TABLE telemetry ( id UUID, sensor_id INT, value FLOAT, created_at TIMESTAMPTZ NOT NULL ) PARTITION BY RANGE (created_at);
By creating daily partitions, we effectively turned a massive scan into a precise lookup. If a user queries data for "today," the database engine only touches the current day's partition.
You shouldn't keep everything in your production database forever. If you’re looking to automate the removal of old records, consider combining partitioning with Database TTL Strategies: Optimizing Expiring Data Workflows.
For our "cold" storage, we implemented a background worker that:
This approach transformed our storage optimization efforts. We went from a 600GB database that required constant maintenance to a lean 40GB "hot" set.
Don't get over-zealous with partitioning. If you create too many partitions—say, hourly partitions for a table that only has a few thousand rows—you'll actually hurt query performance. The overhead of the query planner checking each partition's metadata starts to outweigh the benefits of scanning smaller files.
I’ve also seen engineers try to denormalize everything into these partitions. While Materialized views for database performance in complex analytical queries are great for pre-calculating aggregates, keep your base partitions as clean as possible. Keep the logic simple: one partition per time unit, and index only what you need for your most frequent filters.
How many partitions is too many? There’s no magic number, but I aim for fewer than 100 partitions per table. If you're hitting hundreds, you likely need to reconsider your partitioning key or move to a specialized time-series engine like TimescaleDB.
Does partitioning slow down writes? Generally, no. In fact, it can speed them up because the B-tree indexes are smaller and easier to keep in memory. Just ensure you aren't creating so many indexes on each partition that the write amplification becomes a bottleneck.
When should I move data to cold storage? When your "hot" query performance degrades beyond your SLO. For us, that was around the 14-day mark. Monitoring your disk I/O and cache hit ratio is the best way to determine your own threshold.
Partitioning is a journey, not a destination. We’re still tweaking our retention policies, and I'm currently looking into automated partition management tools to reduce the manual overhead of creating new tables every day.
Next time, I’d probably start with TimescaleDB’s native continuous aggregates instead of building custom partition logic from scratch. But for a standard Postgres setup, this approach saved our bacon when we needed to scale fast without a total infrastructure migration. Just watch those index counts, and keep your cold data far away from your hot queries.
Master multi-tenancy database schema design by balancing row-level security and query performance. Learn how to scale your SaaS architecture effectively.
Read moreImprove database performance by implementing latent materialized view refreshing. Learn how to offload heavy read-heavy workloads using asynchronous processing.