100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
PostgreSQL Mastery
60 minintermediate

Mid-Course Project: High-Performance Analytics DB

This mid-course project brings the first four modules together into one task: design and tune a PostgreSQL database that ingests a large, ever-growing stream of events and serves fast analytical queries over it. You will model the schema, partition the events table by time, build the right indexes, accelerate dashboards with materialised views, and prove each decision with EXPLAIN ANALYZE — turning a naive slow design into a performant one with evidence at every step.

The scenario is an analytics database for a sports-streaming platform recording viewing events — millions per day — that product teams query for dashboards: daily active viewers, top matches, watch-time distributions. The point is integration: combining data types, schema design, partitioning, indexing, query optimisation, and concurrency-aware maintenance into a coherent, fast system.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as running a season's full statistics operation demands more than recording scores — you need organised archives by season, quick-reference leaderboards, and efficient ways to answer 'top scorers this month' without re-reading every scorecard, an analytics database demands partitioning, indexing, and precomputed summaries working together. The insight is that performance at scale is an orchestration of techniques, not one trick: the season's stats desk is fast because every part — archives, indexes, summaries — is designed to support the questions actually asked. The project's real lesson is how the parts reinforce each other, the way a stats desk's systems do: the season archives (range partitions) mean any monthly question touches one slim volume, which keeps each volume's quick-reference lists (per-partition indexes) small and sharp, which makes the evening leaderboard recompute (the materialised view refresh) fast enough to run on schedule — remove any layer and the others strain.

Learning Objectives

  • Model an analytics schema using appropriate data types, constraints, and relationships.
  • Partition a large, append-mostly events table by time so pruning and data ageing are efficient.
  • Design indexes (composite, partial, covering) that match the dashboard query patterns.
  • Use materialised views to precompute expensive aggregations and refresh them on a schedule.
  • Validate every optimisation with EXPLAIN ANALYZE, confirming plans and timings improved.
  • Plan for concurrency and maintenance: keep transactions short and vacuum/partition lifecycle healthy.

Technical Requirements

  • PostgreSQL 15+ with permission to create tables, partitions, indexes, and materialised views.
  • An events table partitioned by RANGE on the event timestamp, with monthly (or daily) partitions.
  • A small set of dimension tables (viewers, matches) with primary keys and foreign keys.
  • Indexes aligned to the dashboard queries, validated against EXPLAIN ANALYZE.
  • At least one materialised view for a costly aggregation, with a documented refresh strategy.
  • A maintenance plan: partition creation/retention automation and autovacuum tuning notes.

Architecture & Design

The core is a partitioned events fact table holding one row per viewing event, referencing slim dimension tables for viewers and matches. Partitioning by event time gives partition pruning for the time-bounded dashboard queries and makes retention a fast DETACH/DROP. Indexes are designed per query: a composite index for 'events by match over a window', a partial index for any hot subset, and covering indexes where a dashboard reads a fixed set of columns.

Expensive rollups — daily active viewers, per-match watch time — are precomputed as materialised views refreshed on a schedule, so dashboards read a small summary instead of scanning millions of raw events. Raw ingestion stays simple and append-only; the heavy aggregation work happens once per refresh, not once per dashboard load, mirroring the seed-time-versus-request-time principle of fast analytics.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a stats desk keeps raw ball-by-ball logs archived by match but publishes precomputed leaderboards updated each evening, so fans read the leaderboard instantly rather than waiting for every log to be re-tallied, your design keeps raw events partitioned while serving dashboards from refreshed materialised views. The insight is that you separate the firehose of raw records from the polished summaries — ingest cheaply, summarise periodically, and let readers consult the summary, not the firehose. The separation buys each side its own optimisation: the raw log side is tuned for cheap, relentless ingest — append-only entries into the current season's volume, minimal indexes on the hot partition, no triggers recomputing aggregates per ball — while the leaderboard side is tuned purely for reading: precomputed, indexed, small. The seam between them is the refresh, with its own disciplines: run it concurrently so fans never stare at a blank board mid-update, schedule it to follow close of play rather than a blind timer, and stamp it 'accurate as of stumps' — freshness as an explicit contract, not an accident.
sql
-- Partitioned fact table + dimension references
CREATE TABLE view_events (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    viewer_id   bigint NOT NULL,
    match_id    bigint NOT NULL,
    watched_ms  integer NOT NULL CHECK (watched_ms >= 0),
    event_time  timestamptz NOT NULL,
    PRIMARY KEY (id, event_time)
) PARTITION BY RANGE (event_time);

CREATE TABLE view_events_2026_06 PARTITION OF view_events
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

-- Index aligned to 'events for a match in a time window'
CREATE INDEX ON view_events (match_id, event_time);

Phase 1 — Model and Partition

Design the schema first: slim viewers and matches dimension tables with primary keys, and a view_events fact table partitioned by RANGE on event_time with monthly partitions. Choose data types deliberately — bigint ids, timestamptz for time, integer milliseconds for watch time — and add CHECK constraints to enforce validity at the source.

Create a few partitions spanning your data range and confirm that a time-bounded query prunes to the relevant partitions with EXPLAIN. This phase establishes the storage foundation: data ages cleanly by partition, and time-filtered queries already avoid scanning the whole dataset before you add a single index.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a stats operation first decides how to file records — by season, with a clear template for each match — before optimising any lookups, you first model and partition before indexing. The insight is that a sound filing structure comes before clever finding aids: get the archive organised by the dimension you query (time) and a large share of the performance work is already done. The reason for the order is that the filing decision is nearly irreversible while finding aids are disposable: re-filing a million scorecards from alphabetical to by-season means touching every card — repartitioning a large table is a migration project — whereas a leaderboard can be built or scrapped in an afternoon (CREATE INDEX, DROP INDEX) without moving a single record. So you spend the design care where the cost of being wrong is highest: choose the partition key from the questions the desk actually answers (time, for analytics), size the volumes so each is liftable (months, not decades), and only then look at what lookups remain slow. Indexes added to a badly filed archive are compensation, not design — fast lookups into volumes you should never have had to open.
sql
-- Dimension tables
CREATE TABLE viewers (id bigint PRIMARY KEY, country text NOT NULL);
CREATE TABLE matches (id bigint PRIMARY KEY, title text NOT NULL, played_on date);

-- Confirm pruning on a time-bounded query
EXPLAIN
SELECT count(*) FROM view_events
WHERE event_time >= '2026-06-10' AND event_time < '2026-06-11';
-- Plan should scan only view_events_2026_06, others pruned

Phase 2 — Index for the Dashboard Queries

Enumerate the actual dashboard queries — top matches by watch time over a window, daily active viewers, per-country breakdowns — and design indexes to match each. A composite index on (match_id, event_time) serves per-match windows; a covering index can answer a hot summary from the index alone; a partial index targets any skewed hot slice.

Capture each query's baseline plan, add the targeted index, run ANALYZE, and confirm the plan switched from a sequential scan to an index or bitmap scan with a large drop in time. Let the plans, not intuition, dictate which indexes exist, and remove any that go unused.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a stats desk builds exactly the leaderboards and cross-references its analysts repeatedly ask for, not every conceivable one, you build indexes for the dashboard queries you actually serve. The insight is that finding aids are justified by real questions: an index, like a leaderboard no analyst ever consults, is pure overhead unless it answers a query that genuinely gets run. The desk's method for deciding which leaderboards to build is the transferable skill: it keeps a log of what analysts actually request (pg_stat_statements, ranked by total time), builds the cross-reference for the requests that dominate the queue, then checks the circulation record to confirm the new aid is being consulted (pg_stat_user_indexes — an index at zero scans is a leaderboard nobody reads). Every aid also taxes the pen: each evening's new scorecards must be entered into every cross-reference that mentions them, so an ingest-heavy desk feels each additional index as slower filing — the write amplification that makes 'index everything' self-defeating. Build from evidence, verify uptake in EXPLAIN, and retire what the log shows has gone unread.
sql
-- Baseline then targeted index for 'top matches by watch time last 7 days'
EXPLAIN (ANALYZE, BUFFERS)
SELECT match_id, sum(watched_ms) AS total_ms
FROM view_events
WHERE event_time >= now() - interval '7 days'
GROUP BY match_id ORDER BY total_ms DESC LIMIT 10;

CREATE INDEX idx_ve_match_time ON view_events (match_id, event_time) INCLUDE (watched_ms);
ANALYZE view_events;
-- Re-run EXPLAIN ANALYZE: expect index usage and lower time

Phase 3 — Materialise and Maintain

Precompute the costliest dashboards as materialised views: a daily per-match watch-time rollup and a daily-active-viewers summary. Add a unique index on each so it can be refreshed concurrently, and refresh on a schedule (a scheduled job, foreshadowing the pg_cron lesson) so dashboards read a tiny summary instead of aggregating millions of raw rows on every load.

Finish with the maintenance plan: automate creation of next month's partition and retention/DROP of old ones, and tune autovacuum on the high-churn tables so dead tuples from any updates are reclaimed promptly. Document that ingestion uses short transactions to avoid blocking vacuum, tying the concurrency lessons into the operational design.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as the stats desk publishes the evening leaderboard once and lets thousands of fans read it, rather than re-tallying for each fan, and also keeps the archive tidy by retiring old seasons on schedule, you refresh materialised views periodically and automate partition lifecycle and vacuuming. The insight is that doing the expensive work once and keeping the house in order is what sustains speed over time — the summary is computed on a schedule, not on demand, and the archive never silently bloats. Both routines share one principle — move expensive work off the moment of demand and onto a schedule you control — and both rot without ownership. The leaderboard needs a named refresh cadence, a concurrent swap so reading never blocks, and an 'as of stumps' stamp so staleness is visible. The archive needs its lifecycle automated end to end: next season's volume created before the first ball (a job pre-creating partitions, because an unrouteable scorecard is a failed insert), old volumes retired by detaching whole books, vacuum keeping pace on the hot volume. The test of the design is quiet: six months on, untouched, are the dashboards still fast and the disk not full?
sql
CREATE MATERIALIZED VIEW mv_daily_match_watch AS
SELECT date_trunc('day', event_time) AS day, match_id,
       sum(watched_ms) AS total_ms, count(*) AS events
FROM view_events
GROUP BY 1, 2;

-- Unique index enables REFRESH ... CONCURRENTLY (no read blocking)
CREATE UNIQUE INDEX ON mv_daily_match_watch (day, match_id);

-- Refresh on a schedule (see the pg_cron lesson); concurrently to avoid blocking reads
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_match_watch;

-- Tune autovacuum on the busiest partition; automate next-month partition + retention

Evaluation Rubric

  • Schema & data modelling (15%): appropriate types, constraints, and dimension/fact separation.
  • Partitioning (20%): events partitioned by time with verified pruning and a retention strategy.
  • Indexing (25%): indexes match the dashboard queries; each justified by an EXPLAIN ANALYZE before/after.
  • Materialised views (20%): costly aggregations precomputed, uniquely indexed, and refreshed concurrently on a schedule.
  • Evidence (10%): every optimisation backed by a captured plan and timing, not intuition.
  • Maintenance & concurrency (10%): partition lifecycle automation, autovacuum tuning, and short-transaction ingestion documented.

Extension Challenges: Add a BRIN index on event_time for the oldest, read-rarely partitions and compare its size to the B-Tree; introduce a rollup that supports incremental refresh (only the latest day) instead of full refresh; add full-text search over match titles for a search box on the dashboard; and benchmark ingestion throughput with and without the secondary indexes to quantify the read/write trade-off you are making.

Submit your capstone project

Checking submission status…
Lesson 20 of 35
0% complete