yudopr.dev
Back to all posts

Building a Data Warehouse with PostgreSQL, Kafka, and Debezium

2026-06-01•15 min read
Data EngineeringPostgreSQLKafkaDebeziumCase Study

Data Warehouse Architecture


Executive Summary

Every data team eventually hits the same wall: analytical queries running against a live transactional database. It starts small — a few dashboard queries, a couple of reports. Then one Monday morning, the engineering lead pings you: "The app is slow again."

That was the situation when I joined this project. MySQL was doing double duty — serving user transactions and powering dashboards, reverse ETL, and ELK ingestion simultaneously. The load had become a liability.

The mandate was clear: separate analytics from OLTP, keep data fresh, and don't blow the budget. What I built was a PostgreSQL-based Data Warehouse powered by Debezium CDC, Kafka, and a micro-batch stored procedure engine — a solution that could be operated by a small team, with no external cloud warehouse dependencies.


1. The Problem Space

Before: What Was Actually Happening

The previous architecture was a direct-read pattern:

graph LR
  MySQL[MySQL OLTP] -->|Direct Queries| ELK[ELK Stack]
  MySQL -->|Direct Queries| Dashboards[BI Dashboards]
  MySQL -->|Direct Sync| RevETL[Reverse ETL Systems]

Every dashboard refresh, every Kibana aggregation, every reverse ETL sync was hitting the same MySQL instance that was handling live user writes. This created three hard problems:

1. OLTP Performance Degradation Analytical queries on MySQL are fundamentally different from OLTP queries — they scan large ranges, perform aggregations, and hold table-level read locks longer. Under moderate analytical load, INSERT and UPDATE latency on the application side climbed noticeably.

2. No Historical Layer MySQL is an OLTP store — it's designed for current state, not history. There was no slowly changing dimension (SCD) tracking, no point-in-time query support, no ability to audit how a record looked on a specific date.

3. Scattered Business Logic Transformation rules were duplicated across the ELK index mappings, the BI tool's calculated fields, and reverse ETL sync scripts. When a business rule changed, it had to be patched in three places — each with a different query language.

The Constraints That Shaped Every Decision

Constraint Implication
Zero impact on OLTP writes No synchronous triggers, no read replicas for writes
Budget: near-zero additional infrastructure No Snowflake, no BigQuery, no Spark cluster
Latency: <5 min for reverse ETL Near real-time ingestion required
Small team, SQL-native Complex orchestration tools (Airflow) are out
Must be backfill-safe Every job must be safely re-runnable

2. Architecture Design

The Core Principle: Decouple Capture from Transformation

The architecture separates responsibilities into two independent concerns:

  1. Ingestion Layer: MySQL → Debezium → Kafka → PostgreSQL raw schema (event-driven, near real-time)
  2. Transformation Layer: raw → staging → core → datamart (SQL stored procedures, scheduled via pg_cron)

This separation means ingestion is never blocked by transformation, and transformation can safely lag behind ingestion without losing any events.

graph TD
  subgraph Source
    MySQL[MySQL OLTP\nApplication DB]
  end

  subgraph Ingestion
    Debezium[Debezium\nMySQL Connector]
    Kafka[Apache Kafka\nEvent Buffer]
    Consumer[Kafka Consumer\nUpsert Worker]
  end

  subgraph DWH["PostgreSQL Data Warehouse"]
    Raw[(raw.*\nCDC Sink)]
    Staging[(staging.*\nCleaned & Typed)]
    Core[(core.*\nFacts & Dims)]
    Datamart[(datamart.*\nPre-aggregated)]
  end

  subgraph Consumers
    BI[BI Dashboards]
    RevETL[Reverse ETL]
    ELK[ELK Stack]
  end

  MySQL -->|Binlog CDC| Debezium
  Debezium -->|CDC Events| Kafka
  Kafka --> Consumer
  Consumer -->|UPSERT| Raw
  Raw -->|pg_cron trigger| Staging
  Staging -->|pg_cron trigger| Core
  Core -->|pg_cron trigger| Datamart
  Datamart --> BI
  Datamart --> RevETL
  Datamart --> ELK

Why PostgreSQL as the Warehouse?

This choice raised eyebrows internally. PostgreSQL is an OLTP database — why not a proper columnar store like Redshift or Snowflake?

The reasoning was deliberate:

  • The team already knew SQL on MySQL. Switching to PostgreSQL was a minor cognitive jump. Switching to Redshift SQL, BigQuery's dialect, or Spark's DataFrame API would have been major.
  • The data volumes were manageable. I wasn't processing petabytes. With proper partitioning, indexing, and VACUUM, PostgreSQL handles hundreds of millions of rows comfortably for our query patterns.
  • Window functions, materialized views, and partitioning. PostgreSQL's analytical feature set is genuinely excellent for moderate-scale DWH use.
  • No additional managed service cost. The PostgreSQL instance ran on infrastructure we already had.

The tradeoff was that PostgreSQL requires more active tuning than a managed warehouse. I accepted that and built the maintenance routines into the platform.


3. The Ingestion Layer: CDC with Debezium + Kafka

How Change Data Capture Works Here

MySQL has a binary log (binlog) — a sequential record of every write that occurs. Debezium's MySQL connector tails this binlog and converts each INSERT, UPDATE, and DELETE into a structured CDC event published to Kafka.

This approach has a critical advantage over polling: it captures every row state, not just the latest one. A polling approach reading WHERE updated_at > last_run will miss records that were updated and deleted between runs. CDC doesn't have this gap.

Debezium Configuration Decisions

A few non-obvious configuration decisions that mattered in practice:

Snapshot Mode: initial On first startup, Debezium takes a consistent snapshot of the entire table before switching to binlog tailing. I configured snapshot.mode=initial so the raw schema starts with a complete baseline.

Tombstone Events Debezium publishes a tombstone (null-value) message after a delete event. This is important for Kafka log compaction — without it, deleted records pile up in the compacted log. I set tombstones.on.delete=true.

Decimal Handling MySQL DECIMAL types were configured with decimal.handling.mode=double to avoid schema evolution headaches with Avro serialization of arbitrary-precision types.

The Kafka Consumer: Upsert Into Raw

The Kafka consumer reads CDC events and applies them to the raw schema in PostgreSQL as UPSERT (INSERT ON CONFLICT DO UPDATE) operations.

The raw schema tables mirror the MySQL source schema almost exactly, with two additions:

-- Every raw table has these two metadata columns
_cdc_deleted_at   TIMESTAMPTZ   -- populated from Debezium delete events
_cdc_updated_at   TIMESTAMPTZ   -- populated from Debezium event timestamp

The upsert pattern:

INSERT INTO raw.orders (id, user_id, status, total, created_at, _cdc_updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (id) DO UPDATE SET
  user_id      = EXCLUDED.user_id,
  status       = EXCLUDED.status,
  total        = EXCLUDED.total,
  _cdc_updated_at = EXCLUDED._cdc_updated_at;

Deletes from MySQL result in the row being retained in raw with _cdc_deleted_at populated — we don't physically delete from the warehouse. Downstream layers filter out logically deleted records as needed.


4. Data Modeling: The Four-Layer Schema

Each schema layer has a single, well-defined responsibility. This makes the data lineage auditable and transformations testable in isolation.

Layer 1 — raw.*: The Immutable Source Mirror

Raw is a nearly 1:1 copy of MySQL, augmented with CDC metadata. No business logic lives here. The only transformations are:

  • Type coercion for PostgreSQL compatibility (e.g., MySQL tinyint(1) → PostgreSQL boolean)
  • Addition of _cdc_deleted_at and _cdc_updated_at metadata columns

No data is ever deleted from raw. It's the system of record within the DWH.

Layer 2 — staging.*: Cleansed & Validated

Staging performs the dirty work:

  • Deduplication: handles race conditions where two CDC events arrive for the same record in the same micro-batch
  • Type casting: parses string timestamps into proper TIMESTAMPTZ, normalizes phone number formats, etc.
  • Null handling: replaces known sentinel nulls (e.g., 'N/A', '0000-00-00') with proper NULL
  • Filtering: excludes logically deleted records (WHERE _cdc_deleted_at IS NULL)

Layer 3 — core.*: Business-Modeled Facts & Dimensions

Core implements the actual data model. I used a Kimball-style star schema — not because it's a religion, but because the query patterns and the team's familiarity with SQL JOIN made it the pragmatic choice.

core.fact_orders
  ├── order_id (PK)
  ├── user_dim_key (FK → core.dim_users)
  ├── product_dim_key (FK → core.dim_products)
  ├── order_date_key (FK → core.dim_date)
  ├── status
  ├── gross_revenue
  ├── net_revenue
  └── _etl_batch_id

core.dim_users (SCD Type 2)
  ├── user_dim_key (surrogate PK)
  ├── user_id (natural key)
  ├── full_name, email, tier
  ├── valid_from, valid_to
  └── is_current

SCD Type 2 for users and products. When a user's tier changes, I close the current record (set valid_to, is_current = false) and insert a new record. This enables point-in-time analysis — "what tier was this user when they placed this order?"

Layer 4 — datamart.*: Pre-aggregated for Consumers

Dashboards and reverse ETL read from datamart tables, not from core. These are pre-aggregated and indexed specifically for their consumer's access patterns:

-- Example: daily revenue datamart for BI dashboards
CREATE TABLE datamart.daily_revenue AS
SELECT
  d.date                     AS report_date,
  u.tier                     AS user_tier,
  p.category                 AS product_category,
  SUM(o.gross_revenue)       AS gross_revenue,
  SUM(o.net_revenue)         AS net_revenue,
  COUNT(DISTINCT o.order_id) AS order_count,
  COUNT(DISTINCT o.user_dim_key) AS unique_buyers
FROM core.fact_orders o
JOIN core.dim_date d    ON o.order_date_key  = d.date_key
JOIN core.dim_users u   ON o.user_dim_key    = u.user_dim_key AND u.is_current
JOIN core.dim_products p ON o.product_dim_key = p.product_dim_key AND p.is_current
GROUP BY 1, 2, 3;

The BI team reads datamart.daily_revenue. They never write SQL against core or raw.


5. The Transformation Engine: Stored Procedures + pg_cron

Why Stored Procedures?

The alternative was a Python-based orchestration layer (Airflow DAGs calling pandas transforms). I rejected it for this project for pragmatic reasons:

  1. All the data is already in PostgreSQL. Moving it out to Python for transformation and back in is wasteful I/O.
  2. Set-based SQL transforms outperform row-by-row Python at these volumes by orders of magnitude.
  3. The team writes SQL every day. Debugging a stored procedure is much faster for them than debugging a pandas pipeline.

The stored procedures follow a consistent interface:

CREATE OR REPLACE PROCEDURE etl.process_orders(
  p_business_date    DATE,
  p_start_ts         TIMESTAMPTZ,
  p_end_ts           TIMESTAMPTZ
)
LANGUAGE plpgsql
AS $$
DECLARE
  v_batch_id   BIGINT;
  v_rows_in    INT := 0;
  v_rows_out   INT := 0;
BEGIN
  -- 1. Open a batch control record
  INSERT INTO etl.job_runs (job_name, started_at, status, p_business_date, p_start_ts, p_end_ts)
  VALUES ('process_orders', NOW(), 'running', p_business_date, p_start_ts, p_end_ts)
  RETURNING id INTO v_batch_id;

  -- 2. Stage: clean and deduplicate
  INSERT INTO staging.orders (...)
  SELECT DISTINCT ON (id) ...
  FROM raw.orders
  WHERE _cdc_updated_at BETWEEN p_start_ts AND p_end_ts
    AND _cdc_deleted_at IS NULL
  ORDER BY id, _cdc_updated_at DESC;

  GET DIAGNOSTICS v_rows_in = ROW_COUNT;

  -- 3. Core: merge into fact table
  INSERT INTO core.fact_orders (...)
  SELECT ... FROM staging.orders WHERE batch_id = v_batch_id
  ON CONFLICT (order_id) DO UPDATE SET
    status       = EXCLUDED.status,
    net_revenue  = EXCLUDED.net_revenue,
    _etl_batch_id = EXCLUDED._etl_batch_id;

  GET DIAGNOSTICS v_rows_out = ROW_COUNT;

  -- 4. Refresh the datamart
  CALL etl.refresh_datamart_daily_revenue(p_business_date);

  -- 5. Close the batch as succeeded
  UPDATE etl.job_runs
  SET status = 'succeeded', finished_at = NOW(),
      rows_in = v_rows_in, rows_out = v_rows_out
  WHERE id = v_batch_id;

EXCEPTION WHEN OTHERS THEN
  UPDATE etl.job_runs
  SET status = 'failed', finished_at = NOW(), error_message = SQLERRM
  WHERE id = v_batch_id;
  RAISE;
END;
$$;

Idempotency Is Non-Negotiable

Every procedure is designed to be safely re-runnable for the same parameters. If a job fails and is retried for the same time window, the final state in the warehouse must be identical to what a successful first run would have produced.

This is enforced by the INSERT ... ON CONFLICT DO UPDATE pattern throughout. There are no unconditional INSERT INTO ... SELECT statements — everything is upserted.

Watermarking for Near Real-Time Reverse ETL

The micro-batch procedures that feed reverse ETL systems run every 1–5 minutes. The watermark logic prevents data loss while avoiding redundant processing:

CREATE OR REPLACE PROCEDURE etl.process_orders_micro_batch()
LANGUAGE plpgsql AS $$
DECLARE
  v_last_watermark  TIMESTAMPTZ;
  v_upper_bound     TIMESTAMPTZ;
  v_overlap_window  INTERVAL := '2 minutes';
BEGIN
  -- Get the end_time of the last successful run as our watermark
  SELECT end_ts INTO v_last_watermark
  FROM etl.job_runs
  WHERE job_name = 'process_orders_micro_batch'
    AND status   = 'succeeded'
  ORDER BY finished_at DESC
  LIMIT 1;

  -- Default to 24 hours ago if no prior run exists
  v_last_watermark := COALESCE(v_last_watermark, NOW() - INTERVAL '24 hours');

  -- Upper bound is set at procedure start (not end)
  v_upper_bound := NOW();

  -- Read from (watermark - overlap) to catch late-arriving data
  CALL etl.process_orders(
    p_business_date => v_upper_bound::DATE,
    p_start_ts      => v_last_watermark - v_overlap_window,
    p_end_ts        => v_upper_bound
  );
END;
$$;

The overlap window (2 minutes) is intentional. It re-reads records from slightly before the last watermark to handle two scenarios:

  1. Late-arriving CDC events: Kafka occasionally delivers events slightly out of order.
  2. Identical updated_at timestamps: Records updated in the same second as the previous watermark boundary need to be captured.

Because the upsert is idempotent, re-reading records from the overlap window has no adverse effect on data correctness.

pg_cron Scheduling

-- Micro-batch for reverse ETL: every 5 minutes
SELECT cron.schedule('orders-micro-batch', '*/5 * * * *',
  $$CALL etl.process_orders_micro_batch()$$);

-- Daily batch for KPIs: 1 AM every day
SELECT cron.schedule('orders-daily', '0 1 * * *',
  $$CALL etl.process_orders(CURRENT_DATE - 1, NULL, NULL)$$);

-- Weekly trend batch: Sunday 2 AM
SELECT cron.schedule('orders-weekly', '0 2 * * 0',
  $$CALL etl.process_weekly_trends(DATE_TRUNC('week', CURRENT_DATE - 7))$$);

pg_cron.job_run_details gives us a queryable history of every execution — job name, scheduled time, start time, end time, and return status — without any external monitoring infrastructure.


6. Data Quality & Operational Observability

The Control Table

Every job run writes to etl.job_runs:

CREATE TABLE etl.job_runs (
  id               BIGSERIAL PRIMARY KEY,
  job_name         TEXT          NOT NULL,
  started_at       TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
  finished_at      TIMESTAMPTZ,
  status           TEXT          CHECK (status IN ('running', 'succeeded', 'failed')),
  p_business_date  DATE,
  p_start_ts       TIMESTAMPTZ,
  p_end_ts         TIMESTAMPTZ,
  rows_in          INT,
  rows_out         INT,
  error_message    TEXT
);

This table is the first stop for any data freshness question. A simple query tells us when each job last ran, how many rows it processed, and whether it succeeded.

Automated Data Quality Checks

A suite of SQL assertions runs after each daily batch:

-- Freshness check: did we process data for the expected date?
DO $$
BEGIN
  ASSERT (
    SELECT MAX(order_date) FROM core.fact_orders
  ) >= CURRENT_DATE - 1,
  'Freshness failure: core.fact_orders missing yesterday''s data';
END;
$$;

-- Uniqueness check: no duplicate order_ids in the fact table
DO $$
BEGIN
  ASSERT (
    SELECT COUNT(*) FROM (
      SELECT order_id, COUNT(*) FROM core.fact_orders GROUP BY 1 HAVING COUNT(*) > 1
    ) dups
  ) = 0,
  'Uniqueness failure: duplicate order_ids found in core.fact_orders';
END;
$$;

-- Reconciliation: row count within 1% of source
DO $$
DECLARE
  v_source_count  BIGINT;
  v_dwh_count     BIGINT;
BEGIN
  SELECT COUNT(*) INTO v_source_count FROM raw.orders WHERE _cdc_deleted_at IS NULL;
  SELECT COUNT(*) INTO v_dwh_count    FROM core.fact_orders;

  ASSERT ABS(v_source_count - v_dwh_count)::FLOAT / NULLIF(v_source_count, 0) < 0.01,
  FORMAT('Row count reconciliation failed: source=%s, dwh=%s', v_source_count, v_dwh_count);
END;
$$;

PostgreSQL Maintenance

Running a DWH workload on PostgreSQL means taking autovacuum seriously. The raw and staging tables have very high churn (constant upserts), which generates significant dead tuple bloat.

I tuned autovacuum per-table for the high-churn tables:

ALTER TABLE raw.orders SET (
  autovacuum_vacuum_scale_factor = 0.01,  -- vacuum at 1% dead tuples instead of 20%
  autovacuum_analyze_scale_factor = 0.005
);

And scheduled explicit VACUUM ANALYZE after large daily batches:

SELECT cron.schedule('vacuum-raw-orders', '30 1 * * *',
  $$VACUUM ANALYZE raw.orders$$);

Monitoring pg_stat_user_tables.n_dead_tup and pg_stat_bgwriter became a weekly hygiene habit.


7. Key Decisions in Retrospect

Looking back, here are the decisions that had the largest impact — positive and negative:

What Worked Well

Debezium's binlog CDC was the right ingestion method. Polling (WHERE updated_at > ?) would have missed deletes and had timestamp collision issues. CDC captured everything, including hard deletes, with zero application-side changes.

Stored procedures kept the team productive. No new tools to learn, no Python environments to manage. The entire transformation layer is queryable, testable, and debuggable in psql.

The four-layer schema made debugging straightforward. When a dashboard showed wrong numbers, I could query each layer to pinpoint exactly where the divergence occurred. The raw layer being immutable meant I could always replay from source truth.

Idempotency eliminated incident stress. When a job failed at 2 AM, the on-call engineer simply re-ran it with the same parameters the next morning. No data corruption, no manual cleanup.

What Was Hard

Debezium schema evolution required careful handling. When the MySQL team added a column to a table, the Debezium connector required configuration updates and the consumer needed to be updated to handle the new schema. In retrospect, I should have invested in a schema registry (Confluent Schema Registry) earlier.

PostgreSQL VACUUM at high upsert volume needs attention. Early on, I under-tuned autovacuum on the raw tables. Query performance on those tables degraded gradually until we noticed bloat during a quarterly review. Table-level vacuum tuning fixed it, but it was avoidable.

pg_cron error handling is minimal. pg_cron records failure in job_run_details, but it doesn't alert on failure. I built a simple cron job on the app server that queried job_run_details and sent Slack alerts for failed jobs. It works, but a proper orchestrator would handle this more elegantly.


8. Results

Metric Before After
MySQL analytical query load ~40% of CPU ~8% of CPU
Reverse ETL data latency ~15–30 min <5 min
Data freshness for dashboards Near real-time (via ELK lag) <2 min (datamart)
DWH infrastructure cost — $0 additional
Historical data available None Full history since go-live
Business logic duplication 3+ systems Single SQL layer

The most qualitatively significant change was the last one. Having a single SQL-defined transformation layer meant the data team could make a business rule change once and trust it to propagate correctly to every consumer.


Conclusion

This architecture isn't glamorous. It doesn't use any particularly novel technology. PostgreSQL has existed for decades. Debezium and Kafka are mature, well-documented tools. pg_cron is a straightforward scheduler.

But the goal was never novelty — it was correctness, maintainability, and cost efficiency. The system I shipped was operated smoothly by our small engineering team, cost nothing additional in infrastructure, and handled the analytical load that was previously degrading production.

The most important engineering choices weren't technical — they were organizational: matching the technology to the team's skills, keeping operational complexity low, and making the system recoverable from failure without requiring midnight heroics.

The hardest data warehousing problems are rarely about scale. They're about clarity of ownership, correctness of transformations, and the discipline to keep things simple.


(Watch the architecture deep dive: Watch on YouTube)