database.schema.table
(one connection, one database; the first component is always the connected
database). Cost paradigm: database load, expressed as database-seconds.
Read-only against data, enforced in depth.
Authentication: discover, don’t ask
The engine discovers a connection at runtime and never prompts for or persists a password. Discovery order:postgres.servicein.dex/config.yml, naming apg_service.confentry (PGSERVICEFILE, else~/.pg_service.conf)DATABASE_URL(the twelve-factor URL most app stacks already export)- the
PG*environment (PGHOST/PGDATABASE/PGSERVICEand friends), resolved natively by libpq, including~/.pgpass - the committed non-secret
postgres.host/port/dbname/userconfig target (password still supplied byPGPASSWORDor~/.pgpass) - the
hostof atype: postgrestarget in a discovered dbtprofiles.yml
database_url:password or
environment:external) is ever surfaced; the credential kind is password
when one is inline and external for everything else (.pgpass, peer or
trust auth, SSL client certificates), deliberately coarse. DSNs, identities,
and passwords never cross the envelope (the sanitizer additionally strips
user:pass@host URL fragments). Every discovery failure names all the fixes.
Config
scripts/postgres_seed.sql shows the shape
(dex_ro and dbt_dev roles).
Cost model: load, not dollars
Postgres bills nothing, but dex is usually pointed at a production OLTP primary, and an unbounded scan there is a real cost: it evicts cache, holds back vacuum, and steals cycles from the application. So the guarded quantity is database-seconds, gated through the same strict handshake as every metered connector.- Free:
connect test,explore inventory, all schema facts (pg_cataloglookups, no table scans),pg_statsreads,EXPLAINestimation, and the free axes ofmaintain check(schema and volume read catalog metadata only). - Metered: profiling aggregates,
explore query, relationship verification probes, distinct-count escalations, andtransform build.
explore query and explore cluster bill an auto-profile of an object this connection has that the .dex/ cache cannot adjudicate, priced into the same handshake as the statements: see �3. Pass --no-auto-profile (or set auto_profile: false in .dex/config.yml) to be refused instead.
Budgets (budget.ceiling, --budget, budget.session_ceiling) are
database-seconds: the number you budget is the number the server enforces.
Estimates are a heuristic, honestly labeled. Query estimates come from
the genuinely free planner preflight (EXPLAIN (FORMAT JSON)), so an
index-served lookup is not quoted as a full scan; plan cost translates to
seconds through a deliberately conservative scan rate, and profile estimates
come from relation sizes over the same rate. Every handshake payload carries
estimate_quality: "heuristic".
The budget is hard-enforced regardless of estimate quality. Before every
metered statement the session’s statement_timeout is set to the remaining
budget, so a wrong heuristic cannot overrun the ceiling: Postgres kills the
statement and dex reports the over-ceiling refusal. Actual spend is
wall-clock seconds per statement (a killed statement still bills what ran),
recorded as billed_seconds in the ledger. Every session connects as application_name = 'dex' for
attribution in pg_stat_activity.
Budgets here are database-seconds; the handshake itself is in
�0.
Read-only, enforced in depth
- The session sets
default_transaction_read_only = onbefore any statement, so the server itself refuses writes (SQLSTATE 25006) even if every other guard were bypassed. Connections are autocommit, so no idle-in-transaction session ever holds back vacuum on the primary. - Every data statement passes the SELECT-only guard parsed in the postgres
dialect through one execution door (DML, DDL,
COPY, and multi-statement forms all refuse). - The adapter issues no mutating statements: catalog SELECTs,
EXPLAIN, and sessionSETs only. - The documented grant shape is a least-privilege read-only role; dbt dev
builds write to
dev_schema(defaultdbt_dev), which is refused as a source, and the generated profile renders one thread and anenv_varpassword reference, never a value.
Profiling behavior
Profiling is deliberately light on the primary. The metered batch is one cheap single-pass scan per 50 columns:COUNT(*), per-column non-null
counts, and min/max only for engine-cleared safe columns. Distinct counts
come free from the planner’s own statistics (pg_stats.n_distinct, scaled
by the exact row count when negative); the value-carrying statistics columns
(most_common_vals, histogram_bounds) are never read. A statistics
estimate is never a uniqueness verdict: near-unique keys escalate to an exact
COUNT(DISTINCT) inside the confirmed budget through the standard escalation
flow, and that scan also upgrades the row count from the reltuples estimate
to an exact figure, so uniqueness proofs and grain verdicts compare against
real rows.
Freshness caveats, surfaced as data-quality notes rather than silently
thinner numbers: a never-analyzed table has no row estimate and no distinct
estimates (the note names ANALYZE as the fix), and maintain volume on
unprofiled tables compares planner estimates whose accuracy tracks
autovacuum’s ANALYZE cadence. json, jsonb, arrays, bytea, xml,
tsvector, and the geometric and PostGIS types degrade to non-null counts.
Tables above postgres.max_full_profile_bytes are profiled from a block
sample (TABLESAMPLE SYSTEM), noted, with uniqueness not judged.
dbt builds
transform init renders a type: postgres dev profile from the discovered
connection: host, port, user, and dbname from whichever source won discovery,
schema pinned to the dedicated dev schema, one thread, and
password: "{{ env_var('PGPASSWORD', '') }}" (the empty default keeps
~/.pgpass and peer auth working when no variable is set). dbt has no
dry-run, so transform build cannot be estimated upfront; the ceiling and
confirmation still bind, and the ceiling is injected into the dbt subprocess
as PGOPTIONS="-c statement_timeout=<ceiling>s", the per-statement
server-side cap (the maximum_bytes_billed analogue). Actual per-node
execution time is summed into billed_seconds and recorded to the ledger.
transform build --verify inherits that: its row counts cannot be priced
upfront either, so they are gated after the build as a phase drawn against the
reservation the build already holds, and their execution time is billed into the
same settlement. Only a relation the warehouse keeps no row count for is counted
at all, which is any view (dbt’s default materialization); a table’s count is
free catalog metadata, and a verdict resting on it is reported exact: false to
say so.
transform test --mutate prices its whole batch as one number and confirms it
once, then runs one dbt invocation per mutant. Nothing is materialized: a mutant
builds as an ephemeral model, so the dev namespace holds exactly the relations it
held before. A budget that runs out partway stops the run, and the remaining
mutants are reported not_run rather than the budget being exceeded. Survivors
are not yet compared with the model on the dev data on this connector, so each
reports equivalence.status: not_checked with the reason, and nothing is
priced or run for it.
--verify also reads postgres.dev_schema for the length of that one command,
because dbt writes the relations it is judging there and that schema is refused
as a source everywhere else. The schema need not exist yet: dbt creates it on a
first build, and dex lists it after dbt has run. How that read is bounded is
under Read scope in �2.
transform init content-checks the dev schema (and, with --layered-schemas,
the sibling staging_dev / intermediate_dev / marts_dev layer schemas)
with one pg_class catalog lookup per schema, warning when one already holds
tables or views; a missing schema stays silent, since dbt creates it.
Testing
Offline: a stateful fake connection (tests/fakes/postgres.py) that records
statement ordering, serves catalog and pg_stats lookups from a table
registry, answers EXPLAIN with size-derived plan costs, simulates timing on
an injected clock, and enforces statement_timeout with psycopg’s real
QueryCanceled. Live: an env-gated integration suite (DEX_TEST_PG_DSN,
plus DEX_TEST_PG_DEV_PASSWORD for builds) against the seeded database from
scripts/postgres_seed.sql; scripts/setup_postgres_dev.sh stands up the
same thing locally in Docker, and CI runs the suite against a free, keyless,
digest-pinned postgres:16 service container with its capabilities dropped
(kept in the integration workflow for pattern parity, never a merge gate).
Source: references/postgres.md (edit on main). This page is generated; change the source, not the site.