Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
7 KiB
Thermograph DB image — Postgres 18 + parquet reads
The db service can read the app's Parquet climate cache
(data/cache/*.parquet) directly from SQL, for ad-hoc analytics, via
pg_duckdb — DuckDB embedded inside Postgres.
SELECT r['date'] AS date, r['tmax'] AS tmax, r['tmin'] AS tmin
FROM read_parquet('/parquet/1026_-2857.parquet') r
ORDER BY r['date'] LIMIT 5;
Chosen extension: pg_duckdb — and why
| Option | PG18? | Fit | Verdict |
|---|---|---|---|
| pg_duckdb (duckdb / MotherDuck) | Yes — official image pgduckdb/pgduckdb:18-v1.1.1 is genuine PG18.1 |
read_parquet('…') in plain SQL; globs, union_by_name, full DuckDB analytics engine |
Chosen |
| pg_parquet (Crunchy Data) | Yes (14–18) | COPY … TO/FROM '…' (format 'parquet') — import/export, not a query engine |
Viable, but COPY-oriented; no standalone official image (ships via Crunchy Bridge/CPK), so it'd need a Rust/pgrx source build |
| parquet_fdw | No prebuilt PG18 support; low activity | Foreign tables over parquet | Rejected — oldest, weakest PG18 story |
pg_duckdb wins for the stated goal (ad-hoc analytics): it exposes DuckDB's
read_parquet directly in SQL, so you query cache files like tables — no
import step, no foreign-table DDL, and you get aggregation/joins/globs across
many cells at once.
PG-version reality for PG18 (empirically verified 2026-07-19)
No version delta. PG18 support is real, not a fallback. The pulled image reports:
PostgreSQL 18.1 (Debian 18.1-1.pgdg12+2) on x86_64-pc-linux-gnu
pg_extension: pg_duckdb 1.1.0
shared_preload_libraries: pg_duckdb
The image is built on Debian 12 bookworm — the same base as the official
postgres:18 image — and uses the standard docker-entrypoint.sh. So
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB / PGDATA /
/docker-entrypoint-initdb.d / pg_isready all behave exactly as with
postgres:18. It is a drop-in replacement for the db service; nothing else
in the stack changes.
We FROM the official pg_duckdb image (pinned to 18-v1.1.1, not 18-main)
rather than FROM postgres:18 + compile, because pg_duckdb links a full DuckDB
build — compiling from source in the Dockerfile means the DuckDB toolchain and
a long, fragile build for no benefit over the maintainers' official PG18 image.
Files here
Dockerfile.db—FROM pgduckdb/pgduckdb:18-v1.1.1, plusCOPYof the init script so the image enables the extension on first init even without the compose bind mount.init/10-parquet.sql—CREATE EXTENSION IF NOT EXISTS pg_duckdb;(runs from/docker-entrypoint-initdb.don first cluster init).
Compose snippet to merge into the db service
Replace image: postgres:18 with the build: block; add the read-only parquet
bind and the init mount. Everything else in the db service stays as-is.
db:
# image: postgres:18 # <- remove; build the parquet-capable image
build:
context: .
dockerfile: deploy/db/Dockerfile.db
environment:
POSTGRES_USER: thermograph
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
POSTGRES_DB: thermograph
PGDATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
- ./data/cache:/parquet:ro # read-only parquet cache
- ./deploy/db/init:/docker-entrypoint-initdb.d
# healthcheck / cpus / deploy / restart: unchanged
Notes:
- The bind mount at
/docker-entrypoint-initdb.dreplaces the base image's own init scripts (its0001-install-pg_duckdb.sqland the MotherDuck-only0002-enable-md-pg_duckdb.sql). That's intended: our10-parquet.sqlstill runsCREATE EXTENSION, and we don't use MotherDuck. If you prefer to keep the image's baked scripts, drop the:/docker-entrypoint-initdb.dline — theDockerfilealready bakes10-parquet.sqlin. :rokeeps the DB from ever mutating the app's cache. In prod the app writes the cache to theappdatavolume; point this bind at wherever that lives on the host if you want the DB to see the live cache rather than the repo copy.- Init scripts only run when
PGDATAis empty. On an existing database, enable it once by hand:docker compose exec db psql -U thermograph -d thermograph \ -c 'CREATE EXTENSION IF NOT EXISTS pg_duckdb;'
Usage — real cache file at /parquet/…
Cache columns: date, tmax, tmin, precip, wind, gust, humid, fmax, fmin, feels.
pg_duckdb ≥ 0.3 uses the r['colname'] subscript syntax (not
AS t(col type, …)).
-- one cell, first rows
SELECT r['date'] AS date, r['tmax'] AS tmax, r['tmin'] AS tmin, r['precip'] AS precip
FROM read_parquet('/parquet/1026_-2857.parquet') r
ORDER BY r['date'] LIMIT 5;
-- per-year analytics over one cell
SELECT EXTRACT(YEAR FROM r['date']::timestamp) AS yr,
ROUND(AVG(r['tmax'])::numeric, 1) AS avg_tmax,
MAX(r['tmax']) AS record_high
FROM read_parquet('/parquet/1026_-2857.parquet') r
WHERE r['date'] >= '2020-01-01'
GROUP BY yr ORDER BY yr;
-- glob across every cached cell (union_by_name handles the _rf / _forecast
-- files whose column sets differ)
SELECT COUNT(*) FROM read_parquet('/parquet/*.parquet', union_by_name := true) r;
Proof of work (verified 2026-07-19)
Built deploy/db/Dockerfile.db, ran a throwaway container with the real
data/cache bind-mounted read-only at /parquet, then:
$ psql -c "SELECT version();"
PostgreSQL 18.1 (Debian 18.1-1.pgdg12+2) on x86_64-pc-linux-gnu ...
$ psql -c "SELECT extname, extversion FROM pg_extension WHERE extname='pg_duckdb';"
extname | extversion
-----------+------------
pg_duckdb | 1.1.0
$ psql -c "SELECT COUNT(*) FROM read_parquet('/parquet/1026_-2857.parquet') r;"
row_count
-----------
16982
$ psql -c "SELECT r['date'] AS date, r['tmax'] AS tmax, r['tmin'] AS tmin,
r['precip'] AS precip, r['humid'] AS humid
FROM read_parquet('/parquet/1026_-2857.parquet') r
ORDER BY r['date'] LIMIT 5;"
date | tmax | tmin | precip | humid
---------------------+------+------+--------+-------
1980-01-01 00:00:00 | 56.2 | 34.7 | 0 | 73
1980-01-02 00:00:00 | 63.8 | 38 | 0 | 79
1980-01-03 00:00:00 | 60.1 | 46.1 | 0.315 | 83
1980-01-04 00:00:00 | 51.7 | 40 | 0 | 68
1980-01-05 00:00:00 | 56.5 | 33.7 | 0 | 78
$ psql -c "SELECT EXTRACT(YEAR FROM r['date']::timestamp) AS yr,
ROUND(AVG(r['tmax'])::numeric,1) AS avg_tmax, MAX(r['tmax']) AS record_high
FROM read_parquet('/parquet/1026_-2857.parquet') r
WHERE r['date'] >= '2020-01-01' GROUP BY yr ORDER BY yr;"
yr | avg_tmax | record_high
------+----------+-------------
2020 | 78.8 | 99
2021 | 77.0 | 93
2022 | 79.2 | 101.4
2023 | 80.8 | 107.1
2024 | 79.8 | 97.5
2025 | 80.0 | 99.4
2026 | 77.8 | 95.6
$ psql -c "SELECT COUNT(*) FROM read_parquet('/parquet/*.parquet', union_by_name := true) r;"
count
--------
525421 -- rows across all 45 cached cells