YAML config schema¶
load_runner_config(path) parses a YAML file into a LoadedRunnerConfig whose fields
plug straight into DbtDag(...).
Pass the whole LoadedRunnerConfig in one shot. Every routing field
(runner / runners / default_runner / overrides /
task_groups) is auto-wired, including mode: group | single
entries under overrides[tag.<t>] ( replacement for the
removed top-level tag_groups: key).
Forward each field explicitly. Callers occasionally need this so they can mix Python-side overrides with YAML values on a field-by- field basis.
dag = DbtDag(
runners=cfg.runners,
default_runner=cfg.default_runner,
overrides=cfg.overrides,
tag_overrides=cfg.tag_overrides,
task_groups=cfg.task_groups,
project=...,
project_archive_s3="...",
)
metadata not exposed as Python kwargs
cfg.tag_group_specs (from overrides[tag.<t>]: {mode: group,
...}) and cfg.tag_single_name_prefixes (from
overrides[tag.<t>]: {mode: single, name: <prefix>}) do NOT
have direct Python-level kwargs on DbtDag. They only flow
through config=cfg. Use the recommended tab above if your
YAML declares either shape.
Variable interpolation (vars=)¶
load_runner_config accepts an optional vars kwarg. When set, every ${name}
in the raw YAML text is replaced with str(vars[name]) before the YAML
parser runs, so a single runners.yml can drive dev/qa/prd without file
duplication or wrapper scripts.
import os
from dbt_aws.common import load_runner_config
cfg = load_runner_config(
"runners.yml",
vars={"env": os.environ["AIRFLOW__ENV__ENVIRONMENT"]},
)
runners:
glue_spark_lib:
type: glue_spark
mode: create
iam_role_name: se-intelds-edh-euwe1-rdo-glue-service-${env}
deploy_bucket: se-intelds-edh-euwe1-glue-scripts-${env}
upload_artefacts_s3_prefix:
"s3://se-intelds-edh-euwe1-rdo-stratus-${env}/dbt-aws-airflow/glue_spark_lib/"
create_job_kwargs:
SecurityConfiguration: se-intelds-edh-euwe1-security_conf-${env}
DefaultArguments:
"--conf": >-
spark.sql.catalog.glue_catalog.warehouse=s3://se-intelds-edh-euwe1-rdo-dbt-data-gold-${env}/gold/
default_runner: glue_spark_lib
Semantics:
vars=None(default): no interpolation. Behaviour is bit-for-bit identical to previous releases; a literal${...}in the YAML survives to the runner.vars=dict: every${name}is replaced withstr(vars[name]). Substitution is a plain string replace on the raw byte stream (no Jinja, no filters, no arithmetic), so a variable can appear in the middle of any string — bucket names, ARNs, IAM role names, or fragments inside multi-line>-scalars.- Undefined references error at load time. A
${roll_name}with no matching entry invarsraisesRunnerConfigErrornaming both the missing key AND the caller-provided key set, so users don’t have to guess which side is wrong. - Escape hatch:
$${...}→ literal${...}. The inner name is NOT looked up invars. Use this for the rare case where a legitimate dollar-brace substring must survive interpolation. - No implicit
os.environfallback. Callers must passvars=explicitly. This keeps the config’s dependencies obvious and prevents the library from accidentally capturing unrelated environment vars. - Existing validation runs after interpolation. A typo in a variable
name (
${roll_name}) surfaces as an interpolation error; a typo in a YAML key (iam-role-namevsiam_role_name) still surfaces as aRunnerConfigErrorfrom_construct_runner. - INFO log at DAG-parse time listing the set of resolved keys (values redacted), so logs make it obvious which env a file was rendered for.
Top-level keys¶
# === RUNNERS ===========================================================
# Exactly one of `runner` (single) OR `runners` (multi) must be present.
runner: # single-runner shortcut
type: glue_spark # glue_spark | glue_python_shell |
# glue_interactive_session | emr_serverless |
# emr_cluster_step
# ... runner kwargs (see "Runner shapes" below) ...
# OR
runners: # multi-runner map
spark:
type: glue_spark
# ... runner kwargs ...
shell:
type: glue_python_shell
# ... runner kwargs ...
default_runner: spark # required when `runners:` is set
# === OVERRIDES =========================================================
# One unified block. Three entry shapes, all with the SAME field schema:
# * `all:` -- broadest scope, applies to every node in the
# rendered graph (after select / exclude filtering).
# * `tag.<name>:` -- bulk-by-tag override.
# * `<unique_id>:` -- per-node override, keyed by dbt's unique_id.
#
# , `all:` and `tag.<name>:` entries accept two meta-keys
# that are NOT part of the runner's OVERRIDE_TYPE:
# * `mode: group | single` (default: single)
# - `single` -- one Airflow task per node, entry supplies bulk
# override defaults. This is the earlier releases behaviour.
# - `group` -- collapse every eligible node into ONE Airflow
# task per (name, runner). Replaces the earlier releases top-level
# `tag_groups:` key.
# * `name: <airflow-task-id-legal-string>`
# - Under `mode: group`: the collapsed task's id (default:
# the tag name for `tag.<t>`, the string "all" for `all:`).
# - Under `mode: single`: a task-id PREFIX applied to every
# matching node, so siblings sort together in the Graph view
# (`<name>__<sanitised_uid>`).
#
# Precedence per field (higher wins). See
# ``docs/reference/precedence.md`` for the canonical ladder:
# 1. Runner default (on the `runners:` block)
# 2. `overrides.all.<field>`
# 3. `overrides[tag.<name>].<field>`
# 4. `node.meta.stratus.<field>` (declared in dbt project)
# 5. `overrides[<unique_id>].<field>`
#
# Per-node ALWAYS wins per-field over tag; tag wins over all.
overrides:
# Broadest scope: applies to every node. Weakest override layer --
# higher layers (tag / meta / per-node) can still override any key.
all:
worker_type: G.2X # baseline sizing for every node
# Bulk by tag, per-node fan-out (mode: single is the default).
tag.landing:
runner: shell # dispatch: pick a named runner
target: shell_dev # dbt --target
profile_name: shell_prof # dbt --profile
name: landing # optional -- task-id prefix
# (yields ``landing__model__proj__x``)
# Bulk by tag, GROUP mode -- collapses into one Airflow task.
tag.bronze:
mode: group
name: bronze_batch # optional -- defaults to "bronze"
runner: spark
worker_type: G.2X
number_of_workers: 4
command: build
tag.gold:
mode: group # ``name:`` defaults to ``gold``
runner: spark
target: prod
full_refresh: true
# Per-node -- keyed by unique_id. Wins over tag entries per field.
# Meta-keys ``mode:`` and ``name:`` are rejected on per-node entries.
model.proj.heavy_aggregate:
worker_type: G.4X
number_of_workers: 8
timeout_minutes: 240
model.proj.hotfix:
runner: shell
target: shell_dev
profile_name: shell_prof
command: build
# === VISUAL GROUPING ===================================================
# task_groups: below is INDEPENDENT of overrides[tag.*].mode above --
# it controls collapsible folders in the Airflow Graph view (one task
# per dbt node still) rather than task collapse.
task_groups:
- name: bronze
tags: [bronze]
- name: silver
tags: [silver]
ungrouped_group: other # optional; null/omitted -> unmatched at DAG root
Allowed top-level keys: runner, runners, default_runner, overrides,
task_groups, ungrouped_group, openlineage, resource_tags. Anything
else raises RunnerConfigError.
Removed top-level keys
The following top-level YAML keys were removed and now raise
RunnerConfigError at load time with a per-key migration hint:
| Removed key | Since | Replacement |
|---|---|---|
tag_runners: |
— | overrides[tag.<t>]: {runner: ...} |
tag_profiles: |
— | overrides[tag.<t>]: {profile_name: ...} |
tag_targets: |
— | overrides[tag.<t>]: {target: ...} |
tag_groups: |
removed | overrides[tag.<t>]: {mode: group, name: ...} |
Field schema is per-runner
Every field under overrides[tag.<t>] or overrides[<uid>] is validated
against the SELECTED runner's OVERRIDE_TYPE. Unknown fields raise
RunnerConfigError naming the accepted field list.
Selectors are NOT in the YAML schema
select / exclude are deliberately omitted from the runner YAML -- they are
DAG-level concerns, not runner-level. Pass them on DbtDag(select=...,
exclude=...). This keeps the runner YAML reusable across multiple DAGs (one
runners.yml, N DAG files each with their own subset).
overrides.all:¶
Broadest-scope override entry inside the overrides: block. Applies
to every node in the rendered graph (after select / exclude
filtering). Sits at layer 2 in the precedence ladder
— above runner defaults, below tag / meta / per-node.
Same field schema as tag.<name>: entries:
- Accepts every field the selected runner's
OVERRIDE_TYPEsupports (worker_type,command,full_refresh,vars_json,resource_tags, ...). - Accepts the meta-keys
mode: single | groupandname: <str>. runner:sets a graph-wide dispatch default (weaker than any tag / meta / per-noderunner:).
mode: single (default) with name: <prefix>¶
Every node's Airflow task-id gets prefixed with <name>__. Useful
for "prefix every task with dev_" in a shared workspace.
Resulting task-ids: dev__model__proj__orders,
dev__model__proj__customers, etc.
mode: group¶
Collapses every eligible node into ONE Airflow task per
(name, runner) bucket. name: defaults to the literal string
"all".
overrides:
all:
mode: group
name: dbt_all # optional -- defaults to "all"
command: build # every node in the group runs ``dbt build``
One task, id dbt__dbt_all__<runner>, running
dbt build --select <every node name> on the assigned runner.
Pull-out via per-model override¶
A node with any per-model override (in overrides[<uid>] or
node.meta.stratus, beyond the dispatch-only runner key) gets
pulled OUT of the group into a singleton task. Same pull-out rule
as overrides[tag.<t>]: {mode: group}.
overrides:
all:
mode: group
name: dbt_all
# ``hotpath`` runs on its own Airflow task with G.8X. The rest of
# the graph still batches under ``dbt__dbt_all__<runner>``.
model.proj.hotpath:
worker_type: G.8X
If pulling a node out disconnects the remaining graph into multiple
components, the collapse pass gives each component its own task with
a __<idx> suffix (dbt__dbt_all__spark__0, __1, ...).
Test-mode idiom¶
The expected use case for all: {mode: group} is one-shot
test / dev / CI runs where you want every model to run in a single
Glue Job / Session / Application. Not intended for production DAGs
— the collapsed task loses per-node retry granularity.
runners:
test_all:
type: glue_spark
mode: attach
job_name: dbt-test-mode
aws_conn_id: aws_default
region_name: us-east-1
default_runner: test_all
overrides:
all:
mode: group
name: dbt_all
command: build
Top-level resource_tags:¶
AWS resource tags applied to every runner in the file. Merges per key
over any runner-level resource_tags: (runner keys win on conflict).
Typical use: declare cost-centre / environment / owner once at the
top, override per-runner-specific bits where needed.
# Cost / compliance tags applied to every AWS resource created by
# this DAG's runners.
resource_tags:
CostCenter: data-platform
Environment: prod
Owner: analytics-team
runners:
spark:
type: glue_spark
mode: create
# No ``resource_tags:`` block -- inherits all three above.
shell:
type: glue_python_shell
mode: create
resource_tags:
# Inherits CostCenter + Environment; overrides Owner;
# adds Runtime.
Owner: platform-team
Runtime: python-shell
Semantics:
- Applies at AWS
create_*time --mode='create'runners only. Undermode='attach'the AWS resource pre-exists and IaC (Terraform / CloudFormation) owns its tags;resource_tagsis silently ignored. - Layered.
resource_tagscan also live underoverrides[<uid>].resource_tags,overrides[tag.<t>].resource_tags, and dbt-sidemeta.stratus.resource_tags. All layers shallow-merge per key (later wins). Runner-level tags remain the baseline cascade. - Caller-supplied
Tagsinsidecreate_job_kwargsmerges per Key on top ofresource_tags(caller wins). - AWS constraints enforced at DAG-parse time: keys and values must be
non-empty strings, key <= 128 chars, value <= 256 chars, no
aws:prefix. Violations raiseResourceTagsError.
Per-runner AWS API surface (what the boto3 call receives):
| Runner | AWS API | Tag kwarg shape |
|---|---|---|
glue_spark |
glue:CreateJob |
Tags: dict[str, str] on create_job_kwargs |
glue_python_shell |
glue:CreateJob |
Tags: dict[str, str] on create_job_kwargs |
glue_interactive_session |
glue:CreateSession |
Tags: dict[str, str] (session-scoped) |
The library handles the per-service kwarg-shape conversion -- callers
always supply resource_tags: dict[str, str] regardless of the target
runner.
Per-model + per-tag Spark config (spark_conf, )¶
See also: Example →
spark_confvs--conffor a copy-paste demo, a walkthrough of the merged output at each layer, and a list of the common mistakes.
GlueSparkRunner accepts a spark_conf: dict[str, str] kwarg that
Glue Spark reads as --conf key=value fragments. The library resolves
an effective config per node from three layers, shallow-merged (later
wins per key):
runners.<name>.spark_conf-- runner-level baseline, applied to the Job'sDefaultArguments["--conf"]at create/update.overrides[tag.<name>].spark_conf-- bulk-by-tag layer.overrides[<unique_id>].spark_confor dbt-sidemeta.stratus.spark_conf-- per-model layer.
The merged result lands in the JobRun's --conf argument
(StartJobRun-level override), so concurrent runs of the same Job with
different per-node spark_conf don't race.
runners:
spark:
type: glue_spark
mode: create
iam_role_name: AWSGlueServiceRole
deploy_bucket: my-glue-bucket
spark_conf: # runner-level baseline
spark.sql.adaptive.enabled: "true"
spark.sql.shuffle.partitions: "200"
overrides:
tag.bronze: # bulk-by-tag layer
spark_conf:
spark.sql.shuffle.partitions: "400" # replaces the runner value
model.my_project.fct_orders_daily: # per-model layer (wins)
spark_conf:
spark.sql.shuffle.partitions: "800" # beats tag.bronze
spark.sql.autoBroadcastJoinThreshold: "-1"
For fct_orders_daily (tagged bronze), the effective --conf is:
--conf spark.sql.adaptive.enabled=true
--conf spark.sql.shuffle.partitions=800
--conf spark.sql.autoBroadcastJoinThreshold=-1
Escape hatch: spark_conf_replace¶
Set spark_conf_replace: {...} on any layer (runner, tag, meta,
per-node) to REPLACE the merged spark_conf from all lower layers
entirely, instead of shallow-merging. Use when a single model needs a
completely different profile from the runner defaults.
overrides:
model.my_project.dim_special:
spark_conf_replace: # discards runner + tag layers
spark.sql.adaptive.enabled: "false"
spark.sql.shuffle.partitions: "50"
Effective --conf for dim_special: --conf spark.sql.adaptive.enabled=false --conf spark.sql.shuffle.partitions=50 (runner-level spark.sql.adaptive.enabled=true is dropped).
Runtime-only limitation¶
spark_conf and spark_conf_replace land in the JobRun --conf
argument, so they cover DRIVER / EXECUTOR runtime configs
(spark.sql.*, spark.default.parallelism, spark.rdd.compress,
...). Configs that must be set before the JVM starts
(spark.jars.packages, spark.hadoop.fs.*, spark.driver.memory,
spark.executor.memory, ...) MUST live in the runner's
create_job_kwargs.DefaultArguments -- Glue silently ignores those
at StartJobRun time.
Validation¶
All layers are validated at DAG-parse:
- keys must match
^[a-zA-Z][a-zA-Z0-9._-]*$(Glue's--confparser splits on spaces; any space in a key silently drops the config) - keys must not start with
--conf(footgun: copy-pasting fromspark-submit) - values must be non-empty strings (numbers cast to
strat the YAML side)
Violations raise RunnerConfigError naming the offending key or
value.
Runner shapes¶
Each type: key maps to a concrete Runner class. The rest of the runner block is
forwarded as kwargs to its constructor (so YAML keys mirror the Python kwargs 1:1).
type: glue_spark¶
runners:
spark:
type: glue_spark
mode: create # or "attach"
iam_role_name: AWSGlueServiceRole
deploy_bucket: my-glue-bucket
deploy_prefix: dbt-aws
create_job_kwargs:
DefaultArguments:
"--additional-python-modules": "dbt-aws,dbt-core==1.11.11,dbt-duckdb==1.10.1"
"--job-language": "python"
GlueVersion: "5.0"
WorkerType: G.1X
NumberOfWorkers: 2
ExecutionProperty:
MaxConcurrentRuns: 5
update_config: true # push CreateJob changes to existing Jobs
aws_conn_id: aws_default
region_name: eu-west-1
upload_artefacts_s3_prefix: "s3://my-glue-bucket/dbt-aws/glue_spark/"
type: glue_interactive_session¶
runners:
warm:
type: glue_interactive_session
iam_role_arn: arn:aws:iam::123:role/AWSGlueServiceRole
reusable: true # one session shared (false = per-node)
session_id_prefix: dbt-aws-warm
additional_python_modules: "dbt-aws,dbt-core==1.11.11,dbt-duckdb==1.10.1"
default_arguments:
glue_version: "5.0"
worker_type: G.1X
number_of_workers: 2
idle_timeout_minutes: 15
timeout_minutes: 45
aws_conn_id: aws_default
region_name: eu-west-1
upload_artefacts_s3_prefix: "s3://my-glue-bucket/dbt-aws/session_warm/"
type: glue_python_shell¶
Glue 3.0 Python Shell limitations
See Troubleshooting.
type: emr_cluster_step¶
runners:
emr_cluster:
type: emr_cluster_step
mode: create # or "attach" to an existing cluster
reusable: true # share one cluster across all steps
auto_terminate: true
deploy_mode: client # recommended for dbt (no SparkContext needed)
pyspark_python: /usr/bin/python3.11 # EMR 7.5+ has both 3.9 + 3.11; force 3.11
driver_cores: 2
driver_memory: 4g
executor_cores: 2
executor_memory: 4g
num_executors: 1
deploy_bucket: my-glue-bucket
deploy_prefix: dbt-aws
job_flow_overrides:
Name: dbt-aws-cluster
ReleaseLabel: emr-7.5.0
# ... full EMR RunJobFlow spec (Instances, Roles, BootstrapActions, LogUri)
aws_conn_id: aws_default
region_name: eu-west-1
upload_artefacts_s3_prefix: "s3://my-glue-bucket/dbt-aws/emr_cluster/"
type: emr_serverless¶
runners:
emr_sl:
type: emr_serverless
mode: attach # "create" also supported
application_id: 00abc123...
execution_role_arn: arn:aws:iam::123:role/EmrServerlessJobRole
deploy_bucket: my-glue-bucket
deploy_prefix: dbt-aws
aws_conn_id: aws_default
region_name: eu-west-1
Common kwargs on every runner¶
These apply to all runner shapes:
runners:
<name>:
type: <shape>
with_deps: true # default true -- workers install packages.yml deps
full_refresh: false
vars_json: null
upload_artefacts_s3_prefix: null
state_s3: null
defer: false
profile_name: null
env_vars_json: null
dbt_extra_flags: []
Validation¶
Every error raises RunnerConfigError at load_runner_config(...) call time:
| Failure | Result |
|---|---|
| Unknown top-level key | Error naming the key + valid keys |
Both runner: and runners: set |
Mutually exclusive |
| Neither set | Must declare one |
runners: set without default_runner: |
default_runner is required |
default_runner: not a key in runners: |
Error |
Unknown runner type: |
Error listing valid types |
| Missing required runner kwarg | Error from the runner's __init__ |
Override field not on the selected runner's OVERRIDE_TYPE |
Error |
Tag mapped to two different runners in tag_runners: |
Error |
Two mode: create runners with the same resolved job_name |
Error (would race on glue:CreateJob) |
Rejected top-level keys: tag_runners, tag_profiles, tag_targets, tag_groups¶
These four top-level tag routing / grouping keys are rejected by the
loader. Use overrides[tag.<t>]: entries instead:
# NOT accepted -- loader raises RunnerConfigError
tag_runners:
landing: shell
tag_profiles:
landing: shell_prof
tag_targets:
landing: shell_dev
tag_groups:
gold:
name: gold_batch
command: build
# The unified form ---------------------------------------------
overrides:
tag.landing:
runner: shell
profile_name: shell_prof
target: shell_dev
tag.gold:
mode: group
name: gold_batch
command: build
Why the unified shape:
- One place per tag. All the settings that make a tag "landing" live under one key -- no split brain across four top-level maps.
- More fields. The unified form accepts every field the runner's
OVERRIDE_TYPEsupports --worker_type,command,full_refresh,vars_json,timeout_minutes, etc. The rejected forms were limited to a single field each. - No silent drops. Adding a fifth per-tag knob doesn't need a fifth top-level key.
- Discoverable via error. Loading a rejected YAML shape raises a hard error naming the key and printing an inline migration example.
Mixing YAML + Python¶
Pass config=cfg for the YAML values and layer explicit kwargs on top for
Python-side changes. Explicit kwargs win per field.
cfg = load_runner_config("runners.yml")
dag = DbtDag(
dag_id="my_dbt",
project=...,
project_archive_s3="...",
config=cfg, # every YAML field
overrides={ # merged with cfg.overrides
**cfg.overrides,
"model.proj.special": {"runner": "session_per_node"},
},
)
Historically you had to forward every field manually. That pattern still works (see the "Advanced" tab at the top of this page) but has to enumerate every field the YAML uses -- forgetting one is a silent drop.