Module A-15·17 min read

How Postgres extensions replace entire categories of specialized databases — without leaving ACID behind.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 15 — The Extensions Ecosystem: pg_cron, TimescaleDB, Citus, and pgvector

What this module covers: PostgreSQL's extension system is its most underappreciated feature. Extensions add capabilities that match or exceed dedicated NoSQL databases — without leaving the ACID guarantees, SQL interface, and operational tooling you already have. This module covers the four extensions that most frequently eliminate the need for a separate database: pg_cron for background job scheduling, TimescaleDB for time-series at scale, Citus for horizontal sharding, and pgvector for AI/embedding workloads.


Why Extensions Matter

In Module 11, we compared Postgres to MongoDB, Cassandra, and ClickHouse. Those comparisons assumed vanilla Postgres. With extensions, the picture changes:

  • Need time-series with columnar compression? → TimescaleDB makes Postgres competitive with InfluxDB and ClickHouse
  • Need horizontal write scaling? → Citus distributes Postgres across many nodes
  • Need vector similarity search for AI? → pgvector makes Postgres competitive with Pinecone and Weaviate
  • Need background job scheduling? → pg_cron replaces external cron + application-layer job tables

Each extension adds specialized capabilities while keeping the full Postgres stack: MVCC, WAL, ACID, SQL, pg_stat_*, your existing monitoring, your existing connection pooler, your existing backup tooling.


pg_cron: Background Job Scheduling Inside Postgres

pg_cron runs SQL or stored procedures on a cron schedule, directly inside the database process. No external scheduler, no separate worker service, no application-layer job table.

Installation and Setup

-- Add to shared_preload_libraries (requires restart)
-- postgresql.conf:
-- shared_preload_libraries = 'pg_cron'
-- cron.database_name = 'mydb'
 
CREATE EXTENSION pg_cron;

Scheduling Jobs

-- Run ANALYZE on the transactions table every night at 2 AM
SELECT cron.schedule(
  'nightly-analyze-transactions',
  '0 2 * * *',
  'ANALYZE transactions'
);
 
-- Run partition maintenance every hour
SELECT cron.schedule(
  'partman-hourly',
  '0 * * * *',
  $$SELECT partman.run_maintenance_proc()$$
);
 
-- Clean up expired sessions every 5 minutes
SELECT cron.schedule(
  'cleanup-expired-sessions',
  '*/5 * * * *',
  $$DELETE FROM sessions WHERE expires_at < now()$$
);
 
-- Run a stored procedure weekly on Sunday at 3 AM
SELECT cron.schedule(
  'weekly-aggregation',
  '0 3 * * 0',
  $$CALL compute_weekly_stats()$$
);

Managing Jobs

-- List all scheduled jobs
SELECT jobid, jobname, schedule, command, active
FROM cron.job
ORDER BY jobid;
 
-- View recent job run history
SELECT
  jobid,
  job_pid,
  database,
  username,
  command,
  status,
  start_time,
  end_time,
  return_message
FROM cron.job_run_details
ORDER BY start_time DESC
LIMIT 20;
 
-- Disable a job without removing it
UPDATE cron.job SET active = false WHERE jobname = 'nightly-analyze-transactions';
 
-- Remove a job
SELECT cron.unschedule('nightly-analyze-transactions');

When pg_cron Is the Right Tool

pg_cron is ideal for:

  • Database maintenance tasks (ANALYZE, VACUUM, partition creation)
  • Data retention cleanup (delete rows older than N days)
  • Periodic aggregation into summary tables
  • Refreshing materialized views on a schedule

It is not suitable for:

  • Long-running jobs that need external resources (API calls, file I/O)
  • Jobs that need distributed coordination across multiple Postgres instances
  • Jobs with complex dependency graphs (use Airflow, Prefect, or similar)

TimescaleDB: Time-Series at Postgres Scale

TimescaleDB is an open-source extension that adds automatic time-based partitioning (hypertables), columnar compression, and time-series-specific query functions on top of Postgres.

Hypertables

A hypertable is a Postgres table with automatic time-based partitioning managed by TimescaleDB. Data is partitioned into "chunks" automatically — no manual CREATE TABLE partition ... required.

CREATE EXTENSION timescaledb;
 
-- Convert a regular table to a hypertable
CREATE TABLE metrics (
  time        TIMESTAMPTZ NOT NULL,
  device_id   BIGINT NOT NULL,
  temperature DOUBLE PRECISION,
  humidity    DOUBLE PRECISION
);
 
-- Convert to hypertable, partitioned by 'time' with 7-day chunks
SELECT create_hypertable('metrics', 'time', chunk_time_interval => INTERVAL '7 days');
 
-- Inserts now automatically route to the correct chunk
INSERT INTO metrics VALUES (now(), 1001, 23.5, 68.2);

The hypertable looks and behaves like a regular Postgres table. All standard SQL works. TimescaleDB handles partitioning transparently.

Columnar Compression

TimescaleDB's native columnar compression is the feature that makes it genuinely competitive with InfluxDB and ClickHouse for time-series workloads.

-- Enable compression on chunks older than 7 days
ALTER TABLE metrics SET (
  timescaledb.compress,
  timescaledb.compress_segmentby = 'device_id',  -- group by device for compression
  timescaledb.compress_orderby = 'time DESC'
);
 
-- Automate compression with a policy
SELECT add_compression_policy('metrics', INTERVAL '7 days');
 
-- Check compression ratios
SELECT
  chunk_name,
  pg_size_pretty(before_compression_total_bytes) AS before,
  pg_size_pretty(after_compression_total_bytes) AS after,
  ROUND(100.0 * (1 - after_compression_total_bytes::numeric /
    NULLIF(before_compression_total_bytes, 0)), 1) AS compression_pct
FROM chunk_compression_stats('metrics')
ORDER BY chunk_name;

Typical compression ratios for time-series data: 10–40x. A 100GB raw metrics table becomes 3–10GB compressed. Compressed chunks are read using a columnar scan — only the requested columns are decompressed, dramatically reducing I/O for aggregation queries.

Continuous Aggregates

Continuous aggregates are materialized views that update automatically as new data arrives:

-- Create a continuous aggregate: hourly averages per device
CREATE MATERIALIZED VIEW metrics_hourly
WITH (timescaledb.continuous) AS
SELECT
  time_bucket('1 hour', time) AS bucket,
  device_id,
  AVG(temperature) AS avg_temp,
  AVG(humidity) AS avg_humidity,
  MAX(temperature) AS max_temp,
  MIN(temperature) AS min_temp
FROM metrics
GROUP BY bucket, device_id;
 
-- Refresh policy: update the aggregate every 30 minutes for recent data
SELECT add_continuous_aggregate_policy(
  'metrics_hourly',
  start_offset => INTERVAL '3 hours',
  end_offset   => INTERVAL '1 hour',
  schedule_interval => INTERVAL '30 minutes'
);
 
-- Query the aggregate (much faster than raw table for dashboards)
SELECT bucket, device_id, avg_temp
FROM metrics_hourly
WHERE bucket > now() - INTERVAL '24 hours'
ORDER BY bucket DESC;

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.