How Postgres extensions replace entire categories of specialized databases — without leaving ACID behind.
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_cronfor background job scheduling, TimescaleDB for time-series at scale, Citus for horizontal sharding, andpgvectorfor 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? →
pgvectormakes Postgres competitive with Pinecone and Weaviate - Need background job scheduling? →
pg_cronreplaces 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 & RegisterDiscussion
0Join the discussion