Airflow & dbt
A green DAG and a wrong number are not mutually exclusive
Orchestrated stacks are well instrumented, and a task that exits zero can still have loaded the wrong rows. This page covers where to look, the queries to run, and how Decim investigates an Airflow or dbt incident from code and logs.
Why a green DAG proves so little
Airflow records whether a process exited zero. That is its job, and it is a different question from whether the data is right.
This is not a criticism of Airflow. A scheduler that tried to have opinions about row counts would be a worse scheduler, and the separation is correct. But it does mean the green tick in the UI is answering a narrower question than most people read it as answering.
Consider the ways a task exits zero having done the wrong thing. It queried a source that was
empty and wrote nothing. It used a date bound computed from an execution date that was not
what anyone expected, and loaded a window that had already been loaded. It retried after a
partial commit and wrote half the rows twice. A sensor with soft_fail=True did
not find its file, skipped, and downstream ran anyway against yesterday's data.
Every one of those produces a DAG run that is entirely green. None of them raise an exception, because none of them are errors in the sense the runtime understands — they are correct executions of an instruction that was wrong, or correct executions against input that was missing.
What the DAG file does not contain
The same problem as SSIS and SQL Agent, in a more modern costume: much of what decides behaviour lives in a database, not a repository.
The DAG file is genuinely in version control, which makes it tempting to treat as the definition. It is not. Connections, variables, pools, paused state and every manual intervention live in the metadata database, and a change to any of them alters what runs with no diff for anyone to review.
| What | Where it lives | In git? | Consequence |
|---|---|---|---|
| Connections and credentials | Metadata database / secrets backend | No | A repointed connection changes the target with no diff anywhere |
| Variables | Metadata database | No | Frequently hold thresholds, feature flags and date bounds |
| Pools and concurrency | Metadata database | No | Decides what actually ran in parallel, and what starved |
| Dynamically generated DAGs | A config table or a YAML file read at parse time | Partly | The repository holds the generator, not the graph |
| Paused / unpaused state | Metadata database | No | A paused DAG produces no failures and no rows |
| Manual clears and marks | Metadata database | No | Someone marking a task success leaves no trace outside Airflow |
The dynamically generated DAG deserves particular attention, because it is the same structure as the config-driven engine described under custom ETL services. When a DAG is generated by iterating over rows in a config table, the repository holds the generator. The graph — the thing that actually ran — is data, and reading the Python tells you how the loop works and nothing about what it produced.
Decim's pipeline discovery reads the repository today, so it reads the generator, and the config file too if that is committed. A graph generated from rows in a database table is not visible to it; what it shows is the steps the code makes explicit, with each connection marked direct or inferred. Combining code with runtime observation in the topology is the direction, not a shipped feature.
Where dbt tests stop
They validate the rows in the table. They are structurally unable to see the rows that never arrived.
A well-tested dbt project is a genuine asset, and unique, not_null,
accepted_values and relationships catch a great deal. What they all
share is that they are assertions over the rows present. If every EUR row were filtered
out upstream and never reached the model, every one of those tests would pass, because
everything that did arrive is perfectly valid.
The tests that would catch it are the ones about arrival rather than content — source freshness, recency, and a row-count comparison against upstream. These are the tests most projects skip, partly because they are less obviously about "quality" and partly because a naive absolute threshold becomes noisy within a quarter.
# The dbt test that would have caught it — and why most projects lack it.
# freshness and row_count checks assert something about arrival, which is
# exactly what a model-level test cannot see.
sources:
- name: raw
schema: raw
tables:
- name: transactions
loaded_at_field: ingested_at
freshness:
warn_after: { count: 2, period: hour }
error_after: { count: 6, period: hour }
tests:
- dbt_utils.recency:
datepart: hour
field: ingested_at
interval: 6
models:
- name: fct_transactions
tests:
# Compares this run against the trailing average rather than a fixed
# bound. A hardcoded threshold is either noisy or useless within a quarter.
- dbt_utils.equal_rowcount:
compare_model: ref('stg_transactions')
Note equal_rowcount comparing against ref('stg_transactions') rather
than a hardcoded number. A fixed bound is either so loose it never fires or so tight it fires
every time volume moves; a comparison against upstream asks the question that actually
matters, which is whether this model lost rows relative to its own input.
Even so, this only catches loss inside the dbt boundary. Rows diverted before they
reached raw.transactions are outside every test in the project, which is where
source freshness earns its place: it is the only assertion in a dbt project that can fail
because something did not happen.
| Failure mode | Cause | What proves it |
|---|---|---|
| Task succeeded, wrote nothing | Empty source, silent early return, wrong date bound | Row count per partition against the trailing baseline |
| Task succeeded, wrote twice | A retry after a partial commit, non-idempotent insert | Duplicate keys per partition; count against distinct count |
| dbt tests all pass | Tests validate the rows present, not the ones absent | Source freshness and equal_rowcount against upstream |
| Sensor timed out into success | soft_fail=True turns a missing file into a skip | Sensor task state against the manifest of expected files |
| Backfill overlapped a scheduled run | Two writers, same partition | Concurrent run ids writing the same load_date |
| A connection was repointed | Metadata change, no commit | Connection host history against the first bad run |
Reconciling across tasks
The check nothing in the stack performs: did the number of rows that entered a stage match the number that left it?
Airflow knows tasks ran. dbt knows models built. Neither compares a count at one stage against a count at the next, because neither owns both stages. That comparison is the entire substance of most data-loss investigations, and on most stacks nobody is doing it.
Where OpenTelemetry or OpenLineage is emitted, this becomes arithmetic rather than inference — per-stage counts arrive as span attributes and the reconciliation is exact. Where it is not, counts have to be read from the tables themselves, which works but ties you to what the current state happens to be.
Backfills and the idempotency assumption
The most common way a modern pipeline produces wrong numbers while everything reports success.
Every backfill rests on an assumption: that re-running a task for a past interval produces the
same result as running it the first time. When the model is a pure transformation of an
immutable source, that holds. It stops holding the moment the source is mutable, the load uses
INSERT rather than a merge on a key, or the task derives its window from wall
clock time rather than the logical date.
The failure is quiet and it compounds. A backfill overlapping a scheduled run puts two writers on the same partition; both succeed; the partition now holds roughly twice the rows for part of its range. Nothing errors, no test on uniqueness fires unless there is a genuine key constraint, and the discrepancy surfaces weeks later in a report that nobody can reconcile.
The tell is a partition whose row count is anomalous in the upward direction, which is why a reconciliation should flag deviation in both directions. Loss is what people look for; duplication is what they find later.
A worked sequence
Same method as every other stack: establish an interval before widening the search.
- 1
Find the first run that was wrong, not the first that failed
These are almost never the same run. Work from row counts per partition, because task state cannot distinguish a load that wrote 400,000 rows from one that wrote zero — both are
success.Partitions that should exist, and what is actually there -- The reconciliation the DAG cannot do: rows per partition, against the -- partitions that were supposed to be written. Absent partitions are the -- finding — and they are invisible to any query that only reads what exists. WITH expected AS ( SELECT generate_series( date_trunc('day', now() - interval '14 days'), date_trunc('day', now()), interval '1 day' )::date AS load_date ), actual AS ( SELECT load_date, count(*) AS rows FROM analytics.fct_transactions WHERE load_date >= now() - interval '14 days' GROUP BY load_date ) SELECT e.load_date, coalesce(a.rows, 0) AS rows, CASE WHEN a.load_date IS NULL THEN 'MISSING PARTITION' END AS flag FROM expected AS e LEFT JOIN actual AS a USING (load_date) ORDER BY e.load_date; - 2
Check duration against the task's own history
This is the one genuinely strong signal the metadata database holds. A task whose median runtime is four minutes and which completed in twenty seconds did not do its job quickly — it did nothing successfully.
Duration against a 30-run trailing average -- The useful question the metadata database *can* answer: did a task that -- succeeded take a suspiciously different amount of time than usual? -- A load that normally runs 4 minutes and finished in 20 seconds succeeded -- at doing nothing. SELECT ti.task_id, ti.run_id, EXTRACT(epoch FROM (ti.end_date - ti.start_date)) AS duration_s, AVG(EXTRACT(epoch FROM (ti.end_date - ti.start_date))) OVER (PARTITION BY ti.task_id ORDER BY ti.start_date ROWS BETWEEN 30 PRECEDING AND 1 PRECEDING) AS trailing_avg_s FROM task_instance AS ti WHERE ti.dag_id = 'load_transactions' AND ti.state = 'success' AND ti.start_date >= now() - interval '30 days' ORDER BY ti.start_date DESC; - 3
Read task state around the window
Now that you have a bounded interval, look at what else happened in it: retries, upstream skips, sensors that soft-failed, and any task whose
try_numberis above one. A retry after a partial write is the usual explanation for a duplicated partition.Task instances in the window -- Airflow's metadata database is the scheduler's own record. Task state -- tells you whether the process exited zero — nothing more. SELECT ti.dag_id, ti.task_id, ti.run_id, ti.state, ti.start_date, ti.end_date, EXTRACT(epoch FROM (ti.end_date - ti.start_date)) AS duration_s, ti.try_number, ti.hostname FROM task_instance AS ti WHERE ti.dag_id = 'load_transactions' AND ti.start_date >= now() - interval '3 days' ORDER BY ti.start_date DESC, ti.task_id; - 4
Check what changed outside the repository
Connections, variables, pool sizes and paused state. This is where an orchestrated stack fails in the same way a SQL Agent estate does — the change that caused the incident was made in a UI and left no diff. Compare the metadata database against the last known-good run rather than against the repository.
- 5
Only now, correlate with commits
With an interval established, the DAG file and dbt model history become useful. Starting here instead produces a plausible commit and a wrong answer, because on any active project there is always a plausible commit.
Bound the search to the interval you proved git log --since="2026-08-05 01:50" --until="2026-08-05 02:15" \ -- dags/ models/ --name-only --pretty=format:"%h %an %ad %s"
How Decim investigates an orchestrated pipeline
Decim reads the DAGs and models as code, reads the task logs, and writes the queries for evidence held in the metadata database or the warehouse.
The sequence above is the one Decim follows. What differs on this stack is where each piece of evidence lives and which of those places Decim can reach today. There is no Airflow or dbt connector yet, so the boundary is worth stating exactly.
| Evidence | How Decim reaches it | Status |
|---|---|---|
| DAG files, operators and dbt models committed to git | Read from the GitHub repositories you select and turned into discovered pipelines, with the file and line range behind every step | Today |
Commits and pull requests to dags/ and models/ | Read from GitHub and used to connect a change to the first bad run | Today |
| Airflow task logs | Google Cloud Logging sources you define (Cloud Composer writes task logs there), or exported logs attached to the incident | Today |
run_results.json, manifest.json, screenshots | Attached to the incident as files, up to 500 MB each | Today |
| The incident ticket | Jira Cloud issue imported as a snapshot: description, comments, attachments | Today |
task_instance, connections, variables, partition row counts | A read-only query Decim writes and your team runs; Decim never executes it | Via a check |
| Airflow metadata database, REST API, dbt Cloud, warehouses | Native Airflow, dbt, Snowflake, BigQuery and Redshift connectors | Planned |
Discovery from the repository
Install the GitHub App on the repository that holds dags/ and
models/. Decim reads the code and finds each pipeline by its trigger, which for a
DAG is usually its schedule. It builds the step graph from the tasks and the models they run,
with sources, transformations and destinations, each cited by file and line range. There is no
Airflow-specific parser; Decim reads Python and SQL the way it reads any code. A DAG generated
at parse time from a committed YAML file is visible through that file; one generated from rows
in a database is not.
What an investigation would look like
Say the report is “yesterday's partition of fct_transactions is empty and
every task in load_transactions is green”, linked to the Jira ticket the
analytics team raised. In the locate phase Decim ranks the discovered pipelines
against that description, picks the DAG that writes fct_transactions, and cites
the code that made the match.
It then reads the logs step by step, most suspicious first. If the environment runs on Cloud Composer, the task logs are already in Cloud Logging and a log source filter on the DAG id is enough. Each task's logs are compared with successful runs from up to 30 days back, which is where a load task that usually logs a row count and this time logged none, or finished far sooner than usual, stands out. Every finding cites the exact log lines it relied on. A task with no logs to read ends as “no logs”, not as “looked fine”.
In the weigh phase Decim holds up to five competing explanations: an empty source, a date bound computed from the wrong logical date, a sensor that soft-failed into a skip, a connection repointed outside the repository. Each carries the evidence for and against it, the code involved and the test that would settle it, and an independent critique pass challenges them.
Several of those tests need the metadata database or the warehouse, which Decim does not read.
To settle them it writes checks: a task_instance duration query like the
one above, a partition count against the expected dates, validated as read-only and run by your
team. Each result goes back in as evidence and starts a new numbered revision. Rejected
explanations stay on the record with what ruled them out.
Decim concludes only with corroborating evidence. If the cause is in the code, such as a date bound or a model filter, the fix is a repository patch, checked to apply cleanly and opened as a draft pull request; a data repair comes as SQL for your team to review and run. Nothing is merged or executed automatically.
What it does not do yet
Decim does not read the Airflow metadata database, the REST API, dbt Cloud or the warehouse, and it does not run SQL. Logs from a self-hosted Airflow that does not ship to Cloud Logging reach the incident as attachments, since the Windows agent does not run on Linux hosts. Native Airflow and dbt connectors, AWS CloudWatch Logs reading and a Linux agent are planned.
Common questions
We use Airflow properly. Is there really a gap?
Doesn't OpenLineage solve this?
How is this different from Monte Carlo or Bigeye?
Do you need access to our Airflow metadata database?
task_instance, validated against a read-only allowlist, for your team to run. A native Airflow connector is planned.What about dbt Cloud rather than dbt Core?
run_results.json gives per-model status and timing, and manifest.json gives the model graph and test definitions; you can attach both to an incident as evidence. Together they answer which models ran, which tests were defined, and, often more usefully, which were not.Our pipeline is half Airflow and half a SQL Agent job.
msdb are not seen today and are reached through checks. See SQL Server & SSIS for the other half.Related: SQL Server & SSIS and custom ETL services for the stacks a modern platform usually sits alongside; investigations for the method; and all solutions by stack, role and failure mode.
Get started
Bring us a pipeline that broke last week
The fastest way to evaluate this is a real incident you already know the answer to. If Decim gets it wrong, that is a far more useful demo than one where it doesn't.