database.schema.table (one
connection, one database; the first component is always the connected
database). Cost paradigm: compute-seconds, translated to RPU-hours on
Serverless. Read-only against data, enforced in depth. Provisioned clusters
ride the same SQL and metadata paths with seconds-only accounting (node-hours
bill flat, so there is nothing to translate); the cost gate is designed for
Serverless.
Authentication: discover, don’t ask
The engine discovers a connection at runtime and never prompts for or persists a password. Discovery order:redshift.workgroupin.dex/config.yml: the Serverless pin. The AWS default credential chain (aws configure,AWS_*env, SSO, an assumed role, instance metadata;redshift.aws_profilepins a named profile) resolves the endpoint, port, and database from the control plane, and the driver mints IAM temporary database credentials (GetCredentials) at connect time. Nothing durable exists to leak.redshift.cluster_identifierplusdbname/user: the provisioned IAM analogue (GetClusterCredentials).- the
REDSHIFT_*environment (REDSHIFT_HOST,REDSHIFT_DATABASE,REDSHIFT_USER, password viaREDSHIFT_PASSWORD) - the committed non-secret
redshift.host/port/dbname/userconfig target (password still supplied byREDSHIFT_PASSWORD) - the
hostof atype: redshifttarget in a discovered dbtprofiles.yml
iam_serverless:profile
or config_target:password); identities, keys, and passwords never cross the
envelope. Every discovery failure names all the fixes, including the IAM
permissions the workgroup path needs (redshift-serverless:GetWorkgroup,
GetCredentials, and GetNamespace for database discovery).
Config
svv_table_info (an IAM-minted user starts without it, and without it table
sizes are unknown and estimates degrade to minimums), with write access only
on the dedicated dev schema for the dbt user.
Scoping a command
redshift.schemas is the committed source allowlist. --scope narrows it
for one command and is repeatable; a Redshift scope is a bare <schema> in
the connected database (dbt refuses cross-database references, so there is
nothing to qualify). Resolution is a catalog lookup and happens before
anything is estimated: a scope that names no schema is refused with the
schemas that do exist listed, never quietly dropped, and --scope narrows
the committed allowlist, never widens it. --project and --dataset are
BigQuery vocabulary and error here.
Cost model: compute-seconds, honestly floored
Redshift Serverless charges RPU-hours whenever compute is active, with a 60-second minimum each time an idle workgroup wakes, and AWS treats every incoming query as billable activity. Two consequences dex states plainly rather than hides:- Metadata is cheap, not free. Inventory,
connect test, estimation, and the free axes ofmaintain checkread only catalogs (pg_catalog,SVV_TABLE_INFO,SVV_COLUMNS) and are not gated, but touching an idle workgroup can incur the wake minimum;connect testcarries that warning on Serverless. - Estimates carry the wake floor once per command. There is no API that says whether compute is warm (and probing would itself bill), so every estimate on Serverless includes the 60-second minimum exactly once, labeled an upper bound that actuals waive when compute was already active.
explore query, relationship
verification probes, distinct-count escalations, and transform 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
compute-seconds: the number you budget is the number the server enforces.
Every cost surface also carries the translation to RPU-hours
(seconds x base_capacity / 3600, base capacity from the workgroup via the
control plane, labeled approximate because Serverless can scale above it)
and to dollars when redshift.rpu_price_usd is set. dex never guesses a
dollar price: the RPU-hour rate varies by region and agreement.
Estimates are a heuristic, honestly labeled. Redshift has no dry-run,
and its EXPLAIN costs are relative units with no honest translation to
seconds, so estimates come from table bytes (SVV_TABLE_INFO) over a
conservative capacity-scaled scan rate; every handshake payload carries
estimate_quality: "heuristic". The same estimator prices transform build:
each compiled model, snapshot, and test is estimated and summed into the
build’s upfront cost.
transform build --verify costs only where the warehouse keeps no row
count, so a table’s count is free metadata; how the counts are priced into
the build’s own estimate is in
�1.
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 redshift.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.
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: Redshift 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'
(SYS_CONNECTION_LOG) and sets query_group = 'dex' for attribution.
The estimate carries its RPU translation; the handshake itself is in
�0.
Read-only, enforced in depth
- Every data statement passes the SELECT-only guard parsed in the redshift
dialect through one execution door (DML, DDL,
COPY,UNLOAD, and multi-statement forms all refuse). - The adapter issues no mutating statements: catalog SELECTs and session
SETs only. - The session attempts
default_transaction_read_only = on; whether Redshift honors it is probed rather than assumed, andconnect testreports the truth assession_read_only(the guard and grants hold either way). - The documented grant shape is a least-privilege read-only user; dbt dev
builds write to
dev_schema(defaultdbt_dev), which is refused as a source, and the generated profile renders one thread and IAM or anenv_varpassword reference, never a value.
Profiling behavior
Profiling is batched aggregates in single passes:COUNT(*), per-column
non-null counts, HLL(...) approximate distincts (Redshift refuses more
than three APPROXIMATE COUNT(DISTINCT) expressions per statement, so the
equivalent HLL aggregate keeps a wide batch a single pass), and min/max only
for engine-cleared safe columns; never row values. Redshift keeps no usable
planner distincts, so approximate distincts ride the billed batch, and an
approximation is never a uniqueness verdict: near-unique keys escalate to an
exact COUNT(DISTINCT) inside the confirmed budget, and that scan also
upgrades the SVV_TABLE_INFO row estimate to an exact figure. When no single
column proves unique, the composite-key probe (a bounded batch of exact
distinct-combination counts) spends inside the same confirmed budget, and
like the escalation it carries the pending Serverless wake minimum when it
bills first; when the remaining budget cannot cover every pair, the probe
narrows to the best-ranked pairs it can afford, and skips with a note only
when it cannot afford one, in which case the grain stays unknown. SUPER,
VARBYTE, GEOMETRY, GEOGRAPHY, and HLLSKETCH columns degrade to
non-null counts. There is no sampled-profiling threshold: Redshift has
no TABLESAMPLE, so a sampling knob would be a lie; the budget is the only
bound. Empty tables are inventoried from pg_class even though
SVV_TABLE_INFO omits them.
Every CAST in a generated aggregate is total, and has to stay that way.
Redshift evaluates a CASE branch’s cast for rows the WHEN never selects,
so guarding a cast behind a shape predicate inside a CASE does not protect
it: the declared-type-vs-content probe did exactly that and died on any table
with one non-numeric string in a profiled column (Invalid digit, Value 'p', Pos 0, Type: Long). There is no TRY_CAST here and the Python UDF escape
hatch is end-of-support after 2026-06-30, so the discipline is in the
expression shape: a cast’s argument is itself a CASE yielding a digit-only
string on every row, with a sentinel every predicate built on the cast
rejects. It is enforced offline for all six adapters (see
assert_every_cast_is_total), because no unit test can catch a dialect that
disagrees with the standard about evaluation order.
Two more spellings the server refuses outright, both verified live.
DATEDIFF resolves to pg_catalog.date_diff, which is declared over
DATE/TIME/TIMETZ/TIMESTAMP and has no TIMESTAMPTZ overload, while
DATE_TRUNC over a TIMESTAMPTZ column returns TIMESTAMPTZ: the periods
the temporal-continuity probe diffs are exactly the shape it rejects
(function pg_catalog.date_diff("unknown", timestamp with time zone, timestamp with time zone) does not exist), which failed the whole profiling
statement for any table carrying a TIMESTAMPTZ column. Both operands are
therefore cast to TIMESTAMP, a conversion that is total for every type
reaching that path and shifts nothing, since it applies to both sides by the
same rule. And SUBSTR, the spelling the shared expressions use, is refused
by name (SUBSTR() function is not supported (Hint: use SUBSTRING instead))
at execution over a real table, not only in a leader-node-only query, so this
adapter emits SUBSTRING for the slash-date component extraction. Neither
spelling is universal in the other direction: BigQuery has SUBSTR and no
SUBSTRING, which is why the idiom is per-connector rather than swapped in
the shared builder.
dbt builds
transform init renders a type: redshift dev profile from whichever
source won discovery: IAM discovery renders method: iam (temporary
credentials minted by dbt-redshift at runtime, nothing persisted), password
discovery renders password: "{{ env_var('REDSHIFT_PASSWORD') }}" (a Jinja
reference, not a value), both with schema pinned to the dedicated dev
schema and one thread. Before the cost gate, and for free, transform build
refuses config that has drifted from the rendered profile, and a dev schema
the profile’s user lacks the privilege to create or write (the Postgres
question: dbt creates the schema, but only if the user may). transform init
additionally content-checks the dev schema (and, with --layered-schemas,
the staging_dev / intermediate_dev / marts_dev layer schemas) with one
catalog lookup per schema, warning when one already holds tables or views;
this works on IAM profiles too, since content, unlike privilege, is not a
per-user question. dbt has no
dry-run and dex cannot inject a per-build server cap through dbt-redshift,
so the honest layering is: per-node execution time summed into
billed_seconds on the ledger, a durable ALTER USER dbt_dev SET statement_timeout applied by the setup script, and a workgroup usage limit
(RPU-hours per day) as the account-level backstop. Keep one dev schema per
building user: dbt swaps a rebuilt relation in place, and a relation another
user owns breaks the swap with a bare relation already exists (verified
live), so two identities sharing dbt_dev will trip over each other’s
views.
Testing
Offline: a stateful fake connection (tests/fakes/redshift.py) that records
statement ordering, serves the catalog census (including SVV_TABLE_INFO’s
empty-table omission), simulates timing on an injected clock, and enforces
statement_timeout with redshift_connector’s real error shape. Live: an
env-gated integration suite (DEX_TEST_REDSHIFT_*) against a seeded
Serverless workgroup, run in CI on a schedule via GitHub OIDC role assumption
(no stored keys), never as a merge gate; scripts/setup_redshift_ci.sh
provisions the workgroup, users, and the usage-limit backstop.
Source: references/redshift.md (edit on main). This page is generated; change the source, not the site.