Tutorials

PostgreSQL for Analytics: Best Practices

Null World Productions
4 min read

PostgreSQL can serve dashboards, nightly reports, and ad hoc analyst queries. It can also slow down your checkout flow if analytics and OLTP share the same tables and indexes without a plan.

These practices assume a dedicated analytics schema (or database) fed by ETL, not heavy reporting directly against row-level application tables.

Separate read paths from OLTP

Application transactions need narrow indexes and short locks. Analytics scans need wide aggregates and sequential reads on large tables.

Practical split:

  1. OLTP schema — normalized, optimized for writes
  2. Analytics schema — denormalized aggregates, optimized for reads
  3. ETL job — incremental sync on a schedule (or CDC later)

If you must query OLTP tables, use a read replica for reporting connections so a bad analyst query does not block production writes.

Index for filters, not every column

Dashboards repeat the same filter columns: report_date, region, status, customer_id. Index those combinations in order of selectivity.

CREATE INDEX idx_orders_analytics
  ON analytics.fact_orders (report_date DESC, region, status);

Use EXPLAIN (ANALYZE, BUFFERS) on slow queries. Seq scans on million-row tables during business hours are a signal to pre-aggregate or partition, not to add a twelfth single-column index.

Partial indexes help skewed filters:

CREATE INDEX idx_open_tickets
  ON support.tickets (created_at DESC)
  WHERE status = 'open';

Materialized views for repeated aggregates

If three dashboards and a Monday email all compute "revenue by region by week," compute it once.

CREATE MATERIALIZED VIEW analytics.weekly_revenue_by_region AS
SELECT
  date_trunc('week', report_date)::date AS week_start,
  region,
  SUM(net_revenue) AS net_revenue
FROM analytics.daily_revenue
GROUP BY 1, 2;

CREATE UNIQUE INDEX ON analytics.weekly_revenue_by_region (week_start, region);

Refresh on a schedule:

REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.weekly_revenue_by_region;

CONCURRENTLY requires a unique index but avoids blocking readers during refresh. For large views, refresh after ETL completes, not on every page load.

Partition large time-series tables

When fact tables grow past tens of millions of rows, partition by month or week on the date column:

CREATE TABLE analytics.events (
  event_id   BIGSERIAL,
  event_time TIMESTAMPTZ NOT NULL,
  event_type TEXT NOT NULL,
  payload    JSONB
) PARTITION BY RANGE (event_time);

Create partitions ahead of time. Drop old partitions instead of deleting rows when retention policy allows. Queries with event_time bounds prune partitions automatically.

Idempotent ETL with upserts

Analytics loads should survive retries. Use INSERT ... ON CONFLICT:

INSERT INTO analytics.daily_revenue (
  report_date, region, product_line,
  order_count, gross_revenue, net_revenue
)
SELECT ...
ON CONFLICT (report_date, region, product_line) DO UPDATE SET
  order_count   = EXCLUDED.order_count,
  gross_revenue = EXCLUDED.gross_revenue,
  net_revenue   = EXCLUDED.net_revenue,
  updated_at    = NOW();

Track batch metadata in a etl_runs table: start time, row counts, source watermark. Debugging "numbers look wrong Tuesday" starts with run history.

Query patterns that scale

Prefer aggregates in SQL, not in application code. Pulling 500k rows into Python to sum them wastes memory and network.

*Limit ad hoc SELECT . Analysts need column lists and row limits in tooling defaults.

Use CTEs for readability, watch materialization. PostgreSQL 12+ inlines many CTEs; for heavy intermediate results, a temp table or materialized step can be faster. Measure.

Approximate counts when exact counts are expensive. COUNT(*) on huge tables blocks. HyperLogLog extensions or sampled estimates work for dashboard headline numbers when precision is not contractual.

Connection and pool settings

Reporting workloads open many concurrent connections. Use PgBouncer (transaction pooling) in front of PostgreSQL for dashboard APIs. Set statement timeouts on analytics roles:

ALTER ROLE analytics_reader SET statement_timeout = '30s';

A runaway BI tool query should fail fast, not hold a connection for an hour.

Monitoring what matters

Track on the analytics instance:

  • Cache hit ratio (should stay high on warm aggregates)
  • Long-running queries (pg_stat_activity)
  • Table bloat and autovacuum lag on append-heavy facts
  • Replication lag if using read replicas

Alert on ETL freshness: "latest report_date in daily_revenue is more than 26 hours old."

How this connects to our stack

Our ETL Pipeline System and Business Intelligence Platform projects follow this layout: PostgreSQL analytics schema, scheduled loads, materialized views for dashboard APIs, RBAC at the database or API layer. The building your first analytics dashboard guide covers the product side; this guide covers the database side.


Related Resources:

Related Articles:

Need help? Contact us for data pipeline and analytics infrastructure work.