Pipeline to Dashboardnihalsulaiman.com

Week 4 · Load to the warehouse › Day 25

Day 25. Postgres, DuckDB and ClickHouse side by side

⏱ 4h · learn 60m · lab 145m · ship 20m · review 15m🏷 marketing, QSRBefore: Day 24

By the end of today you can

  • Load the same data into Postgres (COPY + ON CONFLICT), DuckDB and ClickHouse
  • Use ClickHouse's ReplacingMergeTree for upserts
  • Benchmark load and query times fairly
  • Write a swappable Warehouse resource for Dagster

1. Why this matters

Your later mission is low-cost stacks for medium companies. That means knowing the open-source warehouses by hand, not by blog post. Today you load the same tables into three of them and measure.

2. Postgres

docker run -d --name pg -e POSTGRES_PASSWORD=pg -p 5432:5432 postgres:16
import psycopg, io
with psycopg.connect("postgresql://postgres:pg@localhost:5432/postgres") as con, con.cursor() as cur:
    cur.execute("create table if not exists stg_ads_load (like fct_ads_daily including defaults)")
    cur.execute("truncate stg_ads_load")
    buf = io.StringIO(); df.write_csv(buf); buf.seek(0)
    with cur.copy("copy stg_ads_load from stdin with (format csv, header true)") as cp:
        cp.write(buf.read())                                          # COPY: the fast path
    cur.execute("""
      insert into fct_ads_daily select * from stg_ads_load
      on conflict (ad_date, campaign_id) do update set
        spend = excluded.spend, clicks = excluded.clicks, conversions = excluded.conversions, updated_at = now()
      where (fct_ads_daily.spend, fct_ads_daily.clicks, fct_ads_daily.conversions)
            is distinct from (excluded.spend, excluded.clicks, excluded.conversions)""")

3. DuckDB

con = duckdb.connect("data/wh.duckdb")
con.sql("""create table if not exists fct_ads_daily (
  ad_date date, campaign_id varchar, spend decimal(18,2), clicks integer, impressions bigint,
  conversions integer, conversion_value decimal(18,2), updated_at timestamp,
  primary key (ad_date, campaign_id))""")
con.sql("""insert or replace into fct_ads_daily          -- upsert: needs the primary key above
  select ad_date, campaign_id, spend, clicks, impressions, conversions, conversion_value, now()
  from 'data/staging/ads/*.parquet'""")

DuckDB can also attach Postgres (attach 'postgresql://…' as pg (type postgres)) and query or write it directly, which is useful for heavy scans over a Postgres warehouse.

4. ClickHouse

docker run -d --name ch -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=ch clickhouse/clickhouse-server
create table fct_ads_daily (
  ad_date Date, campaign_id LowCardinality(String), spend Decimal(18,2), clicks UInt32,
  impressions UInt64, conversions UInt32, conversion_value Decimal(18,2), updated_at DateTime64(3)
) engine = ReplacingMergeTree(updated_at)
order by (campaign_id, ad_date);

ReplacingMergeTree keeps the row with the latest updated_at per sort key, eventually (during background merges). Queries that need exact answers now use FINAL or argMax. That's the ClickHouse trade-off: blazing scans and inserts, unusual update semantics.

import clickhouse_connect
ch = clickhouse_connect.get_client(host="localhost", username="default", password="ch")
ch.insert_df("fct_ads_daily", df.to_pandas())
ch.query("select campaign_id, sum(spend) from fct_ads_daily final group by campaign_id")
Maths and principle

Principle: benchmark fairly: same data, same query, warm and cold runs, report the median of several.

Equation: report \(\tilde t = \text{median}(t_1..t_k)\) and the spread (min–max). Speed-up \(= \tilde t_A / \tilde t_B\). A difference under ~20% on a laptop is usually noise.

Analogy: repeated measurements with error bars before claiming an effect. Where it breaks: caches. The first (cold) run reads from disk; later runs hit memory. Report both.

Try it: runs (s): 2.1, 0.40, 0.42, 0.39, 0.41. Cold time and warm median? (Cold 2.1 s; warm median 0.405 s.)

5. One interface, many warehouses

class Warehouse(dg.ConfigurableResource):
    def upsert(self, table: str, df: pl.DataFrame, keys: list[str]) -> dict: ...
    def replace_partition(self, table: str, df: pl.DataFrame, col: str, value) -> dict: ...
    def query(self, sql: str) -> pl.DataFrame: ...

class AzureSqlWarehouse(Warehouse): ...   # Day 23–24 code
class PostgresWarehouse(Warehouse): ...   # COPY + ON CONFLICT
class DuckDBWarehouse(Warehouse): ...

The asset calls warehouse.upsert(...); the environment chooses which.

The 5-year lens

SQL dialects differ in the details that bite: MERGE vs ON CONFLICT; datetime2 vs timestamptz; top vs limit; string concatenation; identifier quoting; case sensitivity (Postgres folds unquoted names to lower case). Keep warehouse-specific SQL inside the resource classes and in dbt adapters (Week 5). SQLGlot can transpile simple queries between dialects, which helps for migrations.

Lab

  1. Implement PostgresWarehouse and DuckDBWarehouse with upsert and replace_partition. Reuse Day 24 for AzureSqlWarehouse.
  2. Load 120 days of ads and QSR data into all three (plus ClickHouse by hand). Time loads.
  3. Run the same 4 queries everywhere: total by channel, daily trend, top 10 campaigns by ROAS, a 7-day rolling sum with a window function. Cold and warm, median of 5.
  4. Put the results table and a short interpretation in the README. Note anything that needed dialect changes.
  5. Wire the resource into Dagster with P2D_WAREHOUSE=duckdb|postgres|azuresql.

Ship

feat(warehouse): swappable Postgres/DuckDB/Azure SQL resource and benchmark.

Check yourself

1. What's the fast path for bulk-loading Postgres from Python?

2. A ClickHouse ReplacingMergeTree query returns two versions of the same campaign-day. Why?

3. Warm medians: DuckDB 0.31 s, Postgres 0.36 s on a laptop. Fair conclusion?

My notes