Chunk Compression Scheduling & Automation

The engineering problem this guide solves is narrow but consequential: how to compress aging chunks on a deterministic schedule without ever stalling ingestion, corrupting a continuous aggregate refresh, or leaving reclaimable disk locked in bloat. TimescaleDB ships native columnar compression and a background job to drive it, but the default add_compression_policy call is only the starting point. Production IoT platforms need the policy to fire in the right order relative to rollups and retention, to survive lock contention on hot hypertables, and to actually return bytes to the operating system after the row-store-to-columnar rewrite. This page is part of the broader data retention and compression lifecycle and assumes you already run TimescaleDB in production with hypertables in place.

The automated chunk-compression decision flow A left-to-right flow. A compression policy evaluates each chunk against the compress_after age threshold. Chunks not yet old enough are left in the row store. An aged chunk is checked for a free lock: if the ACCESS EXCLUSIVE lock is free it is compressed into a columnar store and then vacuumed to reclaim space; if the lock is held the pass backs off and retries the lock check. The stages map to the numbered implementation steps: steps 1 and 2 register the policy, step 4 is the lock-aware compress, step 5 is the vacuum. yes yes no no retry Compression policy steps 1–2 Older than compress_after? Chunk lock free? Compress chunk step 4 Vacuum & reclaim space step 5 Leave in row store Backoff & retry

Raw chunking alone does not guarantee storage efficiency or predictable latency. A chunk written today is optimal for ingestion — a heap that accepts out-of-order inserts cheaply — but wasteful for the analytical scans that dominate reads a week later. The scheduling layer described here moves each chunk across that boundary at the moment it stops being write-hot, and does so idempotently so a redeploy or a crashed worker never leaves the hypertable in a half-compressed state.

Prerequisites

Compression scheduling depends on the hypertable being compression-enabled, on background workers being available to run the job, and on the segmentation columns being chosen before the first chunk compresses. Confirm each of the following before registering a policy.

Verify the version and worker headroom in a single pass:

sql
-- Confirm the extension version and that background workers are actually available.
SELECT extversion FROM pg_extension WHERE extname = 'timescaledb';
SHOW timescaledb.max_background_workers;
SELECT count(*) AS running_workers
FROM pg_stat_activity
WHERE backend_type = 'TimescaleDB Background Worker Scheduler';

Step-by-Step Implementation

The steps below map directly to the flowchart above: you enable compression on the hypertable, register the policy that evaluates the compress_after threshold, drive lock-aware compression for any out-of-band passes, then reclaim space with vacuum.

1. Enable compression on the hypertable

Compression is a table-level property. Set the columnar compression models parameters — segmentby and orderby — before any policy runs, because they define the physical layout every future chunk inherits. Guard the ALTER so a redeploy against an already-configured table is a no-op:

sql
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_class c
    JOIN pg_namespace n ON n.oid = c.relnamespace
    WHERE c.relname = 'sensor_readings'
      AND n.nspname = 'public'
      AND c.reloptions::text LIKE '%timescaledb.compress=true%'
  ) THEN
    EXECUTE format(
      'ALTER TABLE sensor_readings SET (
        timescaledb.compress,
        timescaledb.compress_segmentby = ''device_id, metric_type'',
        timescaledb.compress_orderby = ''time DESC''
      );'
    );
  END IF;
END $$;

The choice of segmentation and ordering columns directly dictates compression ratios and query pruning efficiency. Misconfigured parameters fragment storage, degrade scan performance, or stall refreshes. The rule: segmentby columns should match your most common equality filter predicates (for example device_id), and orderby should match the time column direction used by your range queries. On multi-tenant deployments where the tenant key is high-cardinality, review the chunk indexing patterns for high-cardinality tags before settling the segmentby set.

2. Register the compression policy idempotently

The native scheduler evaluates each chunk’s time range against compress_after and compresses any chunk that has aged past it. Treat policy creation as declarative infrastructure — if_not_exists => TRUE makes redeploys converge rather than error:

sql
-- Idempotent policy creation: compress chunks once they age past 30 days.
SELECT add_compression_policy(
    'sensor_readings',
    compress_after => INTERVAL '30 days',
    if_not_exists  => TRUE
);

Align compress_after with your retention boundary so the policy never spends I/O compressing chunks that a retention policy is about to drop. If raw data lives 90 days and you compress at 30, chunks spend two-thirds of their life compressed; if you compress at 85 you save almost nothing. Size the gap from the point where writes to a chunk effectively stop.

3. Evaluate candidates dynamically (optional)

The built-in scheduler is sufficient for steady-state workloads. When you need dynamic thresholds — compressing earlier under disk pressure, or gating on continuous aggregate progress — query timescaledb_information.chunks directly and drive compression from your own orchestration:

python
import psycopg
from psycopg.rows import dict_row

def evaluate_compression_candidates(conn_str: str, table_name: str, threshold_days: int = 30) -> list:
    """Return uncompressed chunks older than the threshold, oldest first."""
    with psycopg.connect(conn_str, row_factory=dict_row) as conn:
        with conn.cursor() as cur:
            # Build the interval from a real parameter; %s is not substituted inside a
            # quoted SQL literal such as INTERVAL '%s days'.
            cur.execute("""
                SELECT chunk_schema, chunk_name, range_start, range_end, is_compressed
                FROM timescaledb_information.chunks
                WHERE hypertable_name = %s
                  AND is_compressed = false
                  AND range_end < (NOW() - make_interval(days => %s))
                ORDER BY range_end ASC
                LIMIT 10;
            """, (table_name, threshold_days))
            return cur.fetchall()

4. Compress with lock awareness

Compression acquires an ACCESS EXCLUSIVE lock on each chunk during its transition. In high-write environments an unmanaged pass can block ingestion or trigger cascading statement timeouts. For any out-of-band compression — backfills, catch-up after an outage, disk-pressure sweeps — wrap compress_chunk in exponential backoff so transient lock conflicts retry instead of crashing the pipeline:

python
import time
import logging
import psycopg

logger = logging.getLogger(__name__)

def safe_compress_chunk(conn_str: str, chunk: str, max_retries: int = 3, base_backoff: float = 2.0) -> bool:
    """`chunk` must be the schema-qualified chunk name,
    e.g. '_timescaledb_internal._hyper_1_2_chunk'."""
    for attempt in range(max_retries):
        try:
            with psycopg.connect(conn_str) as conn:
                with conn.cursor() as cur:
                    cur.execute("SELECT compress_chunk(%s::regclass);", (chunk,))
                conn.commit()
            logger.info("Successfully compressed chunk: %s", chunk)
            return True
        except psycopg.OperationalError as e:
            if "lock" in str(e).lower() and attempt < max_retries - 1:
                delay = base_backoff * (2 ** attempt)
                logger.warning("Lock contention on %s. Retrying in %.1fs...", chunk, delay)
                time.sleep(delay)
                continue
            logger.error("Failed to compress %s: %s", chunk, e)
            raise
    return False

Feed safe_compress_chunk the schema-qualified names from evaluate_compression_candidates, iterating oldest-first so a rolling window of at most one chunk holds a lock at any moment.

5. Reclaim space with vacuum

Compression rewrites a chunk’s row store into compressed columnar batches, but the freed heap pages return to the operating system only after VACUUM runs. Relying on default autovacuum thresholds leaves bloat in heavily updated compressed chunks. VACUUM cannot run inside a function or transaction block, and per-relation vacuum timestamps live in pg_stat_all_tables — not the chunks view — so generate the statements and execute them from a client (in psql, end the query with \gexec):

sql
-- Generate VACUUM statements for compressed chunks not vacuumed in 7 days; run them
-- outside a transaction (in psql, end the query with \gexec to execute the results).
SELECT format('VACUUM (VERBOSE, ANALYZE) %I.%I;', c.chunk_schema, c.chunk_name)
FROM timescaledb_information.chunks c
JOIN pg_stat_all_tables s
  ON s.schemaname = c.chunk_schema AND s.relname = c.chunk_name
WHERE c.is_compressed = true
  AND (GREATEST(s.last_vacuum, s.last_autovacuum) IS NULL
       OR GREATEST(s.last_vacuum, s.last_autovacuum) < NOW() - INTERVAL '7 days');

Configuration Parameters Reference

Parameter Type Recommended value Effect
timescaledb.compress boolean true Enables columnar compression on the hypertable; prerequisite for any policy.
timescaledb.compress_segmentby column list Most common equality filter (e.g. device_id) Groups rows into segments; matching your WHERE predicates lets the planner exclude whole segments.
timescaledb.compress_orderby column list time DESC Orders rows within a segment; align with range-scan direction for best pruning and ratio.
compress_after interval Point where writes to a chunk stop (e.g. 30 days) Age threshold at which the policy compresses a chunk.
schedule_interval interval 1 hour (default 12 hours) How often the background job scans for eligible chunks.
initial_start timestamptz Off-peak timestamp First run time; stagger across hypertables so large chunks do not compress simultaneously.
maintenance_work_mem memory 256MB1GB Working memory for the compression rewrite; too low forces spills.
timescaledb.max_background_workers integer Sum of concurrent jobs + headroom Caps how many policy jobs (compression, refresh, retention) run at once.

Estimate the storage a policy will hold using a measured compression ratio rr (compressed size ÷ raw size). For a hypertable holding HdropH_{\text{drop}} days total and compressing after HhotH_{\text{hot}} days, at BdayB_{\text{day}} raw bytes ingested per day:

Stotal=HhotBdayraw chunks  +  (HdropHhot)Bdayrcompressed chunksS_{\text{total}} = \underbrace{H_{\text{hot}} \cdot B_{\text{day}}}_{\text{raw chunks}} \;+\; \underbrace{(H_{\text{drop}} - H_{\text{hot}}) \cdot B_{\text{day}} \cdot r}_{\text{compressed chunks}}

For a fleet writing 40 GB/day, compress_after => 30 days, a 365-day horizon, and a measured r=0.08r = 0.08, raw chunks hold ~1.2 TB while compressed chunks hold (36530)×40×0.081.07(365-30)\times 40 \times 0.08 \approx 1.07 TB — against ~14.6 TB the same window would cost uncompressed.

Integration with Adjacent Features

Compression scheduling never runs in isolation; it is one stage of a sequenced lifecycle.

  • Continuous aggregates. A continuous aggregate refresh policy reads both compressed and uncompressed chunks during incremental materialization. Compress a chunk before its buckets are finalized and a refresh whose window reaches back into it can fail or skip ranges. Schedule compression to run after the refresh policy design and scheduling window has closed over that chunk, or add a compress_after buffer that exceeds your materialization lag. When refreshes and compression share the scheduler, review how the asynchronous execution queue serializes them so neither starves.
  • Retention. Compression and TTL policy enforcement both key off a chunk’s time range. Order matters: a chunk should be downsampled and compressed before it becomes eligible to drop. Setting compress_after well below drop_after guarantees that ordering.
  • Partitioning. On multi-tenant hypertables, space partitioning for multi-tenant IoT keeps per-tenant chunks small enough that each compression pass is short and holds its ACCESS EXCLUSIVE lock briefly.

Performance Validation

After a policy has run, confirm chunks are actually compressing and the job is healthy. Compression status and per-chunk savings come from the chunks view:

sql
-- Compression coverage and before/after bytes per hypertable.
SELECT
    hypertable_name,
    count(*) FILTER (WHERE is_compressed)       AS compressed_chunks,
    count(*) FILTER (WHERE NOT is_compressed)   AS uncompressed_chunks,
    pg_size_pretty(sum(before_compression_total_bytes)) AS raw_size,
    pg_size_pretty(sum(after_compression_total_bytes))  AS compressed_size
FROM timescaledb_information.chunks c
LEFT JOIN chunk_compression_stats('sensor_readings') s
  ON s.chunk_name = c.chunk_name
WHERE hypertable_name = 'sensor_readings'
GROUP BY hypertable_name;

Confirm the background job is succeeding and measure its runtime against the schedule:

sql
SELECT
    j.job_id,
    js.last_run_status,
    js.last_successful_finish,
    js.last_run_duration,
    js.total_failures
FROM timescaledb_information.jobs j
JOIN timescaledb_information.job_stats js USING (job_id)
WHERE j.proc_name = 'policy_compression'
  AND j.hypertable_name = 'sensor_readings';

If last_run_duration approaches schedule_interval, runs risk overlapping — lengthen the interval or narrow the backlog by lowering compress_after so fewer chunks queue per pass.

Troubleshooting

ERROR: chunk "_hyper_1_2_chunk" is not compressed when running retention or a manual decompress. The policy has not reached that chunk yet, or it was decompressed for an update. Check is_compressed in timescaledb_information.chunks and confirm the job’s last_run_status is Success.

Policy is registered but chunks never compress. The worker pool is exhausted. Raise timescaledb.max_background_workers (and max_worker_processes above it), restart, and re-check the running_workers query from the prerequisites. A total_failures that climbs while last_run_status is not Success confirms the diagnosis.

ERROR: tuple concurrently updated or lock timeouts during compression. Ingestion or an UPDATE is touching the chunk as the policy tries to seal it. Move the compression window later so it only targets chunks past their write-hot phase, or drive out-of-band passes through safe_compress_chunk with backoff.

ERROR: cannot update/delete rows from chunk ... compression is enabled. A late or corrective write is hitting an already-compressed chunk. Decompress the specific chunk with decompress_chunk, apply the write, and let the policy recompress it — do not disable compression on the whole hypertable.

Disk usage does not drop after compression reports success. Freed heap pages have not been returned to the OS. Run the vacuum generator from Step 5; for chunks with heavy prior churn a plain VACUUM will not shrink the file — schedule off-peak VACUUM (FULL) on those specific chunks only.

Frequently Asked Questions

Should compress_after ever be shorter than my continuous aggregate refresh window?

No. If compress_after is shorter than the span a refresh policy reaches back over (start_offset), the refresh can hit a chunk that compression already sealed and either error or skip ranges. Keep compress_after larger than the widest refresh start_offset plus your worst-case late-arrival delay, so a bucket is fully materialized before its chunk compresses.

Can I insert into a compressed chunk?

Modern TimescaleDB accepts inserts into compressed chunks — new rows land in an uncompressed region and are folded in on the next compression pass — but UPDATE and DELETE against compressed data require decompressing the affected chunk first. For workloads with frequent late corrections, widen compress_after so corrections mostly land before compression rather than after.

How do I compress a backlog of old chunks without blocking ingestion?

Drive them oldest-first through safe_compress_chunk, one chunk at a time, so only a single ACCESS EXCLUSIVE lock is held at any moment and it is released between chunks. Run the backlog during an off-peak window and cap concurrency at one worker; the goal is to keep each lock short rather than to compress in parallel.

Why did my compression ratio come out worse than expected?

Almost always a segmentby/orderby mismatch. High-cardinality segmentby columns produce many tiny segments that compress poorly; an orderby that fights the natural time ordering breaks run-length and delta encoding. Re-measure with chunk_compression_stats after aligning both to your actual query and ingestion patterns.

Does changing segmentby or orderby affect already-compressed chunks?

No. Altering the compression settings only changes how future chunks compress. Existing compressed chunks keep their original layout until you explicitly decompress_chunk and recompress them. Plan a controlled recompression pass if the new settings materially improve the ratio.

← Back to Data Retention & Compression Lifecycle Automation