Removing Metadata-Database Access from Airflow Task Code

In Airflow 2, task code ran with the same database access as the scheduler. Nothing stopped a task from opening a session, querying TaskInstance, and making decisions based on what it found. Over years, estates accumulate a surprising amount of this. In Airflow 3, tasks run through the Task Execution API and do not get a database connection at all, so every one of these patterns has to go before cutover.

This is usually the hardest part of a migration, because the access is rarely in the DAG file. It lives in a shared helper, a callback, or a custom operator written by someone who has since left.

Finding it

Search the DAG folder, the plugins folder and every internal package the DAGs import for:

  • provide_session, create_session, settings.Session
  • imports of DagRun, TaskInstance, DagModel, XCom or Variable from airflow.models used inside a callable
  • raw SQL against tables like dag_run, task_instance or xcom
  • callbacks (on_failure_callback, on_success_callback) that query state

Module-level imports are not the problem by themselves; what matters is whether the code runs inside a task or callback. Make a list with the file, the function, and which DAGs reach it.

Pattern 1: "Did the upstream DAG succeed today?"

A task queries DagRun for another DAG and fails or skips if the latest run did not succeed. This was a cheap way to build cross-DAG dependencies.

Replace with: asset-based scheduling when the real dependency is "this data has been produced", or an external task sensor in deferrable mode when the dependency really is "that DAG run finished". Asset scheduling is usually the better fit, because it expresses what the downstream DAG actually needs, and it removes the timing assumptions buried in the original query.

Pattern 2: Reading XComs from another DAG

A task opens a session and pulls XCom values pushed by a different DAG, often by querying the table directly.

Replace with: passing the value explicitly. If the producer and consumer are tightly coupled, the consumer can be triggered with the value in its run configuration. If the value is really a pointer to data (a path, a partition, a batch id), write it to the place the data lives and let the consumer read it there. XCom was never a good cross-DAG message bus; Airflow 3 just makes that official.

Pattern 3: Housekeeping DAGs

A maintenance DAG deletes old XComs, task logs or DAG runs by issuing SQL against the metadata database.

Replace with: the airflow db clean command, run as a scheduled job outside task code, or the cleanup facilities your managed platform provides. On self-managed deployments, we usually run it as a Kubernetes CronJob with its own credentials, so database access lives in infrastructure rather than in a DAG.

Pattern 4: Dynamic behaviour based on run history

A task looks at its own previous task instances to decide what to do: skip if the last run was recent, widen the date range if the last run failed, and so on.

Replace with: the information the task context already provides (the data interval, the previous successful data interval end where available), or explicit state kept in a place you own, such as a watermark table in your warehouse. A watermark table is often clearer than the original logic anyway, because it records what was processed rather than inferring it from scheduler state.

Pattern 5: Callbacks that enrich alerts

A failure callback queries the database to add context to an alert: how many times this task failed this week, who owns the DAG, what the last successful run was.

Replace with: the context passed to the callback, plus owner and runbook metadata stored in DAG tags or params. For historical failure counts, query the stable REST API from your alerting system rather than from inside Airflow. This also moves alert formatting out of the task path, so a broken alert can no longer change a task's outcome.

Variables and connections

Variable.get and connection lookups still work in Airflow 3, but they go through the Task SDK and the API server, not a database session. Import them from the SDK. If you have code that reads variables by querying the table directly, that is pattern-matching on the old architecture and needs the same treatment as everything above.

Test it before cutover

The only reliable test is running every affected DAG in an Airflow 3 environment where the task workers genuinely cannot reach the metadata database. Do not test in an environment where the network happens to allow it; a forgotten path will keep working until production. Then compare outputs against the Airflow 2 run in a dual-run harness.

The real-estate caveat

Some of these patterns exist because the DAG design was working around something, and the cleanest fix is a redesign rather than a one-for-one replacement. Budget for that. And when a DAG turns out to be mostly database queries about other DAGs, ask whether it still needs to exist at all.